feat(downloads): best-quality search mode + clearer fallback UI

Adds an opt-in search strategy toggle in the Quality Profile:

- priority (default): unchanged — first source in the hybrid chain that
  meets a quality target wins.
- best_quality: pool candidates from EVERY source per query and download
  them best→worst by actual audio quality; source order only breaks ties.

Implementation reuses existing plumbing so the retry system is untouched:
- engine.search_all_sources pools raw tracks across all configured,
  non-exhausted sources (no first-source short-circuit).
- candidates.order_candidates: new quality_first sort path — profile
  quality rank dominates, confidence/peer signals break ties. Priority
  path is byte-for-byte unchanged (regression-locked by tests).
- task_worker passes quality_first + targets through; skips the redundant
  hybrid-fallback block in best-quality mode (pool already covered it).
- Per-source retry budgets unchanged: a source that spends its budget is
  added to exhausted_download_sources and thus dropped from the whole
  pool. Independent of post_processing.retry_exhaustive.
- Query generator NOT touched.

Also clarifies the "Allow fallback" setting wording: it accepts OFF-LIST
quality as a last resort (not "walk down my list"), and notes that
lossy_copy.downsample_hires also bypasses the quality gate — the cause of
16-bit/MP3 files slipping through a 24-bit-only profile.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
dev 2026-06-14 18:46:01 +02:00
parent 31d85e59c9
commit d484046523
14 changed files with 464 additions and 25 deletions

View file

