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:
parent
dcbe09c7aa
commit
791e3630ff
7 changed files with 16 additions and 14 deletions
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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 []
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 ---
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue