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:
parent
31d85e59c9
commit
d484046523
14 changed files with 464 additions and 25 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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 []
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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},
|
||||
|
|
|
|||
69
tests/downloads/test_candidate_ordering.py
Normal file
69
tests/downloads/test_candidate_ordering.py
Normal 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
|
||||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
66
tests/downloads/test_orchestrator_search_mode.py
Normal file
66
tests/downloads/test_orchestrator_search_mode.py
Normal 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']
|
||||
82
tests/quality/test_search_all_sources.py
Normal file
82
tests/quality/test_search_all_sources.py
Normal 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 == []
|
||||
35
tests/quality/test_search_mode.py
Normal file
35
tests/quality/test_search_mode.py
Normal 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 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"
|
||||
|
|
@ -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
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue