#886: AAC as an opt-in Soulseek quality tier (purely additive, off by default)
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).
This commit is contained in:
parent
56da2e105c
commit
400b35d655
5 changed files with 194 additions and 7 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
106
tests/downloads/test_soulseek_aac_quality.py
Normal file
106
tests/downloads/test_soulseek_aac_quality.py
Normal file
|
|
@ -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)
|
||||
|
|
@ -5135,6 +5135,37 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<!-- AAC Quality (opt-in; Soulseek/torrents — off by default) -->
|
||||
<div class="quality-tier">
|
||||
<div class="quality-tier-header">
|
||||
<label class="checkbox-label">
|
||||
<input type="checkbox" id="quality-aac-enabled"
|
||||
onchange="toggleQuality('aac')">
|
||||
<span class="quality-tier-name">AAC <span style="opacity:.6;font-weight:400;">(.m4a/.aac — Soulseek)</span></span>
|
||||
</label>
|
||||
<span class="quality-tier-priority" id="priority-aac">Priority: 1.5</span>
|
||||
</div>
|
||||
<div class="quality-tier-sliders" id="sliders-aac">
|
||||
<div class="slider-group">
|
||||
<label>Bitrate Range:</label>
|
||||
<div class="dual-slider-container">
|
||||
<input type="range" class="range-slider range-slider-min"
|
||||
id="aac-min" min="0" max="400" value="128" step="10"
|
||||
oninput="updateQualityRange('aac')">
|
||||
<input type="range" class="range-slider range-slider-max"
|
||||
id="aac-max" min="0" max="400" value="400" step="10"
|
||||
oninput="updateQualityRange('aac')">
|
||||
<div class="range-slider-track"></div>
|
||||
</div>
|
||||
<div class="slider-values">
|
||||
<span id="aac-min-value">128 kbps</span>
|
||||
<span>-</span>
|
||||
<span id="aac-max-value">400 kbps</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- MP3 320 Quality -->
|
||||
<div class="quality-tier">
|
||||
<div class="quality-tier-header">
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Reference in a new issue