feat(quality): derive per-source download tier from the global profile

Remove the per-source download-quality dropdowns (Tidal/HiFi/Qobuz/Deezer/
Amazon) — with the global ranked-targets system they were redundant and
conflicting. Add quality_tier_for_source(): picks the LOWEST source tier
that satisfies the user's top target (respects the quality ceiling, saves
bandwidth) or the source's max as best effort. Every source's search +
download + retry path now derives its tier from the global profile instead
of config_manager.get('<source>_download.quality').

Settings keep the per-source allow_fallback toggles; the quality selects are
replaced with a note pointing at Quality Profile.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
dev 2026-06-14 13:18:01 +02:00
parent 6046a814cb
commit fe78a3cdc3
11 changed files with 154 additions and 61 deletions

View file

@ -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)

View file

@ -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', '')

View file

@ -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:

View file

@ -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'] = (

View file

@ -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)

View file

@ -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

View file

@ -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

View file

@ -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)

View file

@ -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'

View file

@ -4740,21 +4740,15 @@
<div id="tidal-download-settings-container" style="display: none;">
<div class="form-group">
<label>Tidal Download Quality:</label>
<select id="tidal-download-quality" class="form-select">
<option value="low">Low (AAC 96kbps)</option>
<option value="high">High (AAC 320kbps)</option>
<option value="lossless">Lossless (FLAC 16-bit/44.1kHz)</option>
<option value="hires">HiRes (FLAC 24-bit/96kHz)</option>
</select>
<div class="setting-help-text">
Audio quality for Tidal downloads. HiRes requires a Tidal HiFi Plus subscription.
Quality is set globally in <strong>Quality Profile</strong> (ranked targets) — Tidal fetches the highest tier your profile accepts.
</div>
<label class="checkbox-inline" style="margin-top: 8px;">
<input type="checkbox" id="tidal-allow-fallback" checked>
Allow quality fallback
</label>
<div class="setting-help-text">
When disabled, only downloads at the exact quality selected. If unavailable, skips to the next source.
When disabled, only downloads at the exact derived quality. If unavailable, skips to the next source.
</div>
</div>
<div class="form-group">
@ -4774,14 +4768,8 @@
<div id="qobuz-settings-container" style="display: none;">
<div class="form-group">
<label>Qobuz Download Quality:</label>
<select id="qobuz-quality" class="form-select">
<option value="mp3">MP3 320kbps</option>
<option value="lossless">Lossless (FLAC 16-bit/44.1kHz)</option>
<option value="hires">Hi-Res (FLAC 24-bit/96kHz)</option>
<option value="hires_max">Hi-Res Max (FLAC 24-bit/192kHz)</option>
</select>
<div class="setting-help-text">
Audio quality for Qobuz downloads. Hi-Res requires a Qobuz Studio or Sublime subscription.
Quality is set globally in <strong>Quality Profile</strong> (ranked targets) — Qobuz fetches the highest tier your profile accepts. Hi-Res requires a Qobuz Studio or Sublime subscription.
</div>
<label class="checkbox-inline" style="margin-top: 8px;">
<input type="checkbox" id="qobuz-allow-fallback" checked>
@ -4840,14 +4828,8 @@
<div id="hifi-download-settings-container" style="display: none;">
<div class="form-group">
<label>HiFi Download Quality:</label>
<select id="hifi-download-quality" class="form-select">
<option value="low">Low (AAC 96kbps)</option>
<option value="high">High (AAC 320kbps)</option>
<option value="lossless">Lossless (FLAC 16-bit/44.1kHz)</option>
<option value="hires">HiRes (FLAC 24-bit/96kHz)</option>
</select>
<div class="setting-help-text">
Audio quality for HiFi downloads. Uses public API instances — no account required.
Quality is set globally in <strong>Quality Profile</strong> (ranked targets) — HiFi fetches the highest tier your profile accepts. Uses public API instances — no account required.
</div>
<label class="checkbox-inline" style="margin-top: 8px;">
<input type="checkbox" id="hifi-allow-fallback" checked>
@ -4888,14 +4870,8 @@
<div id="deezer-download-settings-container" style="display: none;">
<div class="form-group">
<label>Deezer Download Quality:</label>
<select id="deezer-download-quality" class="form-select">
<option value="mp3_128">MP3 128kbps (Free)</option>
<option value="mp3_320">MP3 320kbps (Premium)</option>
<option value="flac">FLAC Lossless (HiFi)</option>
</select>
<div class="setting-help-text">
Audio quality for Deezer downloads. FLAC requires a Deezer HiFi subscription.
MP3 320 requires Premium or higher.
Quality is set globally in <strong>Quality Profile</strong> (ranked targets) — Deezer fetches the highest tier your profile accepts. FLAC requires a Deezer HiFi subscription.
</div>
<label class="checkbox-inline" style="margin-top: 8px;">
<input type="checkbox" id="deezer-allow-fallback" checked>
@ -4930,14 +4906,8 @@
<div id="amazon-download-settings-container" style="display: none;">
<div class="form-group">
<label>Amazon Music Quality:</label>
<select id="amazon-quality" class="form-select">
<option value="flac">FLAC Lossless (24-bit/48kHz Hi-Res)</option>
<option value="opus">Opus (320kbps)</option>
<option value="eac3">EAC3 Dolby Atmos (768kbps 5.1)</option>
</select>
<div class="setting-help-text">
Preferred codec tier. FLAC is 24-bit/48kHz Hi-Res — no subscription required.
Downloads via T2Tunes proxy.
Quality is set globally in <strong>Quality Profile</strong> (ranked targets) — Amazon fetches FLAC (24-bit/48kHz Hi-Res) when your profile wants lossless, otherwise a lossy tier. Downloads via T2Tunes proxy.
</div>
<label class="checkbox-inline" style="margin-top: 8px;">
<input type="checkbox" id="amazon-allow-fallback" checked>

