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/<name>/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 <noreply@anthropic.com>
This commit is contained in:
dev 2026-06-20 21:35:45 +02:00
parent bfc207f21a
commit f9dfbd8fd5
5 changed files with 158 additions and 32 deletions

View file

@ -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 = [

View file

@ -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):

View file

@ -4526,19 +4526,23 @@ def get_quality_presets():
@app.route('/api/quality-profile/preset/<preset_name>', 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/<preset_name>/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 ==
# ===============================

View file

@ -5020,6 +5020,12 @@
💾 Space Saver
</button>
</div>
<div class="help-text" style="margin-top:6px">
Edits you make below are saved <em>per preset</em> — switch away and back and
your changes are still there. Use
<a href="#" onclick="resetActiveQualityPreset(); return false;">Reset to defaults</a>
to restore the selected preset's factory settings.
</div>
</div>
<!-- Ranked target priority list (v3) -->

View file

@ -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');
}
}