@ -460,6 +460,49 @@ class DownloadEngine:
)
return ([], [])
async def search_all_sources(self, query: str, source_chain,
timeout=None, progress_callback=None,
exclude_sources=None):
"""Best-quality mode: pool RAW tracks from EVERY configured source in
``source_chain`` instead of stopping at the first satisfying one.
Unlike :meth:`search_with_fallback`, no source short-circuits the
search the caller (orchestrator/worker) ranks the combined pool
bestworst by actual audio quality. ``exclude_sources`` drops sources
whose per-source retry budget is already spent (so their candidates
never re-enter the pool). Unconfigured / unregistered / raising sources
are skipped exactly like the fallback path. Returns
``(combined_tracks, combined_albums)``.
"""
excluded = {s.lower() for s in (exclude_sources or []) if s}
pooled_tracks = []
pooled_albums = []
for source_name in source_chain:
if source_name.lower() in excluded:
continue
plugin = self._plugins.get(source_name)
if plugin is None:
logger.info(f"Skipping {source_name} (not available)")
continue
if hasattr(plugin, 'is_configured') and not plugin.is_configured():
logger.info(f"Skipping {source_name} (not configured)")
continue
try:
tracks, albums = await plugin.search(query, timeout, progress_callback)
if tracks:
pooled_tracks.extend(tracks)
if albums:
pooled_albums.extend(albums)
except Exception as e:
logger.warning(f"{source_name} search failed: {e}")
logger.info(
"Best-quality pool: %d candidates across %s for: %s",
len(pooled_tracks), ', '.join(source_chain), query,
)
return (pooled_tracks, pooled_albums)
async def download_with_fallback(self, username: str, filename: str,
file_size: int, source_chain) -> Optional[str]:
"""Try each source in ``source_chain`` until one accepts the

View file

@ -28,6 +28,7 @@ from config.settings import config_manager
from core.download_engine import DownloadEngine
from core.download_plugins.registry import DownloadPluginRegistry, build_default_registry
from core.download_plugins.types import TrackResult, AlbumResult, DownloadStatus
from core.quality.selection import load_search_mode
logger = get_logger("download_orchestrator")
@ -344,6 +345,11 @@ class DownloadOrchestrator:
if not chain:
logger.warning("Hybrid search exhausted: no eligible sources after exclusion filter")
return [], []
if load_search_mode() == 'best_quality':
logger.info(f"Best-quality search ({''.join(chain)}): {query}")
return await self.engine.search_all_sources(
query, chain, timeout, progress_callback,
)
logger.info(f"Hybrid search ({''.join(chain)}): {query}")
return await self.engine.search_with_fallback(query, chain, timeout, progress_callback)

View file

@ -47,6 +47,55 @@ from core.runtime_state import (
logger = logging.getLogger(__name__)
def _priority_sort_key(r):
"""Today's confidence-first key: never download a high-quality WRONG file."""
return (
getattr(r, 'confidence', 0) or 0,
getattr(r, 'quality_score', 0) or 0,
getattr(r, 'upload_speed', 0) or 0,
-(getattr(r, 'queue_length', 0) or 0),
getattr(r, 'free_upload_slots', 0) or 0,
getattr(r, 'size', 0) or 0,
)
def _quality_first_sort_key(r, targets):
"""Best-quality key: the user's profile quality rank dominates; all the
priority-mode signals (confidence, speed, ) become tiebreakers.
Every candidate reaching this point already passed match filtering, so it
is "correct enough" ordering by quality among correct candidates is safe.
Candidates with no usable quality info, or that match no target, sort last
(never dropped). Lower target index = better target, so it's negated to fit
the descending (reverse=True) sort.
"""
from core.quality.model import rank_candidate
aq = getattr(r, 'audio_quality', None)
if aq is None or not targets:
target_idx, tier = (len(targets) if targets else 0), 0.0
else:
try:
target_idx, tier = rank_candidate(aq, targets)
except Exception:
target_idx, tier = len(targets), 0.0
return (-target_idx, tier) + _priority_sort_key(r)
def order_candidates(candidates, *, quality_first=False, targets=None):
"""Return *candidates* ordered best-first for the download walk.
``quality_first=False`` (priority mode) confidence-first, byte-for-byte
today's behaviour. ``quality_first=True`` (best-quality mode) → the user's
profile quality rank dominates, confidence/peer signals break ties.
"""
if quality_first:
key = lambda r: _quality_first_sort_key(r, targets or [])
else:
key = _priority_sort_key
return sorted(candidates, key=key, reverse=True)
@dataclass
class CandidatesDeps:
"""Bundle of cross-cutting deps the candidate-fallback logic needs."""
@ -59,25 +108,25 @@ class CandidatesDeps:
on_download_completed: Callable
def attempt_download_with_candidates(task_id, candidates, track, batch_id=None, deps: CandidatesDeps = None):
def attempt_download_with_candidates(task_id, candidates, track, batch_id=None,
deps: CandidatesDeps = None, *,
quality_first=False, quality_targets=None):
"""
Attempts to download with fallback candidate logic (matches GUI's retry_parallel_download_with_fallback).
Returns True if successful, False if all candidates fail.
``quality_first`` (best-quality search mode) orders the walk by the user's
profile quality rank instead of confidence-first; ``quality_targets`` is the
profile target list used for that ranking. Defaults preserve priority-mode
behaviour exactly.
"""
# Sort candidates by match confidence first, then peer quality. Upstream
# Soulseek validation already considers peer speed/slots/queue when scores
# are close; preserve that signal here instead of flattening ties back to
# arbitrary slskd response order.
candidates.sort(
key=lambda r: (
getattr(r, 'confidence', 0) or 0,
getattr(r, 'quality_score', 0) or 0,
getattr(r, 'upload_speed', 0) or 0,
-(getattr(r, 'queue_length', 0) or 0),
getattr(r, 'free_upload_slots', 0) or 0,
getattr(r, 'size', 0) or 0,
),
reverse=True,
# Sort candidates. Priority mode: confidence-first, then peer quality —
# upstream Soulseek validation already considers peer speed/slots/queue when
# scores are close; preserve that signal instead of flattening ties back to
# arbitrary slskd response order. Best-quality mode: profile quality rank
# dominates (all candidates here already passed match filtering).
candidates = order_candidates(
candidates, quality_first=quality_first, targets=quality_targets,
)
with tasks_lock:

View file

@ -54,6 +54,24 @@ def _cand_user_file(candidate):
return getattr(candidate, 'username', None), getattr(candidate, 'filename', None)
def _best_quality_ordering():
"""Return ``(quality_first, targets)`` for the active search mode.
In best-quality mode the candidate walk is ordered by the user's profile
quality rank (bestworst) instead of confidence-first. Fails closed to
priority-mode ordering on any error so a profile/DB hiccup never blocks a
download. See docs/superpowers/specs/2026-06-14-best-quality-search-mode-design.md.
"""
try:
from core.quality.selection import load_search_mode, load_profile_targets
if load_search_mode() == 'best_quality':
targets, _ = load_profile_targets()
return True, targets
except Exception as exc:
logger.debug("[Modal Worker] best-quality ordering unavailable: %s", exc)
return False, None
def _try_cached_candidates(task_id, batch_id, track, deps):
"""Quarantine-retry fast path: attempt the already-found candidates before
re-searching anything.
@ -91,7 +109,10 @@ def _try_cached_candidates(task_id, batch_id, track, deps):
f"[Modal Worker] Quarantine retry: trying {len(remaining)} cached "
f"candidate(s) before re-searching (task {task_id})"
)
return deps.attempt_download_with_candidates(task_id, remaining, track, batch_id)
_qf, _qt = _best_quality_ordering()
return deps.attempt_download_with_candidates(
task_id, remaining, track, batch_id, quality_first=_qf, quality_targets=_qt,
)
def _private_album_bundle_staging_miss_reason(batch_id: Optional[str], deps: Any) -> Optional[str]:
@ -369,6 +390,11 @@ def download_track_worker(task_id: str, batch_id: Optional[str], deps: TaskWorke
logger.info(f"[Modal Worker] Generated {len(search_queries)} smart search queries for '{track.name}': {search_queries}")
logger.info(f"[Modal Worker] About to start search loop for task {task_id} (track: '{track.name}')")
# Best-quality search mode: the orchestrator already pooled candidates
# across every source for each query, so order the candidate walk by the
# user's profile quality rank (best→worst). Computed once per task.
_best_quality, _quality_targets = _best_quality_ordering()
# 2. Sequential Query Search (matches GUI's start_search_worker_parallel logic)
search_diagnostics = [] # Track what happened per query for detailed error messages
all_raw_results = [] # Collect raw results across queries for candidate review modal
@ -495,7 +521,10 @@ def download_track_worker(task_id: str, batch_id: Optional[str], deps: TaskWorke
download_tasks[task_id]['cached_candidates'] = candidates
# Try to download with these candidates
success = deps.attempt_download_with_candidates(task_id, candidates, track, batch_id)
success = deps.attempt_download_with_candidates(
task_id, candidates, track, batch_id,
quality_first=_best_quality, quality_targets=_quality_targets,
)
if success:
# Download initiated successfully - let the download monitoring system handle completion
if batch_id:
@ -529,7 +558,10 @@ def download_track_worker(task_id: str, batch_id: Optional[str], deps: TaskWorke
# === HYBRID FALLBACK: If primary source failed, try remaining sources directly ===
# The orchestrator's hybrid search stops at the first source with results, even if
# those results all fail quality filtering. Try remaining sources individually.
if getattr(deps.download_orchestrator, 'mode', '') == 'hybrid':
#
# Best-quality mode already searched EVERY source per query (the pool), so this
# block would only re-search the same sources — skip it there.
if not _best_quality and getattr(deps.download_orchestrator, 'mode', '') == 'hybrid':
try:
orch = deps.download_orchestrator
hybrid_order = getattr(orch, 'hybrid_order', None) or []

View file

@ -71,6 +71,28 @@ def load_profile_targets() -> Tuple[List[QualityTarget], bool]:
return targets, fallback_enabled
_VALID_SEARCH_MODES = ("priority", "best_quality")
def load_search_mode() -> str:
"""Return the download search strategy from the user's quality profile.
``'priority'`` (default) keeps today's behaviour — the first source in the
hybrid chain that meets a quality target wins. ``'best_quality'`` pools
candidates across all sources and works them bestworst by actual audio
quality. Any missing/unknown value resolves to ``'priority'`` so existing
installs are unaffected.
"""
from database.music_database import MusicDatabase
try:
profile = MusicDatabase().get_quality_profile()
mode = profile.get("search_mode", "priority")
except Exception:
return "priority"
return mode if mode in _VALID_SEARCH_MODES else "priority"
def rank_for_profile(candidates: list) -> Tuple[list, bool]:
"""Load the user's quality profile and rank *candidates* against it.

View file

@ -8618,6 +8618,7 @@ class MusicDatabase:
"version": 3,
"preset": "balanced",
"fallback_enabled": True,
"search_mode": "priority",
"ranked_targets": [
{"label": "FLAC 24-bit/192kHz", "format": "flac", "bit_depth": 24, "min_sample_rate": 192000},
{"label": "FLAC 24-bit/96kHz", "format": "flac", "bit_depth": 24, "min_sample_rate": 96000},

View file

@ -0,0 +1,69 @@
"""order_candidates — the candidate sort used by attempt_download_with_candidates.
Default (priority mode) sorts confidence-first, then peer quality today's
behaviour, locked here as a regression guard. quality_first=True (best-quality
mode) makes the user's profile quality rank dominate, with confidence as the
tiebreaker. Both keep correctly-matched candidates; ordering only changes which
is tried first.
"""
from core.downloads.candidates import order_candidates
from core.quality.model import AudioQuality, QualityTarget
class _Cand:
def __init__(self, name, aq, confidence, quality_score=0,
upload_speed=0, queue_length=0, free_upload_slots=0, size=0):
self.name = name
self.audio_quality = aq
self.confidence = confidence
self.quality_score = quality_score
self.upload_speed = upload_speed
self.queue_length = queue_length
self.free_upload_slots = free_upload_slots
self.size = size
FLAC_HI = AudioQuality('flac', sample_rate=96000, bit_depth=24)
FLAC_CD = AudioQuality('flac', sample_rate=44100, bit_depth=16)
TARGETS = [
QualityTarget(label='FLAC 24', format='flac', bit_depth=24, min_sample_rate=96000),
QualityTarget(label='FLAC 16', format='flac', bit_depth=16),
]
def test_priority_mode_is_confidence_first():
hi = _Cand('hi-flac', FLAC_HI, confidence=0.80)
lo = _Cand('cd-flac', FLAC_CD, confidence=0.95)
ordered = order_candidates([hi, lo], quality_first=False, targets=TARGETS)
assert [c.name for c in ordered] == ['cd-flac', 'hi-flac'] # higher confidence wins
def test_quality_first_lets_better_quality_win_over_confidence():
hi = _Cand('hi-flac', FLAC_HI, confidence=0.80)
lo = _Cand('cd-flac', FLAC_CD, confidence=0.95)
ordered = order_candidates([hi, lo], quality_first=True, targets=TARGETS)
assert [c.name for c in ordered] == ['hi-flac', 'cd-flac'] # 24-bit beats higher-confidence 16-bit
def test_quality_first_uses_confidence_as_tiebreak_within_same_quality():
a = _Cand('a', FLAC_HI, confidence=0.70)
b = _Cand('b', FLAC_HI, confidence=0.90)
ordered = order_candidates([a, b], quality_first=True, targets=TARGETS)
assert [c.name for c in ordered] == ['b', 'a'] # same quality → confidence breaks tie
def test_quality_first_ranks_unmatched_quality_last():
matched = _Cand('matched', FLAC_CD, confidence=0.50)
off_list = _Cand('off', AudioQuality('mp3', bitrate=320), confidence=0.99)
ordered = order_candidates([off_list, matched], quality_first=True, targets=TARGETS)
assert [c.name for c in ordered] == ['matched', 'off'] # off-list sorts last despite high confidence

View file

@ -352,7 +352,7 @@ def test_first_query_success_returns_after_storing_source():
_seed_task()
rec = _Recorder()
def _attempt_success(task_id, candidates, track, batch_id):
def _attempt_success(task_id, candidates, track, batch_id, **kwargs):
download_tasks[task_id]['filename'] = 'song.flac'
download_tasks[task_id]['username'] = 'u1'
return True
@ -478,7 +478,7 @@ def test_hybrid_fallback_tries_secondary_sources():
},
)
def _attempt_yt_success(task_id, candidates, track, batch_id):
def _attempt_yt_success(task_id, candidates, track, batch_id, **kwargs):
return True
deps, _ = _build_deps(
@ -528,7 +528,7 @@ def test_quarantine_retry_tries_cached_candidates_without_searching():
)
attempted = []
def _attempt(task_id, candidates, track, batch_id):
def _attempt(task_id, candidates, track, batch_id, **kwargs):
attempted.append([getattr(c, 'filename', None) for c in candidates])
return True
@ -555,7 +555,7 @@ def test_quarantine_retry_skips_cached_from_exhausted_source():
)
attempted = []
def _attempt(task_id, candidates, track, batch_id):
def _attempt(task_id, candidates, track, batch_id, **kwargs):
attempted.append([getattr(c, 'username', None) for c in candidates])
return True

