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>
82 lines
2.3 KiB
Python
82 lines
2.3 KiB
Python
"""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 == []
|