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:
|
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
|
import json
|
||||||
|
|
||||||
try:
|
try:
|
||||||
profile_json = json.dumps(profile)
|
profile_json = json.dumps(profile)
|
||||||
self.set_preference('quality_profile', profile_json)
|
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')}")
|
logger.info(f"Quality profile saved: preset={profile.get('preset', 'custom')}")
|
||||||
return True
|
return True
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Failed to save quality profile: {e}")
|
logger.error(f"Failed to save quality profile: {e}")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
def get_quality_preset(self, preset_name: str) -> dict:
|
def _load_preset_store(self) -> dict:
|
||||||
"""Get a predefined quality preset (v3 format with ranked_targets)."""
|
"""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
|
# Strict 24-bit FLAC ladder — no 16-bit, no lossy. This is what
|
||||||
# "audiophile" means: only true hi-res passes.
|
# "audiophile" means: only true hi-res passes.
|
||||||
_FLAC_24BIT_TARGETS = [
|
_FLAC_24BIT_TARGETS = [
|
||||||
|
|
|
||||||
|
|
@ -8,8 +8,8 @@ from database.music_database import MusicDatabase
|
||||||
|
|
||||||
|
|
||||||
def _preset(name):
|
def _preset(name):
|
||||||
# get_quality_preset doesn't touch self/DB — call unbound to avoid setup.
|
# _factory_quality_preset is pure (no self/DB) — call unbound to avoid setup.
|
||||||
return MusicDatabase.get_quality_preset(None, name)
|
return MusicDatabase._factory_quality_preset(None, name)
|
||||||
|
|
||||||
|
|
||||||
def _labels(profile):
|
def _labels(profile):
|
||||||
|
|
|
||||||
|
|
@ -4526,19 +4526,23 @@ def get_quality_presets():
|
||||||
|
|
||||||
@app.route('/api/quality-profile/preset/<preset_name>', methods=['POST'])
|
@app.route('/api/quality-profile/preset/<preset_name>', methods=['POST'])
|
||||||
def apply_quality_preset(preset_name):
|
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:
|
try:
|
||||||
from database.music_database import MusicDatabase
|
from database.music_database import MusicDatabase
|
||||||
db = 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)
|
success = db.set_quality_profile(preset)
|
||||||
|
|
||||||
if success:
|
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({
|
return jsonify({
|
||||||
"success": True,
|
"success": True,
|
||||||
"message": f"Applied '{preset_name}' preset",
|
"message": f"Switched to '{preset_name}' preset",
|
||||||
"profile": preset
|
"profile": preset
|
||||||
})
|
})
|
||||||
else:
|
else:
|
||||||
|
|
@ -4548,6 +4552,33 @@ def apply_quality_preset(preset_name):
|
||||||
logger.error(f"Error applying quality preset: {e}")
|
logger.error(f"Error applying quality preset: {e}")
|
||||||
return jsonify({"success": False, "error": str(e)}), 500
|
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 ==
|
# == END QUALITY PROFILE API ==
|
||||||
# ===============================
|
# ===============================
|
||||||
|
|
|
||||||
|
|
@ -5020,6 +5020,12 @@
|
||||||
💾 Space Saver
|
💾 Space Saver
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
|
|
||||||
<!-- Ranked target priority list (v3) -->
|
<!-- Ranked target priority list (v3) -->
|
||||||
|
|
|
||||||
|
|
@ -1902,6 +1902,16 @@ function updateHybridSecondaryOptions() {
|
||||||
// ===============================
|
// ===============================
|
||||||
|
|
||||||
let currentQualityProfile = null;
|
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() {
|
async function loadQualityProfile() {
|
||||||
try {
|
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) {
|
function reorderRankedTarget(from, to) {
|
||||||
const [moved] = currentRankedTargets.splice(from, 1);
|
const [moved] = currentRankedTargets.splice(from, 1);
|
||||||
currentRankedTargets.splice(to, 0, moved);
|
currentRankedTargets.splice(to, 0, moved);
|
||||||
markQualityProfileCustom();
|
|
||||||
renderRankedTargets();
|
renderRankedTargets();
|
||||||
debouncedAutoSaveSettings();
|
debouncedSaveQualityProfile();
|
||||||
}
|
}
|
||||||
|
|
||||||
function moveRankedTarget(i, dir) {
|
function moveRankedTarget(i, dir) {
|
||||||
const j = i + dir;
|
const j = i + dir;
|
||||||
if (j < 0 || j >= currentRankedTargets.length) return;
|
if (j < 0 || j >= currentRankedTargets.length) return;
|
||||||
[currentRankedTargets[i], currentRankedTargets[j]] = [currentRankedTargets[j], currentRankedTargets[i]];
|
[currentRankedTargets[i], currentRankedTargets[j]] = [currentRankedTargets[j], currentRankedTargets[i]];
|
||||||
markQualityProfileCustom();
|
|
||||||
renderRankedTargets();
|
renderRankedTargets();
|
||||||
debouncedAutoSaveSettings();
|
debouncedSaveQualityProfile();
|
||||||
}
|
}
|
||||||
|
|
||||||
function deleteRankedTarget(i) {
|
function deleteRankedTarget(i) {
|
||||||
currentRankedTargets.splice(i, 1);
|
currentRankedTargets.splice(i, 1);
|
||||||
markQualityProfileCustom();
|
|
||||||
renderRankedTargets();
|
renderRankedTargets();
|
||||||
debouncedAutoSaveSettings();
|
debouncedSaveQualityProfile();
|
||||||
}
|
}
|
||||||
|
|
||||||
function onRtAddFormatChange() {
|
function onRtAddFormatChange() {
|
||||||
|
|
@ -2044,33 +2046,72 @@ function addRankedTarget() {
|
||||||
}
|
}
|
||||||
t.label = rtLabel(t);
|
t.label = rtLabel(t);
|
||||||
currentRankedTargets.push(t);
|
currentRankedTargets.push(t);
|
||||||
markQualityProfileCustom();
|
|
||||||
renderRankedTargets();
|
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) {
|
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 {
|
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();
|
const data = await response.json();
|
||||||
|
|
||||||
if (data.success) {
|
if (data.success) {
|
||||||
currentQualityProfile = data.profile;
|
currentQualityProfile = data.profile;
|
||||||
populateQualityProfileUI(currentQualityProfile);
|
// Suppress the global change→auto-save listener while we programmatically
|
||||||
showToast(`Applied '${presetName}' preset`, 'success');
|
// 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 {
|
} else {
|
||||||
showToast(`Failed to apply preset: ${data.error}`, 'error');
|
showToast(`Failed to apply preset: ${data.error}`, 'error');
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error applying quality preset:', error);
|
console.error('Error applying quality preset:', error);
|
||||||
showToast('Failed to apply 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