diff --git a/core/amazon_download_client.py b/core/amazon_download_client.py index 04c817ec..004a0e8d 100644 --- a/core/amazon_download_client.py +++ b/core/amazon_download_client.py @@ -33,6 +33,7 @@ from core.amazon_client import AmazonClient, AmazonClientError from core.download_plugins.base import DownloadSourcePlugin from core.download_plugins.types import AlbumResult, DownloadStatus, TrackResult from core.quality.model import AudioQuality +from core.quality.source_map import quality_tier_for_source from utils.logging_config import get_logger logger = get_logger("amazon_download_client") @@ -79,7 +80,7 @@ class AmazonDownloadClient(DownloadSourcePlugin): self.download_path = Path(download_path) self.download_path.mkdir(parents=True, exist_ok=True) - self._quality = config_manager.get("amazon_download.quality", "flac") + self._quality = quality_tier_for_source("amazon", default="flac") self._allow_fallback = config_manager.get("amazon_download.allow_fallback", True) self._client = AmazonClient(preferred_codec=self._quality) diff --git a/core/deezer_download_client.py b/core/deezer_download_client.py index c0f654d7..622ace52 100644 --- a/core/deezer_download_client.py +++ b/core/deezer_download_client.py @@ -21,7 +21,7 @@ from typing import Any, Dict, List, Optional, Tuple import requests from core.download_plugins.types import AlbumResult, DownloadStatus, TrackResult -from core.quality.source_map import quality_from_deezer +from core.quality.source_map import quality_from_deezer, quality_tier_for_source from utils.logging_config import get_logger logger = get_logger("deezer_download") @@ -120,7 +120,7 @@ class DeezerDownloadClient(DownloadSourcePlugin): self._authenticated = False # Quality preference - self._quality = config_manager.get('deezer_download.quality', 'flac') + self._quality = quality_tier_for_source('deezer', default='flac') # Try to authenticate on init if ARL is configured arl = config_manager.get('deezer_download.arl', '') diff --git a/core/download_orchestrator.py b/core/download_orchestrator.py index c2d6980a..6183b3ee 100644 --- a/core/download_orchestrator.py +++ b/core/download_orchestrator.py @@ -102,12 +102,14 @@ class DownloadOrchestrator: deezer_dl = self.client('deezer_dl') if deezer_arl and deezer_dl: deezer_dl.reconnect(deezer_arl) - deezer_dl._quality = config_manager.get('deezer_download.quality', 'flac') + from core.quality.source_map import quality_tier_for_source + deezer_dl._quality = quality_tier_for_source('deezer', default='flac') # Reload Amazon quality preference (T2Tunes needs no reconnect — public proxy) amazon = self.client('amazon') if amazon: - quality = config_manager.get('amazon_download.quality', 'flac') + from core.quality.source_map import quality_tier_for_source + quality = quality_tier_for_source('amazon', default='flac') amazon._quality = quality amazon._allow_fallback = config_manager.get('amazon_download.allow_fallback', True) if hasattr(amazon, '_client') and amazon._client: diff --git a/core/downloads/monitor.py b/core/downloads/monitor.py index 60ab4585..81a8ef22 100644 --- a/core/downloads/monitor.py +++ b/core/downloads/monitor.py @@ -760,7 +760,8 @@ class WebUIDownloadMonitor: # used_sources keys are formatted as "{username}_{filename}", so startswith is exact. is_tidal = any(s.startswith('tidal_') for s in tried_sources) if is_tidal: - tidal_quality = config_manager.get('tidal_download.quality', 'lossless') + from core.quality.source_map import quality_tier_for_source + tidal_quality = quality_tier_for_source('tidal', default='lossless') allow_fb = config_manager.get('tidal_download.allow_fallback', True) if tidal_quality == 'hires' and not allow_fb: task['error_message'] = ( diff --git a/core/hifi_client.py b/core/hifi_client.py index e56c211f..0497426d 100644 --- a/core/hifi_client.py +++ b/core/hifi_client.py @@ -36,7 +36,7 @@ import requests as http_requests from utils.logging_config import get_logger from config.settings import config_manager from core.download_plugins.types import TrackResult, AlbumResult, DownloadStatus -from core.quality.source_map import quality_from_tidal_tier +from core.quality.source_map import quality_from_tidal_tier, quality_tier_for_source logger = get_logger("hifi_client") @@ -752,7 +752,7 @@ class HiFiClient(DownloadSourcePlugin): loop = asyncio.get_event_loop() tracks = await loop.run_in_executor(None, lambda: self.search_raw(query)) - quality_key = config_manager.get('hifi_download.quality', 'lossless') + quality_key = quality_tier_for_source('hifi', default='lossless') q_info = HLS_QUALITY_MAP.get(quality_key, HLS_QUALITY_MAP['lossless']) # HiFi is Tidal-backed; stamp the configured tier so the global @@ -834,7 +834,7 @@ class HiFiClient(DownloadSourcePlugin): ) def _download_sync(self, download_id: str, track_id: int, display_name: str) -> Optional[str]: - quality_key = config_manager.get('hifi_download.quality', 'lossless') + quality_key = quality_tier_for_source('hifi', default='lossless') chain = ['hires', 'lossless', 'high', 'low'] start = chain.index(quality_key) if quality_key in chain else 1 allow_fallback = config_manager.get('hifi_download.allow_fallback', True) diff --git a/core/qobuz_client.py b/core/qobuz_client.py index addf31a9..73a0cae1 100644 --- a/core/qobuz_client.py +++ b/core/qobuz_client.py @@ -29,7 +29,7 @@ from config.settings import config_manager # Import Soulseek data structures for drop-in replacement compatibility from core.download_plugins.types import TrackResult, AlbumResult, DownloadStatus -from core.quality.source_map import quality_from_qobuz +from core.quality.source_map import quality_from_qobuz, quality_tier_for_source logger = get_logger("qobuz_client") @@ -988,7 +988,7 @@ class QobuzClient(DownloadSourcePlugin): return ([], []) # Get configured quality for display - quality_key = config_manager.get('qobuz.quality', 'lossless') + quality_key = quality_tier_for_source('qobuz', default='lossless') quality_info = QOBUZ_QUALITY_MAP.get(quality_key, QOBUZ_QUALITY_MAP['lossless']) track_results = [] @@ -1190,7 +1190,7 @@ class QobuzClient(DownloadSourcePlugin): try: # Determine quality - quality_key = config_manager.get('qobuz.quality', 'lossless') + quality_key = quality_tier_for_source('qobuz', default='lossless') quality_info = QOBUZ_QUALITY_MAP.get(quality_key, QOBUZ_QUALITY_MAP['lossless']) # Quality fallback chain: hires_max → hires → lossless → mp3 diff --git a/core/quality/source_map.py b/core/quality/source_map.py index 7372dccb..a59e411a 100644 --- a/core/quality/source_map.py +++ b/core/quality/source_map.py @@ -19,6 +19,7 @@ from __future__ import annotations from typing import Optional from core.quality.model import AudioQuality +from core.quality.selection import load_profile_targets # ── Tidal / HiFi (Monochrome is Tidal-backed) ────────────────────────────── @@ -102,3 +103,66 @@ def quality_from_amazon( sample_rate=sample_rate if sample_rate is not None else base.sample_rate, bit_depth=bit_depth if bit_depth is not None else base.bit_depth, ) + + +# ── Profile-driven download tier (replaces per-source quality settings) ───── +# +# Each source's selectable download tiers, ordered best → worst, with the +# AudioQuality the tier delivers. ``quality_tier_for_source`` walks these to +# request the LOWEST tier that satisfies the user's top global target — so the +# global quality profile, not a per-source dropdown, decides what each source +# fetches. + +_SOURCE_TIER_LADDERS: dict[str, list[tuple[str, AudioQuality]]] = { + 'tidal': [ + ('hires', AudioQuality('flac', sample_rate=96000, bit_depth=24)), + ('lossless', AudioQuality('flac', sample_rate=44100, bit_depth=16)), + ('high', AudioQuality('aac', bitrate=320)), + ('low', AudioQuality('aac', bitrate=96)), + ], + 'hifi': [ + ('hires', AudioQuality('flac', sample_rate=96000, bit_depth=24)), + ('lossless', AudioQuality('flac', sample_rate=44100, bit_depth=16)), + ('high', AudioQuality('aac', bitrate=320)), + ('low', AudioQuality('aac', bitrate=96)), + ], + 'qobuz': [ + ('hires_max', AudioQuality('flac', sample_rate=192000, bit_depth=24)), + ('hires', AudioQuality('flac', sample_rate=96000, bit_depth=24)), + ('lossless', AudioQuality('flac', sample_rate=44100, bit_depth=16)), + ('mp3', AudioQuality('mp3', bitrate=320)), + ], + 'deezer': [ + ('flac', AudioQuality('flac', sample_rate=44100, bit_depth=16)), + ('mp3_320', AudioQuality('mp3', bitrate=320)), + ('mp3_128', AudioQuality('mp3', bitrate=128)), + ], + 'amazon': [ + ('flac', AudioQuality('flac', sample_rate=48000, bit_depth=24)), + ('opus', AudioQuality('aac', bitrate=320)), + ], +} + + +def quality_tier_for_source(source_name: str, *, default: Optional[str] = None) -> Optional[str]: + """Return the source tier key to request, derived from the global profile. + + Picks the lowest tier in the source's ladder that satisfies the user's + top (most-preferred) target — respecting the quality ceiling and saving + bandwidth. Falls back to the source's max tier when none can satisfy it + (best effort), or to the source's max when no targets are configured. + Returns *default* for an unknown source. + """ + ladder = _SOURCE_TIER_LADDERS.get(source_name) + if not ladder: + return default + + targets, _ = load_profile_targets() + if not targets: + return ladder[0][0] + + top = targets[0] + for key, aq in reversed(ladder): # low → high + if aq.matches_target(top): + return key + return ladder[0][0] # best effort: max tier diff --git a/core/tidal_download_client.py b/core/tidal_download_client.py index d59f9027..5e2f4524 100644 --- a/core/tidal_download_client.py +++ b/core/tidal_download_client.py @@ -33,7 +33,7 @@ from config.settings import config_manager # Import Soulseek data structures for drop-in replacement compatibility from core.download_plugins.types import TrackResult, AlbumResult, DownloadStatus -from core.quality.source_map import quality_from_tidal_tier +from core.quality.source_map import quality_from_tidal_tier, quality_tier_for_source logger = get_logger("tidal_download_client") @@ -456,7 +456,7 @@ class TidalDownloadClient(DownloadSourcePlugin): if successful_query and successful_query != query: logger.info(f"Tidal fallback query succeeded: '{successful_query}' (original: '{query}')") - quality_key = config_manager.get('tidal_download.quality', 'lossless') + quality_key = quality_tier_for_source('tidal', default='lossless') quality_info = QUALITY_MAP.get(quality_key, QUALITY_MAP['lossless']) # Stamp the configured tier (what will actually be downloaded) so # the global ranker sees real sample_rate/bit_depth. @@ -779,7 +779,7 @@ class TidalDownloadClient(DownloadSourcePlugin): logger.error("Tidal session not authenticated") return None - quality_key = config_manager.get('tidal_download.quality', 'lossless') + quality_key = quality_tier_for_source('tidal', default='lossless') chain = ['hires', 'lossless', 'high', 'low'] start = chain.index(quality_key) if quality_key in chain else 1 allow_fallback = config_manager.get('tidal_download.allow_fallback', True) diff --git a/tests/quality/test_source_tier.py b/tests/quality/test_source_tier.py new file mode 100644 index 00000000..86abb050 --- /dev/null +++ b/tests/quality/test_source_tier.py @@ -0,0 +1,62 @@ +"""quality_tier_for_source — derive a source's requested download tier from +the GLOBAL quality profile instead of a per-source setting. + +Rule: pick the LOWEST source tier that satisfies the user's top (most +preferred) target — respecting the user's quality ceiling and saving +bandwidth — or the source's max tier when none can satisfy it (best effort). +""" + +import pytest + +import core.quality.source_map as sm +from core.quality.model import QualityTarget + + +def _patch_targets(monkeypatch, targets, fallback=True): + monkeypatch.setattr(sm, 'load_profile_targets', lambda: (targets, fallback)) + + +T_FLAC24_96 = [QualityTarget(label='', format='flac', bit_depth=24, min_sample_rate=96000)] +T_FLAC24_192 = [QualityTarget(label='', format='flac', bit_depth=24, min_sample_rate=192000)] +T_FLAC16 = [QualityTarget(label='', format='flac', bit_depth=16)] +T_MP3_320 = [QualityTarget(label='', format='mp3', min_bitrate=320)] + + +def test_tidal_hires_when_top_wants_24_96(monkeypatch): + _patch_targets(monkeypatch, T_FLAC24_96) + assert sm.quality_tier_for_source('tidal') == 'hires' + + +def test_tidal_lossless_respects_16bit_ceiling(monkeypatch): + # User caps at 16-bit → request lossless, NOT hires (saves bandwidth). + _patch_targets(monkeypatch, T_FLAC16) + assert sm.quality_tier_for_source('tidal') == 'lossless' + + +def test_tidal_best_effort_max_when_unsatisfiable(monkeypatch): + # Source maxes at 24/96 but user wants 24/192 → best effort = max tier. + _patch_targets(monkeypatch, T_FLAC24_192) + assert sm.quality_tier_for_source('tidal') == 'hires' + + +def test_no_targets_requests_max(monkeypatch): + _patch_targets(monkeypatch, []) + assert sm.quality_tier_for_source('tidal') == 'hires' + assert sm.quality_tier_for_source('deezer') == 'flac' + + +def test_deezer_flac_and_mp3(monkeypatch): + _patch_targets(monkeypatch, T_FLAC16) + assert sm.quality_tier_for_source('deezer') == 'flac' + _patch_targets(monkeypatch, T_MP3_320) + assert sm.quality_tier_for_source('deezer') == 'mp3_320' + + +def test_qobuz_hires_max(monkeypatch): + _patch_targets(monkeypatch, T_FLAC24_192) + assert sm.quality_tier_for_source('qobuz') == 'hires_max' + + +def test_unknown_source_returns_default(monkeypatch): + _patch_targets(monkeypatch, T_FLAC16) + assert sm.quality_tier_for_source('nope', default='x') == 'x' diff --git a/webui/index.html b/webui/index.html index d9137468..61c4c711 100644 --- a/webui/index.html +++ b/webui/index.html @@ -4740,21 +4740,15 @@