feat(quality): make every audio format controllable via ranked targets
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>
This commit is contained in:
parent
17137cea5b
commit
774d0f6c65
9 changed files with 177 additions and 62 deletions
|
|
@ -261,6 +261,17 @@ def probe_audio_quality(file_path: str):
|
|||
if ext in ('m4a', 'aac', 'mp4'):
|
||||
from mutagen.mp4 import MP4
|
||||
audio = MP4(file_path)
|
||||
# .m4a can carry AAC (lossy) OR ALAC (lossless) — only the real
|
||||
# codec tells them apart, which is why extension-based classification
|
||||
# defaults to 'aac' and we correct it here from the probed file.
|
||||
codec = (getattr(audio.info, 'codec', '') or '').lower()
|
||||
if 'alac' in codec:
|
||||
return AudioQuality(
|
||||
format='alac',
|
||||
bitrate=audio.info.bitrate // 1000 if audio.info.bitrate else None,
|
||||
sample_rate=audio.info.sample_rate,
|
||||
bit_depth=getattr(audio.info, 'bits_per_sample', None) or None,
|
||||
)
|
||||
return AudioQuality(
|
||||
format='aac',
|
||||
bitrate=audio.info.bitrate // 1000,
|
||||
|
|
@ -302,6 +313,15 @@ def probe_audio_quality(file_path: str):
|
|||
bit_depth=getattr(audio.info, 'bits_per_sample', None),
|
||||
)
|
||||
|
||||
if ext == 'wma':
|
||||
from mutagen.asf import ASF
|
||||
audio = ASF(file_path)
|
||||
return AudioQuality(
|
||||
format='wma',
|
||||
bitrate=audio.info.bitrate // 1000 if audio.info.bitrate else None,
|
||||
sample_rate=getattr(audio.info, 'sample_rate', None),
|
||||
)
|
||||
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.debug("probe_audio_quality failed for %s: %s", file_path, e)
|
||||
|
|
|
|||
|
|
@ -34,10 +34,16 @@ class AudioQuality:
|
|||
"""Continuous score for ranking within a matched target bucket.
|
||||
Higher = better. Used as a tiebreaker after target-list matching.
|
||||
"""
|
||||
# NOTE: this only orders the *fallback* path (nothing matched a ranked
|
||||
# target) and tie-breaks candidates of the SAME format within one
|
||||
# matched target. Cross-format PRIORITY is decided solely by the user's
|
||||
# ranked-target list (target index), never by these numbers.
|
||||
format_base: dict[str, float] = {
|
||||
'flac': 100.0,
|
||||
'alac': 98.0, # lossless (Apple)
|
||||
'wav': 95.0,
|
||||
'ogg': 70.0,
|
||||
'opus': 65.0,
|
||||
'aac': 60.0,
|
||||
'mp3': 50.0,
|
||||
'wma': 30.0,
|
||||
|
|
|
|||
|
|
@ -22,6 +22,41 @@ 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
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ from core.download_plugins.album_bundle import (
|
|||
)
|
||||
from core.download_plugins.base import DownloadSourcePlugin
|
||||
from core.quality.model import QualityTarget, filter_and_rank, v2_qualities_to_ranked_targets
|
||||
from core.quality.source_map import AUDIO_EXTENSIONS, format_from_extension
|
||||
from utils.async_helpers import run_async
|
||||
|
||||
logger = get_logger("soulseek_client")
|
||||
|
|
@ -410,7 +411,7 @@ class SoulseekClient(DownloadSourcePlugin):
|
|||
|
||||
|
||||
# Audio file extensions to filter for
|
||||
audio_extensions = {'.mp3', '.flac', '.ogg', '.aac', '.wma', '.wav', '.m4a'}
|
||||
audio_extensions = AUDIO_EXTENSIONS
|
||||
|
||||
for response_data in responses_data:
|
||||
username = response_data.get('username', '')
|
||||
|
|
@ -427,10 +428,9 @@ class SoulseekClient(DownloadSourcePlugin):
|
|||
if f'.{file_ext}' not in audio_extensions:
|
||||
continue
|
||||
|
||||
# .m4a is the usual AAC container — bucket it as 'aac' (the
|
||||
# quality filter treats AAC as an opt-in tier; off by default).
|
||||
quality = 'aac' if file_ext == 'm4a' else (
|
||||
file_ext if file_ext in ['flac', 'mp3', 'ogg', 'aac', 'wma'] else 'unknown')
|
||||
# Source-agnostic extension → format (shared with every other
|
||||
# extension-based source). Ranked targets do the rest.
|
||||
quality = format_from_extension(file_ext)
|
||||
|
||||
# Create TrackResult
|
||||
# Convert duration from seconds to milliseconds (slskd returns seconds, Spotify uses ms)
|
||||
|
|
@ -1140,7 +1140,7 @@ class SoulseekClient(DownloadSourcePlugin):
|
|||
Returns:
|
||||
List of TrackResult objects for audio files
|
||||
"""
|
||||
audio_extensions = {'.mp3', '.flac', '.ogg', '.aac', '.wma', '.wav', '.m4a'}
|
||||
audio_extensions = AUDIO_EXTENSIONS
|
||||
results = []
|
||||
if files:
|
||||
logger.debug(f"Browse raw file sample: {files[0]}")
|
||||
|
|
@ -1154,9 +1154,7 @@ class SoulseekClient(DownloadSourcePlugin):
|
|||
ext = Path(filename).suffix.lower()
|
||||
if ext not in audio_extensions:
|
||||
continue
|
||||
_qext = ext.lstrip('.')
|
||||
quality = 'aac' if _qext == 'm4a' else (
|
||||
_qext if _qext in ['flac', 'mp3', 'ogg', 'aac', 'wma'] else 'unknown')
|
||||
quality = format_from_extension(ext)
|
||||
raw_duration = file_data.get('length')
|
||||
duration_ms = raw_duration * 1000 if raw_duration else None
|
||||
slskd_attrs = {a['type']: a['value'] for a in file_data.get('attributes', [])}
|
||||
|
|
@ -2048,15 +2046,9 @@ class SoulseekClient(DownloadSourcePlugin):
|
|||
targets = [QualityTarget.from_dict(t) for t in (raw_targets or [])]
|
||||
fallback_enabled = profile.get('fallback_enabled', True)
|
||||
|
||||
# AAC (#886) is an opt-in tier: keep AAC/.m4a candidates ONLY when the
|
||||
# active profile explicitly targets AAC. Otherwise drop them BEFORE
|
||||
# ranking — without this, fallback_enabled would hand back AAC files the
|
||||
# user never asked for (pre-#886 such files landed in the dropped
|
||||
# 'other' bucket and were never returned).
|
||||
if not any((t.format or '').lower() == 'aac' for t in targets):
|
||||
results = [r for r in results if (r.audio_quality.format or '').lower() != 'aac']
|
||||
if not results:
|
||||
return []
|
||||
# Every format (AAC included) follows the SAME universal rule: a
|
||||
# candidate passes only if it matches a ranked target; if nothing
|
||||
# matches, the fallback toggle decides. No per-format special-casing.
|
||||
|
||||
logger.debug(
|
||||
"Quality Filter: profile='%s', %d targets, %d candidates",
|
||||
|
|
|
|||
|
|
@ -38,14 +38,14 @@ def _cand(quality, size_mb, bitrate=None):
|
|||
queue_length=0, artist='A', title='Song', album='B', track_number=1)
|
||||
|
||||
|
||||
def _q(enabled_flac=True, enabled_mp3=True, aac=None):
|
||||
def _q(enabled_flac=True, enabled_mp3=True, aac=None, fallback=True):
|
||||
qualities = {
|
||||
'flac': {'enabled': enabled_flac, 'min_kbps': 500, 'max_kbps': 10000, 'priority': 1, 'bit_depth': 'any'},
|
||||
'mp3_320': {'enabled': enabled_mp3, 'min_kbps': 280, 'max_kbps': 500, 'priority': 2},
|
||||
}
|
||||
if aac is not None: # None => omit the tier entirely (pre-existing profile)
|
||||
if aac is not None: # None => omit the tier entirely
|
||||
qualities['aac'] = {'enabled': aac, 'min_kbps': 128, 'max_kbps': 400, 'priority': 1.5}
|
||||
return {'preset': 'custom', 'qualities': qualities, 'fallback_enabled': True}
|
||||
return {'preset': 'custom', 'qualities': qualities, 'fallback_enabled': fallback}
|
||||
|
||||
|
||||
def _filter(candidates, profile):
|
||||
|
|
@ -56,51 +56,44 @@ def _filter(candidates, profile):
|
|||
return c.filter_results_by_quality_preference(candidates)
|
||||
|
||||
|
||||
# ── additive proof: AAC off == today (dropped) ────────────────────────────────
|
||||
def test_aac_dropped_when_tier_absent_pre_existing_profile():
|
||||
# A profile saved before this feature has no 'aac' key at all.
|
||||
out = _filter([_cand('aac', 5)], _q(aac=None))
|
||||
assert out == [] # AAC went to 'other' -> never returned, exactly as before
|
||||
# ── AAC follows the UNIVERSAL rule (no per-format special-casing) ──────────────
|
||||
# AAC is just another format: it passes only if it matches a ranked target;
|
||||
# when nothing matches, the fallback toggle decides — exactly like every format.
|
||||
|
||||
def test_aac_not_targeted_fallback_off_is_dropped():
|
||||
out = _filter([_cand('aac', 5)], _q(aac=None, fallback=False))
|
||||
assert out == [] # no AAC target + fallback off → nothing comes through
|
||||
|
||||
|
||||
def test_aac_dropped_when_tier_present_but_disabled():
|
||||
out = _filter([_cand('aac', 5)], _q(aac=False))
|
||||
assert out == []
|
||||
def test_aac_not_targeted_fallback_on_comes_through():
|
||||
out = _filter([_cand('aac', 5)], _q(aac=None, fallback=True))
|
||||
assert len(out) == 1 and out[0].quality == 'aac' # fallback grabs it
|
||||
|
||||
|
||||
def test_aac_disabled_tier_behaves_like_not_targeted():
|
||||
# An explicitly-disabled aac tier is not a target → same as absent.
|
||||
assert _filter([_cand('aac', 5)], _q(aac=False, fallback=False)) == []
|
||||
|
||||
|
||||
def test_flac_mp3_selection_unchanged_when_aac_absent():
|
||||
# The headline no-regression guard: a normal FLAC/MP3 mix is unaffected.
|
||||
flac, mp3 = _cand('flac', 30), _cand('mp3', 5, bitrate=320)
|
||||
out = _filter([mp3, flac], _q(aac=None))
|
||||
assert out and out[0].quality == 'flac' # FLAC still wins, as before
|
||||
assert out and out[0].quality == 'flac' # FLAC still wins
|
||||
|
||||
|
||||
# ── opt-in: AAC on makes it a real tier ───────────────────────────────────────
|
||||
def test_aac_selected_when_enabled():
|
||||
# ── AAC as a real ranked target ───────────────────────────────────────────────
|
||||
def test_aac_selected_when_targeted():
|
||||
out = _filter([_cand('aac', 5)], _q(aac=True))
|
||||
assert len(out) == 1 and out[0].quality == 'aac'
|
||||
|
||||
|
||||
def test_flac_beats_aac_when_both_present():
|
||||
def test_flac_beats_aac_when_listed_higher():
|
||||
flac, aac = _cand('flac', 30), _cand('aac', 5)
|
||||
out = _filter([aac, flac], _q(aac=True))
|
||||
assert out[0].quality == 'flac' # priority 1 < 1.5
|
||||
assert out[0].quality == 'flac' # flac target ranked above aac (priority 1 < 1.5)
|
||||
|
||||
|
||||
def test_aac_beats_mp3_when_both_present():
|
||||
def test_aac_beats_mp3_when_listed_higher():
|
||||
mp3, aac = _cand('mp3', 5, bitrate=320), _cand('aac', 5)
|
||||
out = _filter([mp3, aac], _q(aac=True))
|
||||
assert out[0].quality == 'aac' # priority 1.5 < 2
|
||||
|
||||
|
||||
def test_default_and_presets_ship_aac_disabled_above_mp3():
|
||||
from database.music_database import MusicDatabase
|
||||
db = MusicDatabase.__new__(MusicDatabase) # no DB init
|
||||
profiles = [db._get_default_quality_profile()]
|
||||
profiles += [db.get_quality_preset(p) for p in ('audiophile', 'balanced', 'space_saver')]
|
||||
for prof in profiles:
|
||||
aac = prof['qualities']['aac']
|
||||
assert aac['enabled'] is False # opt-in everywhere
|
||||
# above MP3: lower priority number than the best MP3 tier present
|
||||
mp3_prios = [v['priority'] for k, v in prof['qualities'].items() if k.startswith('mp3')]
|
||||
assert aac['priority'] < min(mp3_prios)
|
||||
assert out[0].quality == 'aac' # aac target (1.5) ranked above mp3 (2)
|
||||
|
|
|
|||
|
|
@ -84,6 +84,37 @@ def test_metadata_less_flac_matches_16bit_baseline_target():
|
|||
assert AudioQuality('flac').matches_target(QualityTarget(format='flac', bit_depth=16)) is True
|
||||
|
||||
|
||||
# ── ranked targets work for EVERY format, not just flac/mp3 (universal) ────
|
||||
|
||||
def test_opus_target_matches_opus_candidate():
|
||||
t = QualityTarget(format='opus', min_bitrate=128)
|
||||
assert AudioQuality('opus', bitrate=160).matches_target(t) is True
|
||||
assert AudioQuality('opus', bitrate=96).matches_target(t) is False
|
||||
assert AudioQuality('mp3', bitrate=320).matches_target(t) is False # wrong format
|
||||
|
||||
|
||||
def test_wav_target_matches_on_bit_depth_and_sample_rate():
|
||||
t = QualityTarget(format='wav', bit_depth=24, min_sample_rate=96000)
|
||||
assert AudioQuality('wav', sample_rate=96000, bit_depth=24).matches_target(t) is True
|
||||
assert AudioQuality('wav', sample_rate=44100, bit_depth=16).matches_target(t) is False
|
||||
|
||||
|
||||
def test_wma_and_alac_targets_match_their_formats():
|
||||
assert AudioQuality('wma', bitrate=192).matches_target(QualityTarget(format='wma', min_bitrate=128)) is True
|
||||
assert AudioQuality('alac', sample_rate=96000, bit_depth=24).matches_target(
|
||||
QualityTarget(format='alac', bit_depth=24)) is True
|
||||
|
||||
|
||||
def test_only_listed_format_passes_others_rank_last():
|
||||
"""An Opus-only target list: only opus matches; everything else ranks last
|
||||
(index == len(targets)), so with fallback off the caller drops them."""
|
||||
from core.quality.model import rank_candidate
|
||||
targets = [QualityTarget(format='opus')]
|
||||
assert rank_candidate(AudioQuality('opus', bitrate=160), targets)[0] == 0
|
||||
assert rank_candidate(AudioQuality('flac', sample_rate=96000, bit_depth=24), targets)[0] == 1
|
||||
assert rank_candidate(AudioQuality('mp3', bitrate=320), targets)[0] == 1
|
||||
|
||||
|
||||
# ── v2 -> v3 migration preserves the user's priority order ─────────────────
|
||||
|
||||
def test_v2_to_v3_preserves_order_and_maps_fields():
|
||||
|
|
|
|||
|
|
@ -13,9 +13,34 @@ from core.quality.source_map import (
|
|||
quality_from_qobuz,
|
||||
quality_from_deezer,
|
||||
quality_from_amazon,
|
||||
format_from_extension,
|
||||
AUDIO_EXTENSIONS,
|
||||
)
|
||||
|
||||
|
||||
# ── Shared extension → format (used by every extension-based source) ────────
|
||||
|
||||
@pytest.mark.parametrize("ext,fmt", [
|
||||
("flac", "flac"), (".flac", "flac"),
|
||||
("mp3", "mp3"),
|
||||
("m4a", "aac"), ("aac", "aac"), ("mp4", "aac"),
|
||||
("ogg", "ogg"), ("oga", "ogg"),
|
||||
("opus", "opus"),
|
||||
("wav", "wav"), ("wave", "wav"),
|
||||
("aiff", "wav"), ("aif", "wav"), # PCM → wav tier
|
||||
("wma", "wma"),
|
||||
("alac", "alac"),
|
||||
("xyz", "unknown"), ("", "unknown"), (None, "unknown"),
|
||||
])
|
||||
def test_format_from_extension(ext, fmt):
|
||||
assert format_from_extension(ext) == fmt
|
||||
|
||||
|
||||
def test_audio_extensions_cover_all_known_formats():
|
||||
for e in (".flac", ".mp3", ".m4a", ".ogg", ".opus", ".wav", ".aiff", ".wma"):
|
||||
assert e in AUDIO_EXTENSIONS
|
||||
|
||||
|
||||
# ── Tidal / HiFi (Tidal-backed) ────────────────────────────────────────────
|
||||
|
||||
@pytest.mark.parametrize("tier", ["HI_RES_LOSSLESS", "HI_RES", "hi_res", "hires", "HIRES"])
|
||||
|
|
|
|||
|
|
@ -5057,9 +5057,18 @@
|
|||
<!-- Add a new target -->
|
||||
<div class="ranked-target-add">
|
||||
<select id="rt-add-format" onchange="onRtAddFormatChange()">
|
||||
<option value="flac">FLAC (lossless)</option>
|
||||
<optgroup label="Lossless">
|
||||
<option value="flac">FLAC</option>
|
||||
<option value="alac">ALAC</option>
|
||||
<option value="wav">WAV / AIFF</option>
|
||||
</optgroup>
|
||||
<optgroup label="Lossy">
|
||||
<option value="mp3">MP3</option>
|
||||
<option value="aac">AAC</option>
|
||||
<option value="ogg">OGG (Vorbis)</option>
|
||||
<option value="opus">Opus</option>
|
||||
<option value="wma">WMA</option>
|
||||
</optgroup>
|
||||
</select>
|
||||
<span class="rt-lossless-fields">
|
||||
<select id="rt-add-bitdepth" title="Minimum bit depth">
|
||||
|
|
|
|||
|
|
@ -1975,7 +1975,7 @@ let currentRankedTargets = [];
|
|||
|
||||
function rtLabel(t) {
|
||||
const fmt = (t.format || 'any').toUpperCase();
|
||||
if (t.format === 'flac' || t.format === 'wav') {
|
||||
if (RT_LOSSLESS_FORMATS.includes(t.format)) {
|
||||
const bd = t.bit_depth ? `${t.bit_depth}-bit` : '';
|
||||
const sr = t.min_sample_rate ? `≥${t.min_sample_rate / 1000}kHz` : '';
|
||||
const detail = [bd, sr].filter(Boolean).join('/');
|
||||
|
|
@ -2066,8 +2066,12 @@ function deleteRankedTarget(i) {
|
|||
debouncedSaveQualityProfile();
|
||||
}
|
||||
|
||||
// Lossless formats take bit-depth + sample-rate constraints; lossy take a
|
||||
// minimum bitrate. Single source of truth for the add-target field toggle.
|
||||
const RT_LOSSLESS_FORMATS = ['flac', 'alac', 'wav'];
|
||||
|
||||
function onRtAddFormatChange() {
|
||||
const lossless = document.getElementById('rt-add-format')?.value === 'flac';
|
||||
const lossless = RT_LOSSLESS_FORMATS.includes(document.getElementById('rt-add-format')?.value);
|
||||
const llFields = document.querySelector('.rt-lossless-fields');
|
||||
const lyFields = document.querySelector('.rt-lossy-fields');
|
||||
if (llFields) llFields.style.display = lossless ? '' : 'none';
|
||||
|
|
@ -2077,7 +2081,7 @@ function onRtAddFormatChange() {
|
|||
function addRankedTarget() {
|
||||
const fmt = document.getElementById('rt-add-format')?.value || 'flac';
|
||||
const t = { format: fmt };
|
||||
if (fmt === 'flac') {
|
||||
if (RT_LOSSLESS_FORMATS.includes(fmt)) {
|
||||
const bd = document.getElementById('rt-add-bitdepth')?.value;
|
||||
const sr = document.getElementById('rt-add-samplerate')?.value;
|
||||
if (bd) t.bit_depth = parseInt(bd, 10);
|
||||
|
|
|
|||
Loading…
Reference in a new issue