#3 priority mode is quality-agnostic again: search_with_fallback (the priority/hybrid path — best_quality has its own search_all_sources) returned the first source whose results met a target, so an mp3 with bitrate=None (slskd omits it often) was deemed unsatisfied and deprioritised, changing which source wins for users who never opted in. Restore "first source with tracks wins", byte-for-byte; cross-source quality pooling stays in best_quality mode. #4 metadata-less FLAC no longer over-claims a hi-res target: matches_target let a FLAC with no sample_rate/bit_depth satisfy a 24-bit/192k target while a real 16/44 FLAC failed it, so unknown-spec files outranked and discarded genuine CD FLAC under audiophile/hi-res profiles. An unconfirmable spec now fails the strict tier and falls to the plain-flac bucket. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
127 lines
4.2 KiB
Python
127 lines
4.2 KiB
Python
"""Engine ``search_with_fallback`` is the PRIORITY-mode search path and is
|
|
deliberately quality-agnostic: the first source in the chain that returns any
|
|
tracks wins (source order is king), byte-for-byte like the pre-quality-system
|
|
hybrid loop (#896 review #3). It skips unconfigured/unavailable sources,
|
|
swallows per-source exceptions, and returns RAW tracks (match-filtering +
|
|
final quality ranking happen later). Cross-source quality pooling lives in
|
|
best_quality mode (``search_all_sources``), not here.
|
|
"""
|
|
|
|
import asyncio
|
|
|
|
import pytest
|
|
|
|
from core.download_engine.engine import DownloadEngine
|
|
from core.quality.model import AudioQuality
|
|
|
|
|
|
class _Cand:
|
|
def __init__(self, aq, name):
|
|
self.audio_quality = aq
|
|
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 (self._tracks, [])
|
|
|
|
|
|
FLAC = AudioQuality('flac', sample_rate=44100, bit_depth=16)
|
|
MP3 = AudioQuality('mp3', bitrate=320)
|
|
MP3_NO_BITRATE = AudioQuality('mp3') # slskd often omits bitrate
|
|
|
|
|
|
def _engine_with(plugins):
|
|
eng = object.__new__(DownloadEngine)
|
|
eng._plugins = plugins
|
|
return eng
|
|
|
|
|
|
def test_returns_first_source_with_tracks_source_priority_king():
|
|
first = _FakePlugin([_Cand(MP3, 'a-mp3')])
|
|
second = _FakePlugin([_Cand(FLAC, 'b-flac')])
|
|
eng = _engine_with({'first': first, 'second': second})
|
|
|
|
tracks, _ = asyncio.run(eng.search_with_fallback('q', ['first', 'second']))
|
|
|
|
assert [t.name for t in tracks] == ['a-mp3'] # first source wins regardless of quality
|
|
assert second.searched is False # never queried — priority is king
|
|
|
|
|
|
def test_metadata_less_mp3_still_wins_in_priority_mode():
|
|
"""An mp3 whose bitrate slskd omitted must NOT be deprioritised in priority
|
|
mode — the whole point of #896 review #3."""
|
|
first = _FakePlugin([_Cand(MP3_NO_BITRATE, 'a-mp3')])
|
|
second = _FakePlugin([_Cand(FLAC, 'b-flac')])
|
|
eng = _engine_with({'first': first, 'second': second})
|
|
|
|
tracks, _ = asyncio.run(eng.search_with_fallback('q', ['first', 'second']))
|
|
|
|
assert [t.name for t in tracks] == ['a-mp3']
|
|
assert second.searched is False
|
|
|
|
|
|
def test_skips_to_next_source_when_first_empty():
|
|
first = _FakePlugin([])
|
|
second = _FakePlugin([_Cand(FLAC, 'b-flac')])
|
|
eng = _engine_with({'first': first, 'second': second})
|
|
|
|
tracks, _ = asyncio.run(eng.search_with_fallback('q', ['first', 'second']))
|
|
|
|
assert [t.name for t in tracks] == ['b-flac']
|
|
assert second.searched is True
|
|
|
|
|
|
def test_returns_raw_tracks_not_pruned():
|
|
# All of the winning source's tracks come back so the orchestrator's match
|
|
# filter can pick the correct one.
|
|
first = _FakePlugin([_Cand(MP3, 'one'), _Cand(FLAC, 'two')])
|
|
eng = _engine_with({'first': first})
|
|
|
|
tracks, _ = asyncio.run(eng.search_with_fallback('q', ['first']))
|
|
|
|
assert {t.name for t in tracks} == {'one', 'two'}
|
|
|
|
|
|
def test_skips_unconfigured_source():
|
|
first = _FakePlugin([_Cand(FLAC, 'a')], configured=False)
|
|
second = _FakePlugin([_Cand(MP3, 'b')])
|
|
eng = _engine_with({'first': first, 'second': second})
|
|
|
|
tracks, _ = asyncio.run(eng.search_with_fallback('q', ['first', 'second']))
|
|
|
|
assert [t.name for t in tracks] == ['b']
|
|
assert first.searched is False
|
|
|
|
|
|
def test_swallows_per_source_exception_and_continues():
|
|
first = _FakePlugin([], raises=True)
|
|
second = _FakePlugin([_Cand(MP3, 'b')])
|
|
eng = _engine_with({'first': first, 'second': second})
|
|
|
|
tracks, _ = asyncio.run(eng.search_with_fallback('q', ['first', 'second']))
|
|
|
|
assert [t.name for t in tracks] == ['b']
|
|
|
|
|
|
def test_returns_empty_when_all_sources_empty():
|
|
first = _FakePlugin([])
|
|
second = _FakePlugin([])
|
|
eng = _engine_with({'first': first, 'second': second})
|
|
|
|
tracks, albums = asyncio.run(eng.search_with_fallback('q', ['first', 'second']))
|
|
|
|
assert tracks == []
|
|
assert albums == []
|