diff --git a/core/imports/file_ops.py b/core/imports/file_ops.py index f4c29619..65c43c7e 100644 --- a/core/imports/file_ops.py +++ b/core/imports/file_ops.py @@ -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) diff --git a/core/quality/model.py b/core/quality/model.py index bf1e496a..4fe1c0f9 100644 --- a/core/quality/model.py +++ b/core/quality/model.py @@ -34,13 +34,19 @@ 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, - 'wav': 95.0, - 'ogg': 70.0, - 'aac': 60.0, - 'mp3': 50.0, - 'wma': 30.0, + '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, } base = format_base.get(self.format.lower(), 10.0) diff --git a/core/quality/source_map.py b/core/quality/source_map.py index a59e411a..9a834f4d 100644 --- a/core/quality/source_map.py +++ b/core/quality/source_map.py @@ -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 diff --git a/core/soulseek_client.py b/core/soulseek_client.py index 3cc46d19..18e20a63 100644 --- a/core/soulseek_client.py +++ b/core/soulseek_client.py @@ -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", diff --git a/tests/downloads/test_soulseek_aac_quality.py b/tests/downloads/test_soulseek_aac_quality.py index 1dd9fe2f..a4b609d7 100644 --- a/tests/downloads/test_soulseek_aac_quality.py +++ b/tests/downloads/test_soulseek_aac_quality.py @@ -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) diff --git a/tests/quality/test_model.py b/tests/quality/test_model.py index 56ba0db4..a740f22c 100644 --- a/tests/quality/test_model.py +++ b/tests/quality/test_model.py @@ -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(): diff --git a/tests/quality/test_source_map.py b/tests/quality/test_source_map.py index ffd5199e..b4d71c43 100644 --- a/tests/quality/test_source_map.py +++ b/tests/quality/test_source_map.py @@ -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"]) diff --git a/webui/index.html b/webui/index.html index 9992dfca..063586c8 100644 --- a/webui/index.html +++ b/webui/index.html @@ -5057,9 +5057,18 @@