diff --git a/database/music_database.py b/database/music_database.py index 17ae6ecf..c30f5d31 100644 --- a/database/music_database.py +++ b/database/music_database.py @@ -8760,15 +8760,52 @@ class MusicDatabase: return self._get_default_quality_profile() + # 24-bit FLAC ladder seeded on migration for users who had a streaming + # source on Hi-Res under the old (now removed) per-source quality dropdowns. + _HIRES_24BIT_TARGETS = [ + {"label": "FLAC 24-bit/192kHz", "format": "flac", "bit_depth": 24, "min_sample_rate": 192000}, + {"label": "FLAC 24-bit/96kHz", "format": "flac", "bit_depth": 24, "min_sample_rate": 96000}, + {"label": "FLAC 24-bit/48kHz", "format": "flac", "bit_depth": 24, "min_sample_rate": 48000}, + {"label": "FLAC 24-bit/44.1kHz","format": "flac", "bit_depth": 24, "min_sample_rate": 44100}, + ] + + def _had_hires_source_preference(self) -> bool: + """True if the user had any streaming source set to a Hi-Res tier under + the old per-source quality dropdowns (tidal_download/qobuz/hifi_download + .quality = 'hires'|'hires_max'), which #896 removed in favour of the + global profile. Used to preserve their intent on migration.""" + try: + from config.settings import config_manager + except Exception: + return False + hires = {'hires', 'hires_max'} + for key in ('tidal_download.quality', 'qobuz.quality', 'hifi_download.quality'): + try: + if str(config_manager.get(key) or '').strip().lower() in hires: + return True + except Exception: + continue + return False + def _migrate_v2_to_v3(self, profile: dict) -> dict: """Add ranked_targets to a v2 profile without losing its qualities dict.""" from core.quality.model import v2_qualities_to_ranked_targets profile = dict(profile) profile['version'] = 3 if 'ranked_targets' not in profile: - profile['ranked_targets'] = v2_qualities_to_ranked_targets( - profile.get('qualities', {}) + ranked = v2_qualities_to_ranked_targets(profile.get('qualities', {})) + # #896 review #5: the per-source quality dropdowns are gone — sources + # now derive their tier from this profile. If the user had a source on + # Hi-Res, seed 24-bit FLAC targets at the top so they keep Hi-Res + # instead of silently dropping to lossless. Skip when the profile + # already expresses 24-bit (don't duplicate the ladder). + already_24bit = any( + t.get('format') == 'flac' and (t.get('bit_depth') or 0) >= 24 + for t in ranked ) + if not already_24bit and self._had_hires_source_preference(): + ranked = [dict(t) for t in self._HIRES_24BIT_TARGETS] + ranked + profile['ranked_targets'] = ranked return profile def _get_default_quality_profile(self) -> dict: diff --git a/tests/quality/test_quality_presets.py b/tests/quality/test_quality_presets.py index d6b8bb92..db148a96 100644 --- a/tests/quality/test_quality_presets.py +++ b/tests/quality/test_quality_presets.py @@ -31,3 +31,51 @@ def test_balanced_still_includes_16bit_and_mp3(): assert 'FLAC 16-bit' in labels assert any('MP3' in l for l in labels) assert p['fallback_enabled'] is True + + +# ── v2 → v3 migration seeds Hi-Res from the old per-source dropdowns (#896 #5) ── + +class _FakeCfg: + def __init__(self, values): + self._v = values + + def get(self, key, default=None): + return self._v.get(key, default) + + +_V2_LOSSLESS = { + 'version': 2, 'preset': 'balanced', + 'qualities': { + 'flac': {'enabled': True, 'priority': 1, 'bit_depth': 'any'}, + 'mp3_320': {'enabled': True, 'priority': 2}, + }, +} + + +def _migrate(profile, cfg_values, monkeypatch): + monkeypatch.setattr('config.settings.config_manager', _FakeCfg(cfg_values), raising=False) + db = MusicDatabase.__new__(MusicDatabase) + return db._migrate_v2_to_v3(profile) + + +def test_v2_to_v3_seeds_hires_when_a_source_was_hires(monkeypatch): + """A user who had Tidal on Hi-Res keeps it: the migrated profile gains 24-bit + targets at the top so quality_tier_for_source resolves to 'hires', not a + silent drop to lossless.""" + out = _migrate(dict(_V2_LOSSLESS), {'tidal_download.quality': 'hires'}, monkeypatch) + top = out['ranked_targets'][0] + assert top['format'] == 'flac' and top['bit_depth'] == 24 + # the user's existing lossy fallback is preserved below the seeded ladder + assert any(t.get('format') == 'mp3' for t in out['ranked_targets']) + + +def test_v2_to_v3_no_seed_without_hires_preference(monkeypatch): + out = _migrate(dict(_V2_LOSSLESS), {'tidal_download.quality': 'lossless'}, monkeypatch) + assert not any(t.get('bit_depth') == 24 for t in out['ranked_targets']) + + +def test_v2_to_v3_no_duplicate_when_profile_already_24bit(monkeypatch): + v2 = dict(_V2_LOSSLESS) + v2['qualities'] = {'flac': {'enabled': True, 'priority': 1, 'bit_depth': '24'}} + out = _migrate(v2, {'qobuz.quality': 'hires_max'}, monkeypatch) + assert sum(1 for t in out['ranked_targets'] if t.get('bit_depth') == 24) == 1