View file

@ -0,0 +1,66 @@
"""orchestrator.search routes to the right engine method per search_mode:
- priority (default) engine.search_with_fallback (first source wins)
- best_quality + hybrid engine.search_all_sources (pool every source)
- single-source mode the single client's search (toggle is a no-op)
"""
import asyncio
import core.download_orchestrator as orch_mod
from core.download_orchestrator import DownloadOrchestrator
def _run(coro):
loop = asyncio.new_event_loop()
try:
return loop.run_until_complete(coro)
finally:
loop.close()
class _SpyEngine:
def __init__(self):
self.calls = []
async def search_with_fallback(self, query, chain, timeout=None, progress_callback=None):
self.calls.append(('fallback', tuple(chain)))
return (['fb'], [])
async def search_all_sources(self, query, chain, timeout=None,
progress_callback=None, exclude_sources=None):
self.calls.append(('all', tuple(chain)))
return (['pool'], [])
def _hybrid_orch():
orch = DownloadOrchestrator.__new__(DownloadOrchestrator)
orch.mode = 'hybrid'
orch.hybrid_order = ['soulseek', 'hifi']
orch.hybrid_primary = 'soulseek'
orch.hybrid_secondary = 'hifi'
orch.engine = _SpyEngine()
# _resolve_source_chain normalizes names through the registry; stub it so the
# test doesn't need a full registry.
orch._resolve_source_chain = lambda: ['soulseek', 'hifi']
return orch
def test_priority_mode_uses_search_with_fallback(monkeypatch):
monkeypatch.setattr(orch_mod, 'load_search_mode', lambda: 'priority')
orch = _hybrid_orch()
tracks, _ = _run(orch.search('q'))
assert orch.engine.calls == [('fallback', ('soulseek', 'hifi'))]
assert tracks == ['fb']
def test_best_quality_mode_uses_search_all_sources(monkeypatch):
monkeypatch.setattr(orch_mod, 'load_search_mode', lambda: 'best_quality')
orch = _hybrid_orch()
tracks, _ = _run(orch.search('q'))
assert orch.engine.calls == [('all', ('soulseek', 'hifi'))]
assert tracks == ['pool']

