fix(amazon): wire amazon into all streaming-source guards

`validation.py` had amazon absent from `_streaming_sources`, causing
Amazon TrackResult objects (bitrate=None, size=0) to fall through to
the Soulseek P2P code path and get rejected by
`filter_results_by_quality_preference`. Every album track was marked
not found.

Fix: add 'amazon' to every streaming-source guard tuple/set that was
previously missing it:
- core/downloads/validation.py — primary bug fix (quality-filter bypass)
- core/downloads/status.py — _STREAMING_SOURCE_NAMES frozenset
- core/downloads/task_worker.py — hybrid fallback client map
- core/imports/side_effects.py — || filename→stream-id extraction
- web_server.py — is_streaming_source, transfer list display,
  candidate source label, _try_source_reuse, _store_batch_source
- tests/test_download_plugin_conformance.py — registry count + parametrize

Also updates the 2.5.3 What's New entry to drop the stale
"not yet wired" disclaimer.
This commit is contained in:
Broque Thomas 2026-05-16 10:24:13 -07:00
parent dcbe09c7aa
commit 791e3630ff
7 changed files with 16 additions and 14 deletions

View file

@ -87,7 +87,7 @@ class StatusDeps:
# Streaming sources the engine fallback applies to. Soulseek goes through # Streaming sources the engine fallback applies to. Soulseek goes through
# slskd's live_transfers path and must NOT hit the engine fallback. # slskd's live_transfers path and must NOT hit the engine fallback.
_STREAMING_SOURCE_NAMES = frozenset(( _STREAMING_SOURCE_NAMES = frozenset((
'youtube', 'tidal', 'qobuz', 'hifi', 'deezer_dl', 'lidarr', 'soundcloud', 'youtube', 'tidal', 'qobuz', 'hifi', 'deezer_dl', 'lidarr', 'soundcloud', 'amazon',
)) ))
# Keep these in sync with the engine plugins' state strings. # Keep these in sync with the engine plugins' state strings.

View file