View file

@ -1138,17 +1138,14 @@ async function loadSettingsData() {
document.getElementById('max-concurrent-downloads').value = settings.download_source?.max_concurrent || '3';
loadHybridSourceOrder(settings);
loadArtSourceOrder(settings);
document.getElementById('tidal-download-quality').value = settings.tidal_download?.quality || 'lossless';
// Per-source download quality is now derived from the global Quality
// Profile (ranked targets) — the per-source quality selects were removed.
document.getElementById('tidal-allow-fallback').checked = settings.tidal_download?.allow_fallback !== false;
document.getElementById('qobuz-quality').value = settings.qobuz?.quality || 'lossless';
document.getElementById('qobuz-allow-fallback').checked = settings.qobuz?.allow_fallback !== false;
document.getElementById('hifi-download-quality').value = settings.hifi_download?.quality || 'lossless';
document.getElementById('hifi-allow-fallback').checked = settings.hifi_download?.allow_fallback !== false;
loadHiFiInstances();
document.getElementById('deezer-download-quality').value = settings.deezer_download?.quality || 'flac';
document.getElementById('deezer-allow-fallback').checked = settings.deezer_download?.allow_fallback !== false;
document.getElementById('deezer-download-arl').value = settings.deezer_download?.arl || '';
document.getElementById('amazon-quality').value = settings.amazon_download?.quality || 'flac';
document.getElementById('amazon-allow-fallback').checked = settings.amazon_download?.allow_fallback !== false;
document.getElementById('lidarr-url').value = settings.lidarr_download?.url || '';
document.getElementById('lidarr-api-key').value = settings.lidarr_download?.api_key || '';
@ -2946,11 +2943,10 @@ async function saveSettings(quiet = false) {
usenet_download_path: document.getElementById('usenet-download-path')?.value || '',
},
tidal_download: {
quality: document.getElementById('tidal-download-quality').value || 'lossless',
// quality derived from the global Quality Profile (ranked targets)
allow_fallback: document.getElementById('tidal-allow-fallback').checked,
},
hifi_download: {
quality: document.getElementById('hifi-download-quality').value || 'lossless',
allow_fallback: document.getElementById('hifi-allow-fallback').checked,
},
hifi: {
@ -2958,12 +2954,10 @@ async function saveSettings(quiet = false) {
tags: _collectServiceTags('hifi')
},
deezer_download: {
quality: document.getElementById('deezer-download-quality').value || 'flac',
arl: document.getElementById('deezer-download-arl').value || '',
allow_fallback: document.getElementById('deezer-allow-fallback').checked,
},
amazon_download: {
quality: document.getElementById('amazon-quality').value || 'flac',
allow_fallback: document.getElementById('amazon-allow-fallback').checked,
},
lidarr_download: {
@ -2997,7 +2991,6 @@ async function saveSettings(quiet = false) {
// to migrate existing configs.
},
qobuz: {
quality: document.getElementById('qobuz-quality').value || 'lossless',
embed_tags: document.getElementById('embed-qobuz').checked,
tags: _collectServiceTags('qobuz'),
allow_fallback: document.getElementById('qobuz-allow-fallback').checked,