View file

@ -0,0 +1,82 @@
"""Engine.search_all_sources pools candidates from EVERY configured source
(best-quality mode), instead of stopping at the first satisfying one like
search_with_fallback. Ranking happens later in the orchestrator this just
combines raw tracks. Excluded/exhausted and raising sources are skipped.
"""
import asyncio
class _Cand:
def __init__(self, name):
self.name = name
class _FakePlugin:
def __init__(self, tracks, configured=True, raises=False):
self._tracks = tracks
self._configured = configured
self._raises = raises
self.searched = False
def is_configured(self):
return self._configured
async def search(self, query, timeout=None, progress_callback=None):
self.searched = True
if self._raises:
raise RuntimeError("boom")
return (list(self._tracks), [])
def _engine_with(plugins):
from core.download_engine.engine import DownloadEngine
eng = object.__new__(DownloadEngine)
eng._plugins = plugins
return eng
def test_pools_candidates_from_all_sources():
a = _FakePlugin([_Cand('a1'), _Cand('a2')])
b = _FakePlugin([_Cand('b1')])
eng = _engine_with({'a': a, 'b': b})
tracks, albums = asyncio.run(eng.search_all_sources('q', ['a', 'b']))
assert {t.name for t in tracks} == {'a1', 'a2', 'b1'}
assert a.searched and b.searched
assert albums == []
def test_skips_excluded_sources():
a = _FakePlugin([_Cand('a1')])
b = _FakePlugin([_Cand('b1')])
eng = _engine_with({'a': a, 'b': b})
tracks, _ = asyncio.run(
eng.search_all_sources('q', ['a', 'b'], exclude_sources=['b'])
)
assert {t.name for t in tracks} == {'a1'}
assert b.searched is False
def test_skips_unconfigured_and_swallows_raising_source():
a = _FakePlugin([_Cand('a1')], configured=False)
b = _FakePlugin([_Cand('b1')], raises=True)
c = _FakePlugin([_Cand('c1')])
eng = _engine_with({'a': a, 'b': b, 'c': c})
tracks, _ = asyncio.run(eng.search_all_sources('q', ['a', 'b', 'c']))
assert {t.name for t in tracks} == {'c1'} # a skipped, b raised, c survives
def test_empty_when_all_sources_empty():
a = _FakePlugin([])
eng = _engine_with({'a': a})
tracks, albums = asyncio.run(eng.search_all_sources('q', ['a']))
assert tracks == []
assert albums == []

