From f9dfbd8fd5d406962749b9c5b541e2bfeb726f12 Mon Sep 17 00:00:00 2001 From: dev Date: Sat, 20 Jun 2026 21:35:45 +0200 Subject: [PATCH] Quality presets: remember per-preset edits + reset-to-defaults Switching presets now restores the user's prior edits to that preset instead of factory defaults. Edits are stashed per preset name under the quality_profile_presets preference; 'custom'/unknown names are not stashed. Adds a /reset endpoint + "Reset to defaults" UI link to drop a preset's saved edits. - DB: set_quality_profile stashes per-preset; get_quality_preset returns the customized form by default, _factory_quality_preset for the raw defaults; reset_quality_preset forgets a preset's edits. - web_server: apply-preset carries the global search_mode across switches; new preset//reset endpoint. - UI: target edits now save via debouncedSaveQualityProfile (profile-only, no full settings re-init/flicker); preset switch suppresses the global auto-save listener; help text + reset link. Co-Authored-By: Claude Opus 4.8 --- database/music_database.py | 54 ++++++++++++++++- tests/quality/test_quality_presets.py | 4 +- web_server.py | 39 ++++++++++-- webui/index.html | 6 ++ webui/static/settings.js | 87 ++++++++++++++++++++------- 5 files changed, 158 insertions(+), 32 deletions(-) diff --git a/database/music_database.py b/database/music_database.py index 07315108..fbfe28e6 100644 --- a/database/music_database.py +++ b/database/music_database.py @@ -8752,21 +8752,69 @@ class MusicDatabase: }, } + # Presets whose per-preset customizations we remember across switches. + _KNOWN_PRESETS = ('audiophile', 'balanced', 'space_saver') + def set_quality_profile(self, profile: dict) -> bool: - """Save quality profile configuration""" + """Save quality profile configuration. + + Besides the single active profile (read by the download pipeline), we also + stash the profile under its preset name so switching presets and coming + back restores the user's edits instead of the factory defaults. 'custom' + and unknown preset names are not stashed.""" import json try: profile_json = json.dumps(profile) self.set_preference('quality_profile', profile_json) + + preset_name = profile.get('preset') + if preset_name in self._KNOWN_PRESETS: + store = self._load_preset_store() + store[preset_name] = profile + self.set_preference('quality_profile_presets', json.dumps(store)) + logger.info(f"Quality profile saved: preset={profile.get('preset', 'custom')}") return True except Exception as e: logger.error(f"Failed to save quality profile: {e}") return False - def get_quality_preset(self, preset_name: str) -> dict: - """Get a predefined quality preset (v3 format with ranked_targets).""" + def _load_preset_store(self) -> dict: + """Per-preset customizations, keyed by preset name. {} if none saved.""" + import json + raw = self.get_preference('quality_profile_presets') + if raw: + try: + parsed = json.loads(raw) + if isinstance(parsed, dict): + return parsed + except json.JSONDecodeError: + logger.error("Failed to parse quality_profile_presets, ignoring") + return {} + + def reset_quality_preset(self, preset_name: str) -> dict: + """Forget a preset's saved customizations and return its factory defaults.""" + import json + store = self._load_preset_store() + if preset_name in store: + del store[preset_name] + self.set_preference('quality_profile_presets', json.dumps(store)) + return self.get_quality_preset(preset_name, customized=False) + + def get_quality_preset(self, preset_name: str, *, customized: bool = True) -> dict: + """Get a quality preset (v3 format with ranked_targets). + + With ``customized`` (default), a preset the user has edited is returned in + its saved form; otherwise the hard-coded factory defaults are returned.""" + if customized: + saved = self._load_preset_store().get(preset_name) + if saved: + return saved + return self._factory_quality_preset(preset_name) + + def _factory_quality_preset(self, preset_name: str) -> dict: + """The hard-coded factory defaults for a preset (ignores customizations).""" # Strict 24-bit FLAC ladder — no 16-bit, no lossy. This is what # "audiophile" means: only true hi-res passes. _FLAC_24BIT_TARGETS = [ diff --git a/tests/quality/test_quality_presets.py b/tests/quality/test_quality_presets.py index 2ffcfaa0..d6b8bb92 100644 --- a/tests/quality/test_quality_presets.py +++ b/tests/quality/test_quality_presets.py @@ -8,8 +8,8 @@ from database.music_database import MusicDatabase def _preset(name): - # get_quality_preset doesn't touch self/DB — call unbound to avoid setup. - return MusicDatabase.get_quality_preset(None, name) + # _factory_quality_preset is pure (no self/DB) — call unbound to avoid setup. + return MusicDatabase._factory_quality_preset(None, name) def _labels(profile): diff --git a/web_server.py b/web_server.py index 8bbd4091..0e3f955b 100644 --- a/web_server.py +++ b/web_server.py @@ -4526,19 +4526,23 @@ def get_quality_presets(): @app.route('/api/quality-profile/preset/', methods=['POST']) def apply_quality_preset(preset_name): - """Apply a predefined quality preset""" + """Switch to a quality preset, restoring its saved edits if it has any.""" try: from database.music_database import MusicDatabase db = MusicDatabase() - preset = db.get_quality_preset(preset_name) + current = db.get_quality_profile() + preset = dict(db.get_quality_preset(preset_name)) + # search_mode is a global search strategy, not a per-preset audio setting — + # carry the user's current choice across preset switches. + preset['search_mode'] = current.get('search_mode', preset.get('search_mode', 'priority')) success = db.set_quality_profile(preset) if success: - add_activity_item("", "Quality Preset Applied", f"Applied '{preset_name}' preset", "Now") + add_activity_item("", "Quality Preset Applied", f"Switched to '{preset_name}' preset", "Now") return jsonify({ "success": True, - "message": f"Applied '{preset_name}' preset", + "message": f"Switched to '{preset_name}' preset", "profile": preset }) else: @@ -4548,6 +4552,33 @@ def apply_quality_preset(preset_name): logger.error(f"Error applying quality preset: {e}") return jsonify({"success": False, "error": str(e)}), 500 + +@app.route('/api/quality-profile/preset//reset', methods=['POST']) +def reset_quality_preset(preset_name): + """Discard a preset's saved edits and restore its factory defaults.""" + try: + from database.music_database import MusicDatabase + db = MusicDatabase() + + current = db.get_quality_profile() + preset = dict(db.reset_quality_preset(preset_name)) + preset['search_mode'] = current.get('search_mode', preset.get('search_mode', 'priority')) + success = db.set_quality_profile(preset) + + if success: + add_activity_item("", "Quality Preset Reset", f"Reset '{preset_name}' to defaults", "Now") + return jsonify({ + "success": True, + "message": f"Reset '{preset_name}' to defaults", + "profile": preset + }) + else: + return jsonify({"success": False, "error": "Failed to reset preset"}), 500 + + except Exception as e: + logger.error(f"Error resetting quality preset: {e}") + return jsonify({"success": False, "error": str(e)}), 500 + # =============================== # == END QUALITY PROFILE API == # =============================== diff --git a/webui/index.html b/webui/index.html index 0a9a31c3..d9c8a301 100644 --- a/webui/index.html +++ b/webui/index.html @@ -5020,6 +5020,12 @@ 💾 Space Saver +
+ Edits you make below are saved per preset — switch away and back and + your changes are still there. Use + Reset to defaults + to restore the selected preset's factory settings. +
diff --git a/webui/static/settings.js b/webui/static/settings.js index 73a63134..6bcffe17 100644 --- a/webui/static/settings.js +++ b/webui/static/settings.js @@ -1902,6 +1902,16 @@ function updateHybridSecondaryOptions() { // =============================== let currentQualityProfile = null; +let qualityProfileAutoSaveTimer = null; + +// Save just the quality profile (not the whole settings page). Used for quality +// target edits so reordering a target doesn't re-init every backend client. +function debouncedSaveQualityProfile() { + if (window._suppressSettingsAutoSave) return; + if (window._settingsLoadFailed) return; + if (qualityProfileAutoSaveTimer) clearTimeout(qualityProfileAutoSaveTimer); + qualityProfileAutoSaveTimer = setTimeout(() => saveQualityProfile(), 800); +} async function loadQualityProfile() { try { @@ -1993,33 +2003,25 @@ function renderRankedTargets() { }); } -function markQualityProfileCustom() { - if (currentQualityProfile) currentQualityProfile.preset = 'custom'; - document.querySelectorAll('.preset-button').forEach(btn => btn.classList.remove('active')); -} - function reorderRankedTarget(from, to) { const [moved] = currentRankedTargets.splice(from, 1); currentRankedTargets.splice(to, 0, moved); - markQualityProfileCustom(); renderRankedTargets(); - debouncedAutoSaveSettings(); + debouncedSaveQualityProfile(); } function moveRankedTarget(i, dir) { const j = i + dir; if (j < 0 || j >= currentRankedTargets.length) return; [currentRankedTargets[i], currentRankedTargets[j]] = [currentRankedTargets[j], currentRankedTargets[i]]; - markQualityProfileCustom(); renderRankedTargets(); - debouncedAutoSaveSettings(); + debouncedSaveQualityProfile(); } function deleteRankedTarget(i) { currentRankedTargets.splice(i, 1); - markQualityProfileCustom(); renderRankedTargets(); - debouncedAutoSaveSettings(); + debouncedSaveQualityProfile(); } function onRtAddFormatChange() { @@ -2044,33 +2046,72 @@ function addRankedTarget() { } t.label = rtLabel(t); currentRankedTargets.push(t); - markQualityProfileCustom(); renderRankedTargets(); - debouncedAutoSaveSettings(); + debouncedSaveQualityProfile(); } +const PRESET_LABELS = { audiophile: 'Audiophile', balanced: 'Balanced', space_saver: 'Space Saver' }; + +// Switch to a preset. The backend restores the preset's saved edits (or factory +// defaults if untouched) and persists it as the active profile, so there is no +// follow-up save here and no full-page loading overlay (which caused the flicker). async function applyQualityPreset(presetName) { + // Drop any queued auto-save so a stale-target write can't land after the switch. + if (settingsAutoSaveTimer) { clearTimeout(settingsAutoSaveTimer); settingsAutoSaveTimer = null; } + if (qualityProfileAutoSaveTimer) { clearTimeout(qualityProfileAutoSaveTimer); qualityProfileAutoSaveTimer = null; } + try { - showLoadingOverlay(`Applying ${presetName} preset...`); - - const response = await fetch(`/api/quality-profile/preset/${presetName}`, { - method: 'POST' - }); - + const response = await fetch(`/api/quality-profile/preset/${presetName}`, { method: 'POST' }); const data = await response.json(); if (data.success) { currentQualityProfile = data.profile; - populateQualityProfileUI(currentQualityProfile); - showToast(`Applied '${presetName}' preset`, 'success'); + // Suppress the global change→auto-save listener while we programmatically + // set checkbox + select values — these aren't user edits. + window._suppressSettingsAutoSave = true; + try { + populateQualityProfileUI(currentQualityProfile); + } finally { + window._suppressSettingsAutoSave = false; + } + showToast(`Switched to '${PRESET_LABELS[presetName] || presetName}'`, 'success'); } else { showToast(`Failed to apply preset: ${data.error}`, 'error'); } } catch (error) { console.error('Error applying quality preset:', error); showToast('Failed to apply preset', 'error'); - } finally { - hideLoadingOverlay(); + } +} + +// Discard the active preset's saved edits and restore its factory defaults. +async function resetActiveQualityPreset() { + const presetName = currentQualityProfile?.preset; + if (!presetName || !(presetName in PRESET_LABELS)) { + showToast('No preset selected to reset', 'info'); + return; + } + if (settingsAutoSaveTimer) { clearTimeout(settingsAutoSaveTimer); settingsAutoSaveTimer = null; } + if (qualityProfileAutoSaveTimer) { clearTimeout(qualityProfileAutoSaveTimer); qualityProfileAutoSaveTimer = null; } + + try { + const response = await fetch(`/api/quality-profile/preset/${presetName}/reset`, { method: 'POST' }); + const data = await response.json(); + if (data.success) { + currentQualityProfile = data.profile; + window._suppressSettingsAutoSave = true; + try { + populateQualityProfileUI(currentQualityProfile); + } finally { + window._suppressSettingsAutoSave = false; + } + showToast(`Reset '${PRESET_LABELS[presetName] || presetName}' to defaults`, 'success'); + } else { + showToast(`Failed to reset preset: ${data.error}`, 'error'); + } + } catch (error) { + console.error('Error resetting quality preset:', error); + showToast('Failed to reset preset', 'error'); } }