- Allow fallback to any quality if preferred qualities unavailable
+ Allow fallback to any quality if no target is available
- How it works: Downloads try each enabled quality in priority order
- (1 = highest).
- MIN bitrate catches fake/transcoded files (e.g., FLAC below 500 kbps is likely a
- re-encoded MP3). MAX bitrate limits hi-res files if you want to save space.
- When track duration is unavailable, a generous file-size safety net is used instead.
+ How it works: Each download source is checked against this list
+ top-down. The first target a source can satisfy wins; a source that meets no target
+ is skipped for the next one (source priority still decides between sources that can).
+ For lossless, bit depth + sample rate decide the match. For MP3/AAC the bitrate is a
+ minimum threshold (≥), so VBR and mono files aren't falsely rejected. After
+ download the real file is verified against the same list. With fallback off, a track
+ is left missing rather than accepting a quality below every target.
diff --git a/webui/static/helper.js b/webui/static/helper.js
index 60bba85a..221dfe18 100644
--- a/webui/static/helper.js
+++ b/webui/static/helper.js
@@ -1872,9 +1872,9 @@ const HELPER_CONTENT = {
title: 'Quality Preset',
description: 'One-click quality configuration. Presets set all format enables, priorities, and bitrate ranges at once.',
},
- '.bit-depth-btn': {
- title: 'FLAC Bit Depth',
- description: 'Prefer 16-bit (CD quality, smaller), 24-bit (hi-res, larger), or Any. When a specific depth is chosen, the fallback toggle controls whether other depths are accepted.',
+ '.ranked-targets-editor': {
+ title: 'Quality Priority List',
+ description: 'Ordered list of acceptable qualities (1st = most preferred). Each source is checked top-down; the first target it can satisfy wins. Lossless matches on bit depth + sample rate; MP3/AAC use a minimum bitrate (≥) so VBR/mono files aren\'t falsely rejected. Drag to reorder.',
docsId: 'set-quality'
},
'#quality-fallback-enabled': {
diff --git a/webui/static/settings.js b/webui/static/settings.js
index af4bb3f7..381207e7 100644
--- a/webui/static/settings.js
+++ b/webui/static/settings.js
@@ -1904,175 +1904,132 @@ async function loadQualityProfile() {
}
}
+// v3: the working copy of the ordered target list. Mirrors the DOM rows
+// and is the single source of truth that collectQualityProfileFromUI reads.
+let currentRankedTargets = [];
+
+function rtLabel(t) {
+ const fmt = (t.format || 'any').toUpperCase();
+ if (t.format === 'flac' || t.format === 'wav') {
+ const bd = t.bit_depth ? `${t.bit_depth}-bit` : '';
+ const sr = t.min_sample_rate ? `≥${t.min_sample_rate / 1000}kHz` : '';
+ const detail = [bd, sr].filter(Boolean).join('/');
+ return detail ? `${fmt} ${detail}` : fmt;
+ }
+ return t.min_bitrate ? `${fmt} ≥${t.min_bitrate}kbps` : fmt;
+}
+
function populateQualityProfileUI(profile) {
// Update preset buttons
- document.querySelectorAll('.preset-button').forEach(btn => {
- btn.classList.remove('active');
- });
+ document.querySelectorAll('.preset-button').forEach(btn => btn.classList.remove('active'));
const activePresetBtn = document.querySelector(`.preset-button[onclick*="${profile.preset}"]`);
- if (activePresetBtn) {
- activePresetBtn.classList.add('active');
- }
+ if (activePresetBtn) activePresetBtn.classList.add('active');
- // Populate each quality tier
- const qualities = ['flac', 'mp3_320', 'mp3_256', 'mp3_192'];
- qualities.forEach(quality => {
- const config = profile.qualities[quality];
- if (config) {
- // Set enabled checkbox
- const enabledCheckbox = document.getElementById(`quality-${quality}-enabled`);
- if (enabledCheckbox) {
- enabledCheckbox.checked = config.enabled;
- }
+ // The API migrates v2 → v3, so ranked_targets is always present.
+ currentRankedTargets = Array.isArray(profile.ranked_targets)
+ ? profile.ranked_targets.map(t => ({ ...t }))
+ : [];
+ renderRankedTargets();
- // Set min/max sliders
- const minSlider = document.getElementById(`${quality}-min`);
- const maxSlider = document.getElementById(`${quality}-max`);
- if (minSlider && maxSlider) {
- minSlider.value = config.min_kbps;
- maxSlider.value = config.max_kbps;
- updateQualityRange(quality);
- }
-
- // Set priority display
- const prioritySpan = document.getElementById(`priority-${quality}`);
- if (prioritySpan) {
- prioritySpan.textContent = `Priority: ${config.priority}`;
- }
-
- // Toggle sliders visibility
- const sliders = document.getElementById(`sliders-${quality}`);
- if (sliders) {
- if (config.enabled) {
- sliders.classList.remove('disabled');
- } else {
- sliders.classList.add('disabled');
- }
- }
-
- // FLAC-specific: restore bit depth selector and fallback toggle
- if (quality === 'flac') {
- const bitDepthValue = config.bit_depth || 'any';
- document.querySelectorAll('.bit-depth-btn').forEach(btn => {
- btn.classList.toggle('active', btn.getAttribute('data-value') === bitDepthValue);
- });
- const bitDepthSelector = document.getElementById('flac-bit-depth-selector');
- if (bitDepthSelector) {
- if (config.enabled) {
- bitDepthSelector.classList.remove('disabled');
- } else {
- bitDepthSelector.classList.add('disabled');
- }
- }
- // Show/hide and restore fallback toggle
- const fallbackToggle = document.getElementById('flac-fallback-toggle');
- if (fallbackToggle) {
- fallbackToggle.style.display = bitDepthValue === 'any' ? 'none' : 'block';
- }
- const fallbackCb = document.getElementById('flac-bit-depth-fallback');
- if (fallbackCb) {
- fallbackCb.checked = config.bit_depth_fallback !== false;
- }
- }
- }
- });
-
- // Set fallback checkbox
const fallbackCheckbox = document.getElementById('quality-fallback-enabled');
- if (fallbackCheckbox) {
- fallbackCheckbox.checked = profile.fallback_enabled;
- }
+ if (fallbackCheckbox) fallbackCheckbox.checked = profile.fallback_enabled !== false;
}
-function updateQualityRange(quality) {
- const minSlider = document.getElementById(`${quality}-min`);
- const maxSlider = document.getElementById(`${quality}-max`);
- const minValue = document.getElementById(`${quality}-min-value`);
- const maxValue = document.getElementById(`${quality}-max-value`);
+function renderRankedTargets() {
+ const list = document.getElementById('ranked-targets-list');
+ if (!list) return;
+ list.innerHTML = '';
- if (!minSlider || !maxSlider || !minValue || !maxValue) return;
-
- let min = parseInt(minSlider.value);
- let max = parseInt(maxSlider.value);
-
- // Ensure min doesn't exceed max
- if (min > max) {
- min = max;
- minSlider.value = min;
+ if (currentRankedTargets.length === 0) {
+ list.innerHTML = '
No targets yet — add one below. '
+ + 'With fallback off this would reject every download.