View file

@ -0,0 +1,35 @@
"""core.quality.selection.load_search_mode — reads the download search
strategy from the quality profile.
'priority' today's behaviour: first satisfying source wins.
'best_quality' pool all sources, work bestworst by actual quality.
Default and any unknown value resolve to 'priority' so every existing
install keeps its current behaviour.
"""
import core.quality.selection as selection
import database.music_database as music_database
def _patch_profile(monkeypatch, profile):
class _FakeDB:
def get_quality_profile(self):
return profile
monkeypatch.setattr(music_database, "MusicDatabase", _FakeDB)
def test_defaults_to_priority_when_key_absent(monkeypatch):
_patch_profile(monkeypatch, {"version": 3, "ranked_targets": []})
assert selection.load_search_mode() == "priority"
def test_returns_best_quality_when_set(monkeypatch):
_patch_profile(monkeypatch, {"version": 3, "search_mode": "best_quality"})
assert selection.load_search_mode() == "best_quality"
def test_unknown_value_falls_back_to_priority(monkeypatch):
_patch_profile(monkeypatch, {"version": 3, "search_mode": "nonsense"})
assert selection.load_search_mode() == "priority"

View file

@ -18101,9 +18101,9 @@ def _build_candidates_deps():
)
def _attempt_download_with_candidates(task_id, candidates, track, batch_id=None):
def _attempt_download_with_candidates(task_id, candidates, track, batch_id=None, **kwargs):
return _downloads_candidates.attempt_download_with_candidates(
task_id, candidates, track, batch_id, _build_candidates_deps()
task_id, candidates, track, batch_id, _build_candidates_deps(), **kwargs
)

