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>
168 lines
6.7 KiB
Python
168 lines
6.7 KiB
Python
"""Per-source quality mappers — turn each download source's tier string or
|
|
API values into a unified :class:`~core.quality.model.AudioQuality`.
|
|
|
|
Every streaming source describes quality differently (Tidal/HiFi use tier
|
|
strings, Qobuz reports real kHz + bit depth, Deezer uses config codes,
|
|
Amazon mixes real values with HD/UHD tiers). Centralising the knowledge
|
|
here keeps the per-client code to a single call and keeps the tier tables
|
|
in one auditable place.
|
|
|
|
Each value is a *claim*: the download client populates its ``TrackResult``
|
|
from it so the global ranker can choose a source, and the post-download
|
|
quality guard later verifies the real file. Over-claiming is the danger —
|
|
an unknown tier maps to ``format='unknown'`` rather than pretending to be
|
|
lossless.
|
|
"""
|
|
|
|
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) ──────────────────────────────
|
|
#
|
|
# Tidal exposes UPPER_SNAKE tier strings (``HI_RES_LOSSLESS``); HiFi's config
|
|
# uses lowercase keys (``hires``/``lossless``). We normalise both into the
|
|
# same lookup so one mapper serves both sources.
|
|
|
|
_TIDAL_HIRES = AudioQuality(format='flac', sample_rate=96000, bit_depth=24)
|
|
_TIDAL_LOSSLESS = AudioQuality(format='flac', sample_rate=44100, bit_depth=16)
|
|
_TIDAL_HIGH = AudioQuality(format='aac', bitrate=320)
|
|
_TIDAL_LOW = AudioQuality(format='aac', bitrate=96)
|
|
|
|
TIDAL_TIER_MAP = {
|
|
'HI_RES_LOSSLESS': _TIDAL_HIRES,
|
|
'HI_RES': _TIDAL_HIRES,
|
|
'HIRES': _TIDAL_HIRES,
|
|
'LOSSLESS': _TIDAL_LOSSLESS,
|
|
'HIGH': _TIDAL_HIGH,
|
|
'LOW': _TIDAL_LOW,
|
|
}
|
|
|
|
|
|
def quality_from_tidal_tier(tier: str) -> AudioQuality:
|
|
"""Map a Tidal/HiFi quality tier string to an AudioQuality.
|
|
|
|
Case-insensitive; accepts both ``HI_RES`` and ``hires`` spellings.
|
|
Unrecognised tiers map to ``format='unknown'`` so they never
|
|
over-claim lossless quality.
|
|
"""
|
|
key = (tier or '').strip().upper()
|
|
return TIDAL_TIER_MAP.get(key, AudioQuality(format='unknown'))
|
|
|
|
|
|
# ── Qobuz (real API values) ────────────────────────────────────────────────
|
|
|
|
def quality_from_qobuz(sampling_rate_khz: float, bit_depth: int) -> AudioQuality:
|
|
"""Qobuz reports ``maximum_sampling_rate`` in kHz (e.g. 44.1, 96, 192)
|
|
and ``maximum_bit_depth``. These are real values from the API.
|
|
"""
|
|
sample_rate = int(round(sampling_rate_khz * 1000)) if sampling_rate_khz else None
|
|
return AudioQuality(format='flac', sample_rate=sample_rate, bit_depth=bit_depth)
|
|
|
|
|
|
# ── Deezer (config code) ───────────────────────────────────────────────────
|
|
|
|
DEEZER_CODE_MAP = {
|
|
'flac': AudioQuality(format='flac', sample_rate=44100, bit_depth=16),
|
|
'mp3_320': AudioQuality(format='mp3', bitrate=320),
|
|
'mp3_128': AudioQuality(format='mp3', bitrate=128),
|
|
}
|
|
|
|
|
|
def quality_from_deezer(code: str) -> AudioQuality:
|
|
"""Map a Deezer download quality code to AudioQuality.
|
|
|
|
Deezer FLAC is always CD-quality (16-bit/44.1 kHz).
|
|
"""
|
|
return DEEZER_CODE_MAP.get((code or '').lower(), AudioQuality(format='unknown'))
|
|
|
|
|
|
# ── Amazon Music (real sampleRate preferred, HD/UHD tier fallback) ─────────
|
|
|
|
_AMAZON_TIER_MAP = {
|
|
'UHD': AudioQuality(format='flac', sample_rate=96000, bit_depth=24),
|
|
'HD': AudioQuality(format='flac', sample_rate=44100, bit_depth=16),
|
|
}
|
|
|
|
|
|
def quality_from_amazon(
|
|
tier: str,
|
|
sample_rate: Optional[int] = None,
|
|
bit_depth: Optional[int] = None,
|
|
) -> AudioQuality:
|
|
"""Amazon Music is FLAC; prefer the real ``sampleRate``/``bitDepth`` from
|
|
the stream info when present, otherwise fall back to the HD/UHD tier.
|
|
"""
|
|
base = _AMAZON_TIER_MAP.get((tier or '').strip().upper(), AudioQuality(format='flac'))
|
|
return AudioQuality(
|
|
format='flac',
|
|
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
|