@ -294,7 +294,7 @@ def download_track_worker(task_id: str, batch_id: Optional[str], deps: TaskWorke
source_clients = { source_clients = {
name: orch.client(name) name: orch.client(name)
for name in ('soulseek', 'youtube', 'tidal', 'qobuz', for name in ('soulseek', 'youtube', 'tidal', 'qobuz',
'hifi', 'deezer_dl', 'lidarr', 'soundcloud') 'hifi', 'deezer_dl', 'lidarr', 'soundcloud', 'amazon')
} }
# The orchestrator tried sources in order but stopped at the first with results. # The orchestrator tried sources in order but stopped at the first with results.

View file

@ -79,7 +79,7 @@ def get_valid_candidates(results, spotify_track, query):
# Streaming sources (YouTube, Tidal, Qobuz, HiFi, Deezer, SoundCloud) return structured API results # Streaming sources (YouTube, Tidal, Qobuz, HiFi, Deezer, SoundCloud) return structured API results
# with proper artist/title metadata — score using the same matching engine as Soulseek # with proper artist/title metadata — score using the same matching engine as Soulseek
_streaming_sources = ("youtube", "tidal", "qobuz", "hifi", "deezer_dl", "soundcloud") _streaming_sources = ("youtube", "tidal", "qobuz", "hifi", "deezer_dl", "soundcloud", "amazon")
if results[0].username in _streaming_sources: if results[0].username in _streaming_sources:
source_label = results[0].username.replace('_dl', '').title() source_label = results[0].username.replace('_dl', '').title()
expected_artists = spotify_track.artists if spotify_track else [] expected_artists = spotify_track.artists if spotify_track else []

View file

@ -235,7 +235,7 @@ def record_library_history_download(context: Dict[str, Any]) -> None:
source_track_id = search_result.get("track_id", "") or search_result.get("id", "") or ti.get("id", "") source_track_id = search_result.get("track_id", "") or search_result.get("id", "") or ti.get("id", "")
source_track_title = search_result.get("title", "") or search_result.get("name", "") source_track_title = search_result.get("title", "") or search_result.get("name", "")
source_artist = search_result.get("artist", "") source_artist = search_result.get("artist", "")
if source_filename and "||" in source_filename and username in ("tidal", "youtube", "qobuz", "hifi", "deezer_dl", "lidarr", "soundcloud"): if source_filename and "||" in source_filename and username in ("tidal", "youtube", "qobuz", "hifi", "deezer_dl", "lidarr", "soundcloud", "amazon"):
stream_id = source_filename.split("||")[0] stream_id = source_filename.split("||")[0]
if stream_id and not source_track_id: if stream_id and not source_track_id:
source_track_id = stream_id source_track_id = stream_id

View file

@ -59,6 +59,7 @@ def _import_plugin_classes():
from core.deezer_download_client import DeezerDownloadClient from core.deezer_download_client import DeezerDownloadClient
from core.lidarr_download_client import LidarrDownloadClient from core.lidarr_download_client import LidarrDownloadClient
from core.soundcloud_client import SoundcloudClient from core.soundcloud_client import SoundcloudClient
from core.amazon_download_client import AmazonDownloadClient
return { return {
'soulseek': SoulseekClient, 'soulseek': SoulseekClient,
@ -69,10 +70,11 @@ def _import_plugin_classes():
'deezer': DeezerDownloadClient, 'deezer': DeezerDownloadClient,
'lidarr': LidarrDownloadClient, 'lidarr': LidarrDownloadClient,
'soundcloud': SoundcloudClient, 'soundcloud': SoundcloudClient,
'amazon': AmazonDownloadClient,
} }
def test_default_registry_registers_all_eight_sources(): def test_default_registry_registers_all_sources():
"""Smoke check that the foundation registry knows about every """Smoke check that the foundation registry knows about every
source the orchestrator historically dispatched to. If someone source the orchestrator historically dispatched to. If someone
drops a registration here, every other test in this module would drops a registration here, every other test in this module would
@ -82,7 +84,7 @@ def test_default_registry_registers_all_eight_sources():
registry = build_default_registry() registry = build_default_registry()
expected = { expected = {
'soulseek', 'youtube', 'tidal', 'qobuz', 'soulseek', 'youtube', 'tidal', 'qobuz',
'hifi', 'deezer', 'lidarr', 'soundcloud', 'hifi', 'deezer', 'lidarr', 'soundcloud', 'amazon',
} }
assert set(registry.names()) == expected assert set(registry.names()) == expected
@ -102,7 +104,7 @@ def test_deezer_dl_alias_is_registered_against_deezer_spec():
@pytest.mark.parametrize('plugin_name', [ @pytest.mark.parametrize('plugin_name', [
'soulseek', 'youtube', 'tidal', 'qobuz', 'soulseek', 'youtube', 'tidal', 'qobuz',
'hifi', 'deezer', 'lidarr', 'soundcloud', 'hifi', 'deezer', 'lidarr', 'soundcloud', 'amazon',
]) ])
def test_plugin_class_has_all_required_methods(plugin_name): def test_plugin_class_has_all_required_methods(plugin_name):
"""Every registered plugin class exposes every protocol method """Every registered plugin class exposes every protocol method
@ -122,7 +124,7 @@ def test_plugin_class_has_all_required_methods(plugin_name):
@pytest.mark.parametrize('plugin_name', [ @pytest.mark.parametrize('plugin_name', [
'soulseek', 'youtube', 'tidal', 'qobuz', 'soulseek', 'youtube', 'tidal', 'qobuz',
'hifi', 'deezer', 'lidarr', 'soundcloud', 'hifi', 'deezer', 'lidarr', 'soundcloud', 'amazon',
]) ])
def test_plugin_class_async_methods_are_coroutines(plugin_name): def test_plugin_class_async_methods_are_coroutines(plugin_name):
"""Methods declared async in the protocol must be async on every """Methods declared async in the protocol must be async on every

View file

@ -5797,7 +5797,7 @@ def start_download():
if download_id: if download_id:
# Register download for post-processing (simple transfer to /Transfer) # Register download for post-processing (simple transfer to /Transfer)
context_key = _make_context_key(username, filename) context_key = _make_context_key(username, filename)
is_streaming_source = username in ('youtube', 'tidal', 'qobuz', 'hifi', 'deezer_dl', 'lidarr', 'soundcloud') is_streaming_source = username in ('youtube', 'tidal', 'qobuz', 'hifi', 'deezer_dl', 'lidarr', 'soundcloud', 'amazon')
with matched_context_lock: with matched_context_lock:
matched_downloads_context[context_key] = { matched_downloads_context[context_key] = {
'search_result': { 'search_result': {
@ -6179,7 +6179,7 @@ def get_download_status():
all_streaming_downloads = run_async(download_orchestrator.get_all_downloads()) all_streaming_downloads = run_async(download_orchestrator.get_all_downloads())
for download in all_streaming_downloads: for download in all_streaming_downloads:
if download.username in ('youtube', 'tidal', 'qobuz', 'hifi', 'deezer_dl', 'lidarr', 'soundcloud'): if download.username in ('youtube', 'tidal', 'qobuz', 'hifi', 'deezer_dl', 'lidarr', 'soundcloud', 'amazon'):
source_label = download.username.title() source_label = download.username.title()
# Convert DownloadStatus to transfer format that frontend expects # Convert DownloadStatus to transfer format that frontend expects
streaming_transfer = { streaming_transfer = {
@ -11106,7 +11106,7 @@ def redownload_search_sources(track_id):
quality = ext if ext in ('FLAC', 'MP3', 'OPUS', 'OGG', 'M4A', 'WAV') else candidate.quality or '' quality = ext if ext in ('FLAC', 'MP3', 'OPUS', 'OGG', 'M4A', 'WAV') else candidate.quality or ''
svc = source_name if source_name != 'default' else 'hybrid' svc = source_name if source_name != 'default' else 'hybrid'
uname = candidate.username uname = candidate.username
if uname in ('youtube', 'tidal', 'qobuz', 'hifi', 'deezer_dl', 'lidarr', 'soundcloud'): if uname in ('youtube', 'tidal', 'qobuz', 'hifi', 'deezer_dl', 'lidarr', 'soundcloud', 'amazon'):
svc = uname svc = uname
source_candidates.append({ source_candidates.append({
'username': uname, 'username': uname,
@ -16412,7 +16412,7 @@ def _try_source_reuse(task_id, batch_id, track):
if not source_tracks or not last_source: if not source_tracks or not last_source:
_sr.info("Skipped — no source_tracks or no last_source") _sr.info("Skipped — no source_tracks or no last_source")
return False return False
if last_source.get('username') in ('youtube', 'tidal', 'qobuz', 'hifi', 'deezer_dl', 'lidarr', 'soundcloud'): if last_source.get('username') in ('youtube', 'tidal', 'qobuz', 'hifi', 'deezer_dl', 'lidarr', 'soundcloud', 'amazon'):
_sr.info(f"Skipped — {last_source.get('username')} source (no folder-based reuse)") _sr.info(f"Skipped — {last_source.get('username')} source (no folder-based reuse)")
return False return False
@ -16514,7 +16514,7 @@ def _store_batch_source(batch_id, username, filename):
"""Browse the successful download's folder and store results on the batch for reuse.""" """Browse the successful download's folder and store results on the batch for reuse."""
_sr = source_reuse_logger _sr = source_reuse_logger
_sr.info(f"_store_batch_source called: batch={batch_id}, user={username}, file={filename}") _sr.info(f"_store_batch_source called: batch={batch_id}, user={username}, file={filename}")
if not batch_id or username in ('youtube', 'tidal', 'qobuz', 'hifi', 'deezer_dl', 'lidarr', 'soundcloud'): if not batch_id or username in ('youtube', 'tidal', 'qobuz', 'hifi', 'deezer_dl', 'lidarr', 'soundcloud', 'amazon'):
_sr.info(f"Skipped — no batch_id or streaming source ({username})") _sr.info(f"Skipped — no batch_id or streaming source ({username})")
return return

View file

@ -3415,7 +3415,7 @@ function closeHelperSearch() {
const WHATS_NEW = { const WHATS_NEW = {
'2.5.3': [ '2.5.3': [
{ unreleased: true }, { unreleased: true },
{ title: 'Amazon Music Download Source', desc: 'new download source backed by T2Tunes proxy. searches the Amazon Music catalog, downloads 24-bit/48kHz FLAC (or Opus 320kbps / Dolby Atmos EAC3 fallback). codec waterfall mirrors the same pattern as Tidal/Qobuz — best quality first, auto-fallback. not yet wired into the main source selector — coming in a follow-up PR.', page: 'settings' }, { title: 'Amazon Music Download Source', desc: 'new download source backed by T2Tunes proxy. searches the Amazon Music catalog, downloads 24-bit/48kHz FLAC (or Opus 320kbps / Dolby Atmos EAC3 fallback). codec waterfall mirrors Tidal/Qobuz — best quality first, auto-fallback. selectable as a standalone or hybrid source from Settings.', page: 'settings' },
], ],
'2.5.2': [ '2.5.2': [
// --- May 13, 2026 — 2.5.2 release --- // --- May 13, 2026 — 2.5.2 release ---