View file

@ -5095,12 +5095,38 @@
</div>
</div>
<!-- Search strategy -->
<div class="form-group">
<label for="quality-search-mode" style="display:block;margin-bottom:4px;font-weight:600;">Search strategy</label>
<select id="quality-search-mode" style="width:100%;max-width:420px;">
<option value="priority">Source priority — fastest, stops at first good source</option>
<option value="best_quality">Best quality — search all sources, pick the highest</option>
</select>
<div class="help-text" style="margin-top:4px">
<strong>Source priority</strong> takes the first source in your chain that meets
a target — fast, fewer lookups. <strong>Best quality</strong> searches
<em>every</em> source for each track, pools the results, and downloads them
best→worst by actual audio quality (source order only breaks ties). Slower and
more API calls, but it won't settle for a passable Soulseek file when HiFi/Qobuz
has a higher-resolution version. Retry budgets are unchanged: a source that spends
its budget is dropped from the whole pool.
</div>
</div>
<!-- Fallback Option -->
<div class="form-group">
<label class="checkbox-label">
<input type="checkbox" id="quality-fallback-enabled" checked>
Allow fallback to any quality if no target is available
Accept off-list quality when nothing in the list is available
</label>
<div class="help-text" style="margin-top:4px">
⚠️ This does <strong>not</strong> mean "walk down my list" — the list already
does that on its own. When <strong>on</strong>, a file that matches
<em>none</em> of your targets (e.g. a 16-bit FLAC or MP3 when you only listed
24-bit) is <strong>accepted anyway</strong> as a last resort. Turn it
<strong>off</strong> to strictly enforce your list — anything off-list is then
quarantined instead of completed.
</div>
</div>
<div class="help-text">
@ -5111,6 +5137,10 @@
<em>minimum</em> 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.
<br><br>
<strong>Note:</strong> the <em>Downsample hi-res</em> option (under Lossy Copy) also
bypasses this gate — if off-list files keep slipping through with fallback off, check
that setting too.
</div>
</div>

View file

@ -1930,6 +1930,9 @@ function populateQualityProfileUI(profile) {
const fallbackCheckbox = document.getElementById('quality-fallback-enabled');
if (fallbackCheckbox) fallbackCheckbox.checked = profile.fallback_enabled !== false;
const searchModeSelect = document.getElementById('quality-search-mode');
if (searchModeSelect) searchModeSelect.value = profile.search_mode === 'best_quality' ? 'best_quality' : 'priority';
}
function renderRankedTargets() {
@ -2071,6 +2074,7 @@ function collectQualityProfileFromUI() {
version: 3,
preset: (currentQualityProfile && currentQualityProfile.preset) || 'custom',
fallback_enabled: document.getElementById('quality-fallback-enabled')?.checked ?? true,
search_mode: document.getElementById('quality-search-mode')?.value === 'best_quality' ? 'best_quality' : 'priority',
ranked_targets,
};
}