The ranked-target list is now the single source of truth for which formats download, in the user's exact priority order, for ALL sources — no hardcoded format hierarchy decides anything. A candidate passes only if it matches a ranked target; if nothing matches, the existing Use-Fallback toggle decides. - source_map: new shared format_from_extension() + AUDIO_EXTENSIONS — one source of truth for extension→format used by every extension-based source, so adding a format lights it up everywhere. Soulseek now classifies through it (opus/wav/aiff were previously dropped as 'unknown'). - file_ops.probe_audio_quality (generic import-time guard, all sources): add WMA; detect ALAC from the real codec (an .m4a is AAC or ALAC). - soulseek: drop the AAC-specific opt-in gate — AAC now follows the same universal rule as every format. - model.tier_score: documented as ONLY a same-format tiebreak + fallback order, never cross-format priority (the list owns that); add opus/alac bases. - UI: ranked-target editor offers all formats (FLAC/ALAC/WAV·AIFF lossless with bit-depth+sample-rate; MP3/AAC/OGG/Opus/WMA lossy with min-bitrate). - tests: AAC retargeted to the universal model; new coverage for format_from_extension and matches_target across all formats. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
203 lines
8.3 KiB
Python
203 lines
8.3 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
|
|
|
|
|
|
# ── Extension → format string (source-agnostic) ────────────────────────────
|
|
#
|
|
# The single source of truth for mapping a file extension to the unified
|
|
# AudioQuality ``format``. Every extension-based download source (Soulseek,
|
|
# torrent/usenet file lists, …) classifies through this, so the ranked-target
|
|
# system behaves identically across sources and adding a format here lights it
|
|
# up everywhere at once. Unknown extensions → 'unknown' (never matches a
|
|
# target, so it only ever comes through via the fallback toggle).
|
|
#
|
|
# AIFF/AIF are uncompressed PCM like WAV → the same 'wav' tier. ``.m4a``
|
|
# defaults to 'aac'; an ALAC-in-m4a file can't be told apart by extension
|
|
# alone, so probe_audio_quality corrects it from the real codec post-download.
|
|
_EXTENSION_FORMAT_MAP = {
|
|
'flac': 'flac',
|
|
'alac': 'alac',
|
|
'wav': 'wav', 'wave': 'wav',
|
|
'aiff': 'wav', 'aif': 'wav', 'aifc': 'wav',
|
|
'mp3': 'mp3',
|
|
'm4a': 'aac', 'mp4': 'aac', 'aac': 'aac',
|
|
'ogg': 'ogg', 'oga': 'ogg',
|
|
'opus': 'opus',
|
|
'wma': 'wma',
|
|
}
|
|
|
|
# Audio extensions worth probing/classifying at all — derived from the map so
|
|
# the allow-list and the classifier never drift apart.
|
|
AUDIO_EXTENSIONS = {f'.{e}' for e in _EXTENSION_FORMAT_MAP}
|
|
|
|
|
|
def format_from_extension(ext: str) -> str:
|
|
"""Map a file extension (with or without leading dot) to the unified
|
|
AudioQuality format string. Unknown → 'unknown'."""
|
|
return _EXTENSION_FORMAT_MAP.get(str(ext or '').lower().lstrip('.'), 'unknown')
|
|
|
|
|
|
# ── 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
|