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:
parent
bfc207f21a
commit
f9dfbd8fd5
5 changed files with 158 additions and 32 deletions
|
|
@ -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 = [
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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 ==
|
||||
# ===============================
|
||||
|
|
|
|||
|
|
@ -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) -->
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue