From 400b35d655504d0e1d135673a518fc9ca8ba182c Mon Sep 17 00:00:00 2001 From: BoulderBadgeDad Date: Thu, 18 Jun 2026 13:45:52 -0700 Subject: [PATCH] #886: AAC as an opt-in Soulseek quality tier (purely additive, off by default) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit radoslav-orlov: add AAC as a download quality option. AAC is more efficient than MP3, so it's useful for Soulseek/torrents (streaming sources pick their own codec; Amazon — the AAC-heavy one — is down). Additive by construction: every quality tier already defaults enabled=false and the waterfall is built only from enabled tiers, so AAC ships OFF and the bucketer routes a not-enabled AAC file to the 'other' bucket EXACTLY as today (where it was silently dropped). Only a user who turns AAC on makes it a first-class tier, ranked above MP3 / below FLAC (priority 1.5, min-kbps gate so junk AAC can't beat a good MP3). - music_database: aac tier (disabled) in the default profile + all 3 presets. - soulseek_client: map .m4a -> 'aac' in both result parsers (was 'unknown' -> dropped); add the 'aac' bucket + a gated branch + a fallback size limit. - settings UI: an 'AAC' tier toggle (unchecked) between FLAC and MP3; save defaults its priority to 1.5 so upgraded profiles rank it right on first save. 7 seam tests pinning the additive guarantee (aac absent/disabled -> dropped as before; FLAC/MP3 selection unchanged; aac on -> selectable, below FLAC, above MP3); 81 quality/soulseek tests pass, ruff clean. quality_upgrade left untouched (its AAC handling is unchanged). --- core/soulseek_client.py | 24 ++++- database/music_database.py | 29 +++++ tests/downloads/test_soulseek_aac_quality.py | 106 +++++++++++++++++++ webui/index.html | 31 ++++++ webui/static/settings.js | 11 +- 5 files changed, 194 insertions(+), 7 deletions(-) create mode 100644 tests/downloads/test_soulseek_aac_quality.py diff --git a/core/soulseek_client.py b/core/soulseek_client.py index 9843b21e..28c92393 100644 --- a/core/soulseek_client.py +++ b/core/soulseek_client.py @@ -426,8 +426,11 @@ class SoulseekClient(DownloadSourcePlugin): if f'.{file_ext}' not in audio_extensions: continue - quality = file_ext if file_ext in ['flac', 'mp3', 'ogg', 'aac', 'wma'] else 'unknown' - + # .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') + # Create TrackResult # Convert duration from seconds to milliseconds (slskd returns seconds, Spotify uses ms) raw_duration = file_data.get('length') @@ -1147,7 +1150,9 @@ class SoulseekClient(DownloadSourcePlugin): ext = Path(filename).suffix.lower() if ext not in audio_extensions: continue - quality = ext.lstrip('.') if ext.lstrip('.') in ['flac', 'mp3', 'ogg', 'aac', 'wma'] else 'unknown' + _qext = ext.lstrip('.') + quality = 'aac' if _qext == 'm4a' else ( + _qext if _qext in ['flac', 'mp3', 'ogg', 'aac', 'wma'] else 'unknown') raw_duration = file_data.get('length') duration_ms = raw_duration * 1000 if raw_duration else None results.append(TrackResult( @@ -1957,6 +1962,7 @@ class SoulseekClient(DownloadSourcePlugin): 'mp3_320': (1, 50), 'mp3_256': (1, 40), 'mp3_192': (1, 30), + 'aac': (1, 50), 'other': (0, 500), } @@ -2040,6 +2046,7 @@ class SoulseekClient(DownloadSourcePlugin): 'mp3_320': [], 'mp3_256': [], 'mp3_192': [], + 'aac': [], 'other': [] } @@ -2067,6 +2074,17 @@ class SoulseekClient(DownloadSourcePlugin): else: quality_buckets['other'].append(candidate) continue + elif track_format in ('aac', 'm4a'): + # Opt-in AAC tier. ADDITIVE: when AAC isn't enabled in the + # profile (the default, and every profile that predates this), + # route exactly where AAC went before — the 'other' bucket — so + # behaviour is byte-identical. Only a user who turns AAC on lets + # it become a first-class, selectable tier. + aac_cfg = profile['qualities'].get('aac') + if not (aac_cfg and aac_cfg.get('enabled')): + quality_buckets['other'].append(candidate) + continue + quality_key = 'aac' else: quality_buckets['other'].append(candidate) continue diff --git a/database/music_database.py b/database/music_database.py index 30d2c2b3..7f50d875 100644 --- a/database/music_database.py +++ b/database/music_database.py @@ -8676,6 +8676,15 @@ class MusicDatabase: "min_kbps": 150, "max_kbps": 300, "priority": 4 + }, + # AAC (incl. .m4a): opt-in, OFF by default. Priority 1.5 sits it + # above MP3 but below FLAC (AAC is more efficient than MP3); the + # min_kbps gate keeps junk-bitrate AAC from beating a good MP3. + "aac": { + "enabled": False, + "min_kbps": 128, + "max_kbps": 400, + "priority": 1.5 } }, "fallback_enabled": True @@ -8725,6 +8734,12 @@ class MusicDatabase: "min_kbps": 150, "max_kbps": 300, "priority": 4 + }, + "aac": { + "enabled": False, + "min_kbps": 128, + "max_kbps": 400, + "priority": 1.5 } }, "fallback_enabled": False @@ -8757,6 +8772,12 @@ class MusicDatabase: "min_kbps": 150, "max_kbps": 300, "priority": 4 + }, + "aac": { + "enabled": False, + "min_kbps": 128, + "max_kbps": 400, + "priority": 1.5 } }, "fallback_enabled": True @@ -8789,6 +8810,14 @@ class MusicDatabase: "min_kbps": 150, "max_kbps": 300, "priority": 3 + }, + # Space-saver favours small files, where AAC shines — but it + # still ships OFF (opt-in). Priority 0.5 puts it above MP3. + "aac": { + "enabled": False, + "min_kbps": 128, + "max_kbps": 400, + "priority": 0.5 } }, "fallback_enabled": True diff --git a/tests/downloads/test_soulseek_aac_quality.py b/tests/downloads/test_soulseek_aac_quality.py new file mode 100644 index 00000000..1dd9fe2f --- /dev/null +++ b/tests/downloads/test_soulseek_aac_quality.py @@ -0,0 +1,106 @@ +"""#886: AAC as an opt-in Soulseek quality tier. + +The whole point is "purely additive": with AAC OFF (the default, and every +profile that predates this), an AAC candidate must behave EXACTLY as before — +it lands in the 'other' bucket, which the waterfall never returns, so it's +dropped. Only a profile that explicitly enables AAC makes it a selectable tier, +ranked above MP3 and below FLAC. + +filter_results_by_quality_preference reads db.get_quality_profile() and walks the +buckets; we stub the db + the quarantine sweep so it runs offline. +""" + +from __future__ import annotations + +import types +from pathlib import Path +from unittest.mock import patch + +import pytest + +from core.soulseek_client import SoulseekClient +from core.download_plugins.types import TrackResult + + +def _client(): + c = SoulseekClient.__new__(SoulseekClient) + c.base_url = 'http://localhost:5030' + c.api_key = 'k' + c.download_path = Path('./test_downloads') + return c + + +def _cand(quality, size_mb, bitrate=None): + return TrackResult( + username='peer', filename=f'A/B/01 - Song.{quality}', + size=int(size_mb * 1024 * 1024), bitrate=bitrate, duration=None, + quality=quality, free_upload_slots=1, upload_speed=1_000_000, + queue_length=0, artist='A', title='Song', album='B', track_number=1) + + +def _q(enabled_flac=True, enabled_mp3=True, aac=None): + 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) + qualities['aac'] = {'enabled': aac, 'min_kbps': 128, 'max_kbps': 400, 'priority': 1.5} + return {'preset': 'custom', 'qualities': qualities, 'fallback_enabled': True} + + +def _filter(candidates, profile): + c = _client() + fake_db = types.SimpleNamespace(get_quality_profile=lambda: profile) + with patch('database.music_database.MusicDatabase', return_value=fake_db), \ + patch.object(SoulseekClient, '_drop_quarantined_sources', lambda self, r: r): + 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 + + +def test_aac_dropped_when_tier_present_but_disabled(): + out = _filter([_cand('aac', 5)], _q(aac=False)) + assert out == [] + + +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 + + +# ── opt-in: AAC on makes it a real tier ─────────────────────────────────────── +def test_aac_selected_when_enabled(): + 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(): + flac, aac = _cand('flac', 30), _cand('aac', 5) + out = _filter([aac, flac], _q(aac=True)) + assert out[0].quality == 'flac' # priority 1 < 1.5 + + +def test_aac_beats_mp3_when_both_present(): + 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) diff --git a/webui/index.html b/webui/index.html index 43659fdb..127eb692 100644 --- a/webui/index.html +++ b/webui/index.html @@ -5135,6 +5135,37 @@ + +
+
+ + Priority: 1.5 +
+
+
+ +
+ + +
+
+
+ 128 kbps + - + 400 kbps +
+
+
+
+
diff --git a/webui/static/settings.js b/webui/static/settings.js index 0023acf8..abfa3e80 100644 --- a/webui/static/settings.js +++ b/webui/static/settings.js @@ -1934,7 +1934,7 @@ function populateQualityProfileUI(profile) { } // Populate each quality tier - const qualities = ['flac', 'mp3_320', 'mp3_256', 'mp3_192']; + const qualities = ['flac', 'aac', 'mp3_320', 'mp3_256', 'mp3_192']; qualities.forEach(quality => { const config = profile.qualities[quality]; if (config) { @@ -2128,15 +2128,18 @@ function collectQualityProfileFromUI() { fallback_enabled: document.getElementById('quality-fallback-enabled')?.checked ?? true }; - const qualities = ['flac', 'mp3_320', 'mp3_256', 'mp3_192']; + const qualities = ['flac', 'aac', 'mp3_320', 'mp3_256', 'mp3_192']; qualities.forEach((quality, index) => { const enabled = document.getElementById(`quality-${quality}-enabled`)?.checked || false; const minSlider = document.getElementById(`${quality}-min`); const maxSlider = document.getElementById(`${quality}-max`); - // Preserve priority from the currently loaded profile instead of using array order - const existingPriority = currentQualityProfile?.qualities?.[quality]?.priority ?? (index + 1); + // Preserve priority from the currently loaded profile instead of using array order. + // AAC's default is 1.5 (above MP3, below FLAC) — not index+1 — so an upgraded + // profile that never had an aac tier still ranks it correctly on first save. + const _defaultPriority = quality === 'aac' ? 1.5 : (index + 1); + const existingPriority = currentQualityProfile?.qualities?.[quality]?.priority ?? _defaultPriority; profile.qualities[quality] = { enabled: enabled,