From d484046523a02b4b1b624f6bd024d5ca168a1935 Mon Sep 17 00:00:00 2001 From: dev Date: Sun, 14 Jun 2026 18:46:01 +0200 Subject: [PATCH] feat(downloads): best-quality search mode + clearer fallback UI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- core/download_engine/engine.py | 43 ++++++++++ core/download_orchestrator.py | 6 ++ core/downloads/candidates.py | 79 ++++++++++++++---- core/downloads/task_worker.py | 38 ++++++++- core/quality/selection.py | 22 +++++ database/music_database.py | 1 + tests/downloads/test_candidate_ordering.py | 69 ++++++++++++++++ tests/downloads/test_downloads_task_worker.py | 8 +- .../test_orchestrator_search_mode.py | 66 +++++++++++++++ tests/quality/test_search_all_sources.py | 82 +++++++++++++++++++ tests/quality/test_search_mode.py | 35 ++++++++ web_server.py | 4 +- webui/index.html | 32 +++++++- webui/static/settings.js | 4 + 14 files changed, 464 insertions(+), 25 deletions(-) create mode 100644 tests/downloads/test_candidate_ordering.py create mode 100644 tests/downloads/test_orchestrator_search_mode.py create mode 100644 tests/quality/test_search_all_sources.py create mode 100644 tests/quality/test_search_mode.py diff --git a/core/download_engine/engine.py b/core/download_engine/engine.py index d3f04a82..e0bdcced 100644 --- a/core/download_engine/engine.py +++ b/core/download_engine/engine.py @@ -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 + best→worst 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 diff --git a/core/download_orchestrator.py b/core/download_orchestrator.py index 6183b3ee..664a0f4f 100644 --- a/core/download_orchestrator.py +++ b/core/download_orchestrator.py @@ -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) diff --git a/core/downloads/candidates.py b/core/downloads/candidates.py index d9f20777..b1c8658a 100644 --- a/core/downloads/candidates.py +++ b/core/downloads/candidates.py @@ -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: diff --git a/core/downloads/task_worker.py b/core/downloads/task_worker.py index 71e49933..1b7b2bc1 100644 --- a/core/downloads/task_worker.py +++ b/core/downloads/task_worker.py @@ -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 (best→worst) 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 [] diff --git a/core/quality/selection.py b/core/quality/selection.py index fc9e8ef4..690236de 100644 --- a/core/quality/selection.py +++ b/core/quality/selection.py @@ -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 best→worst 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. diff --git a/database/music_database.py b/database/music_database.py index 03360016..335dacf5 100644 --- a/database/music_database.py +++ b/database/music_database.py @@ -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}, diff --git a/tests/downloads/test_candidate_ordering.py b/tests/downloads/test_candidate_ordering.py new file mode 100644 index 00000000..c66fde84 --- /dev/null +++ b/tests/downloads/test_candidate_ordering.py @@ -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 diff --git a/tests/downloads/test_downloads_task_worker.py b/tests/downloads/test_downloads_task_worker.py index 39320940..ed15a2c3 100644 --- a/tests/downloads/test_downloads_task_worker.py +++ b/tests/downloads/test_downloads_task_worker.py @@ -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 diff --git a/tests/downloads/test_orchestrator_search_mode.py b/tests/downloads/test_orchestrator_search_mode.py new file mode 100644 index 00000000..991fc486 --- /dev/null +++ b/tests/downloads/test_orchestrator_search_mode.py @@ -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'] diff --git a/tests/quality/test_search_all_sources.py b/tests/quality/test_search_all_sources.py new file mode 100644 index 00000000..59fd0f8a --- /dev/null +++ b/tests/quality/test_search_all_sources.py @@ -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 == [] diff --git a/tests/quality/test_search_mode.py b/tests/quality/test_search_mode.py new file mode 100644 index 00000000..1f8eb3f0 --- /dev/null +++ b/tests/quality/test_search_mode.py @@ -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 best→worst 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" diff --git a/web_server.py b/web_server.py index 300c3a36..4dc3753e 100644 --- a/web_server.py +++ b/web_server.py @@ -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 ) diff --git a/webui/index.html b/webui/index.html index 61c4c711..6e531ba2 100644 --- a/webui/index.html +++ b/webui/index.html @@ -5095,12 +5095,38 @@ + +
+ + +
+ Source priority takes the first source in your chain that meets + a target — fast, fewer lookups. Best quality searches + every 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. +
+
+
+
+ ⚠️ This does not mean "walk down my list" — the list already + does that on its own. When on, a file that matches + none of your targets (e.g. a 16-bit FLAC or MP3 when you only listed + 24-bit) is accepted anyway as a last resort. Turn it + off to strictly enforce your list — anything off-list is then + quarantined instead of completed. +
@@ -5111,6 +5137,10 @@ 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. +

+ Note: the Downsample hi-res option (under Lossy Copy) also + bypasses this gate — if off-list files keep slipping through with fallback off, check + that setting too.
diff --git a/webui/static/settings.js b/webui/static/settings.js index 39a297b9..e59abe3d 100644 --- a/webui/static/settings.js +++ b/webui/static/settings.js @@ -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, }; }