From 9f7f4c14615c6677bdeebdc162f71e22ddedf40d Mon Sep 17 00:00:00 2001 From: Thales <> Date: Wed, 26 Aug 2026 14:43:25 +0100 Subject: [PATCH 1/3] Keep finished tracks unless the user asks otherwise (#459, #462, #463) A Windows user restored a library of four completed jobs, started the 0.15.1 backend against it, and two of them were gone from disk and from registry.json. Reproduced here on the packaged 0.15.2 backend with their own job IDs: without STEMDECK_DESKTOP=1 the same two disappear, with it all four survive. Three problems stacked up to produce that. The deletion was the default. sweep_old_jobs removed terminal jobs older than 24 hours and ran seconds after startup, unless an environment variable switched it off. Every documented way of launching StemDeck set one: the shell sets STEMDECK_DESKTOP=1, run.sh defaults STEMDECK_PERSIST_LIBRARY=1, and the Unraid template ships it as 1. So the sweep only ever fired on paths nobody documents, where it did the most damage. A missing environment variable must not mean "delete the user's work", so deletion is now opt-in and the two variables are redundant rather than load-bearing. STEMDECK_PERSIST_LIBRARY=0 is kept as the one env-based way in, for deployments that set it deliberately. The settings file could not be found. _SETTINGS_PATH derives from DATA_DIR, which falls back to ROOT when STEMDECK_DATA_DIR is unset, and only the desktop shell sets that. A backend started any other way read backend/settings.json, which does not exist, and silently ignored the stems location, port, device, quality and language the user had chosen. It now finds the data directory from the layout every package shares: ROOT named backend with a data/ sibling. Docker resolves ROOT to /app and a checkout resolves it to the repo, so neither changes. There was nowhere to see or change any of this. Settings > General now has a switch, off by default, that reveals a days field when it is on. The stored choice beats the environment, unlike jobs_dir where the env pin wins because a mounted volume is not the user's to relocate; how long someone's own work is kept is theirs to decide, and StemDeck is single-user with no separate operator to protect. A tuned STEMDECK_JOB_TTL_SECONDS seeds the default days so existing deployments keep their intent. The sweep loop now re-reads the setting every pass. It used to decide once before its first iteration, which would have made this the only setting in the panel that did nothing until relaunch. No "0 means never": in a field reading "delete after N days", 0 most naturally reads as delete immediately, and that ambiguity has no place on the one control that destroys work. Tests: 13 new Python tests covering the default, the env precedence, the day clamps and the loop re-read, plus 5 browser tests for the reveal, the round trip and the server-owned ceiling. Keys added to all nine i18n tables, with European Portuguese overriding eliminar/predefinicao. --- app/core/config.py | 34 +++++++- app/core/settings.py | 80 ++++++++++++++++++ app/main.py | 48 +++++------ app/pipeline/collect.py | 12 +-- static/js/catalog.js | 49 ++++++++++- static/js/i18n.js | 36 ++++++++ tests/e2e/retention-setting.spec.mjs | 108 ++++++++++++++++++++++++ tests/test_config.py | 45 ++++++++++ tests/test_retention_setting.py | 82 ++++++++++++++++++ tests/test_sweep.py | 121 +++++++++++++++++++-------- 10 files changed, 545 insertions(+), 70 deletions(-) create mode 100644 tests/e2e/retention-setting.spec.mjs create mode 100644 tests/test_retention_setting.py diff --git a/app/core/config.py b/app/core/config.py index 39d197ea..feb00d5b 100644 --- a/app/core/config.py +++ b/app/core/config.py @@ -61,12 +61,38 @@ def detect_torch_device() -> str: EXTRA_STEM_NAMES: tuple[str, ...] = ("lead_vocals", "backing_vocals") JOB_ID_RE = re.compile(r"^[a-f0-9]{12}$") + +def _packaged_data_dir() -> Path | None: + """The data folder of a desktop package, found without being told. + + The desktop shell always passes STEMDECK_DATA_DIR, so this only matters + when the backend is started some other way: by hand, from a terminal, out + of the package the shell would normally launch. That used to resolve + DATA_DIR to `backend/` itself, so the backend read a settings.json that did + not exist and ignored every choice the user had made in the app. Silent, + and it made a directly-run backend a different application with the same + files (#459). + + Keyed on the layout every package shares -- `/backend/app` here, + `/data` beside it -- rather than on a marker file, because only + the Windows package writes one. A source checkout has ROOT named for the + repo and Docker has WORKDIR /app, so neither matches and neither changes. + """ + if ROOT.name != "backend": + return None + candidate = ROOT.parent / "data" + return candidate if candidate.is_dir() else None + + # Runtime knobs -- env-backed so Docker / desktop packaging / local dev can # tune without a code edit. STEMDECK_DATA_DIR is the portable app root for -# mutable runtime data; when unset, dev behavior remains the repo-local jobs/ -# folder. -PORTABLE_DATA_DIR_ENABLED = bool(os.environ.get("STEMDECK_DATA_DIR", "").strip()) -DATA_DIR = _env_path("STEMDECK_DATA_DIR", ROOT) +# mutable runtime data; when unset, a package finds its own (above) and a plain +# dev checkout stays on the repo-local jobs/ folder. +_PACKAGED_DATA_DIR = _packaged_data_dir() +PORTABLE_DATA_DIR_ENABLED = bool( + os.environ.get("STEMDECK_DATA_DIR", "").strip() or _PACKAGED_DATA_DIR +) +DATA_DIR = _env_path("STEMDECK_DATA_DIR", _PACKAGED_DATA_DIR or ROOT) def _stored_jobs_dir() -> Path | None: diff --git a/app/core/settings.py b/app/core/settings.py index 4e5f4aaf..6772b395 100644 --- a/app/core/settings.py +++ b/app/core/settings.py @@ -12,6 +12,8 @@ - `demucs_device` — compute device for separation: auto | cuda | mps | cpu. - `separation_quality` — demucs shift-averaging: standard | best (2x slower). - `cookies_file` — optional cookies.txt handed to yt-dlp for YouTube. +- `auto_delete_jobs` — whether finished jobs are deleted after a while (off). +- `auto_delete_days` — how long they are kept when that is on. Defaults fall back to the config.py constants (which honor their env vars), so nothing changes until the user overrides a value. @@ -166,6 +168,84 @@ def set_allow_network(value: bool) -> bool: return bool(value) +# ── auto_delete_jobs / auto_delete_days ── +# +# Whether finished jobs are deleted after a while, and after how long. +# +# Off unless the user says otherwise, and that direction is the whole point. +# Deleting a finished separation destroys work that cannot be recovered, so the +# behaviour of an install nobody has configured has to be "keep it". It used to +# be the reverse: the sweep ran unless an environment variable switched it off, +# which meant every documented way of starting StemDeck set that variable and +# anyone who started the backend directly silently lost their library within a +# day (#459). +# +# The stored setting wins over the environment, unlike jobs_dir where the env +# pin wins. A mounted volume is not the user's to relocate; how long their own +# work is kept is exactly their call, and StemDeck is single-user with no +# separate operator to protect. +_AUTO_DELETE_DAYS_MIN, _AUTO_DELETE_DAYS_MAX = 1, 365 +AUTO_DELETE_DAYS_MIN, AUTO_DELETE_DAYS_MAX = _AUTO_DELETE_DAYS_MIN, _AUTO_DELETE_DAYS_MAX +DEFAULT_AUTO_DELETE_DAYS = 30 + + +def _default_auto_delete_jobs() -> bool: + """Only an explicit STEMDECK_PERSIST_LIBRARY=0 asks for deletion. + + The variable reads as "persist the library", so 0 means "do not", which is + the one env-based way left to opt in. Unset, malformed, or 1 all mean keep, + so a deployment that forgets it loses nothing. + """ + return os.environ.get("STEMDECK_PERSIST_LIBRARY", "").strip() == "0" + + +def get_auto_delete_jobs() -> bool: + with _LOCK: + v = _ensure().get("auto_delete_jobs") + return v if isinstance(v, bool) else _default_auto_delete_jobs() + + +def set_auto_delete_jobs(value: bool) -> bool: + with _LOCK: + _ensure()["auto_delete_jobs"] = bool(value) + _save() + return bool(value) + + +def _default_auto_delete_days() -> int: + """Honour a STEMDECK_JOB_TTL_SECONDS somebody already tuned. + + That knob predates this setting and is in seconds, so it is converted and + clamped. A TTL shorter than a day becomes one day rather than none: the + control is in days now, and rounding someone's one-hour sweep down to zero + would turn a deliberately aggressive setting into a much slower one. + """ + raw = os.environ.get("STEMDECK_JOB_TTL_SECONDS", "").strip() + if raw: + try: + days = round(int(raw) / 86400) or _AUTO_DELETE_DAYS_MIN + except ValueError: + return DEFAULT_AUTO_DELETE_DAYS + return max(_AUTO_DELETE_DAYS_MIN, min(_AUTO_DELETE_DAYS_MAX, days)) + return DEFAULT_AUTO_DELETE_DAYS + + +def get_auto_delete_days() -> int: + with _LOCK: + v = _num(_ensure().get("auto_delete_days")) + if v is None: + return _default_auto_delete_days() + return max(_AUTO_DELETE_DAYS_MIN, min(_AUTO_DELETE_DAYS_MAX, v)) + + +def set_auto_delete_days(value: int) -> int: + clamped = max(_AUTO_DELETE_DAYS_MIN, min(_AUTO_DELETE_DAYS_MAX, int(value))) + with _LOCK: + _ensure()["auto_delete_days"] = clamped + _save() + return clamped + + # ── max_duration_sec ── def get_max_duration_sec() -> int: with _LOCK: diff --git a/app/main.py b/app/main.py index 14bce40f..2565197a 100644 --- a/app/main.py +++ b/app/main.py @@ -40,9 +40,13 @@ from app.core.registry import reset_all as reset_registry from app.core.registry import restore as restore_registry from app.core.settings import ( + AUTO_DELETE_DAYS_MAX, + AUTO_DELETE_DAYS_MIN, DURATION_MAX_SEC, DURATION_MIN_SEC, get_allow_network, + get_auto_delete_days, + get_auto_delete_jobs, get_cookies_file, get_demucs_device, get_demucs_device_choice, @@ -54,6 +58,8 @@ get_separation_quality, get_video_max_height, set_allow_network, + set_auto_delete_days, + set_auto_delete_jobs, set_cookies_file, set_demucs_device, set_export_sample_rate, @@ -137,34 +143,18 @@ def app_version() -> str: return "0.0.0-dev" -def _sweep_disabled() -> bool: - """The desktop app is a personal, user-curated library (folders + Trash), - with its track list persisted permanently in ~/Documents/StemDeck. The 24h - job TTL sweep -- a sensible disk-hygiene default for the shared server/Docker - deployment -- would wrongly purge stems the user kept, leaving orphaned - library entries that ask to "re-upload to restore". - - So skip the sweep under the desktop shell (STEMDECK_DESKTOP=1), or when a - self-hosted deployment opts into a persistent library - (STEMDECK_PERSIST_LIBRARY=1 -- set by default in run.sh). The user manages - disk via Trash. Shared/Docker deployments that set neither keep the sweep.""" - return ( - os.environ.get("STEMDECK_DESKTOP") == "1" - or os.environ.get("STEMDECK_PERSIST_LIBRARY") == "1" - ) - - async def _sweep_loop() -> None: - # The job TTL sweep is disabled for persistent libraries, but the - # failed-job quarantine (jobs/failed/) expires unconditionally -- failure + # Finished jobs are only deleted when the user has asked for it. The + # failed-job quarantine (jobs/failed/) expires either way -- failure # evidence is diagnostics, not library content, on every deployment. - persistent = _sweep_disabled() - if persistent: - _log.info("job TTL sweep disabled (persistent library; user-managed)") while True: try: - if not persistent: - await asyncio.to_thread(sweep_old_jobs, JOBS_DIR) + # Read every pass rather than once at startup. This is a live + # setting, so turning deletion on or off has to take effect without + # a restart, the same as every other setting in the panel. + if get_auto_delete_jobs(): + ttl = get_auto_delete_days() * 86400 + await asyncio.to_thread(sweep_old_jobs, JOBS_DIR, ttl) await asyncio.to_thread(sweep_failed_jobs, JOBS_DIR) except Exception: _log.warning("sweep failed", exc_info=True) @@ -329,6 +319,13 @@ def _is_lan_ipv4(ip: str) -> bool: def _settings_payload() -> dict[str, object]: return { "allow_network": get_allow_network(), + # Off unless the user asked for it. The days value is published even + # when it is off, so the field the toggle reveals has something to show + # rather than appearing empty on first click. + "auto_delete_jobs": get_auto_delete_jobs(), + "auto_delete_days": get_auto_delete_days(), + "auto_delete_days_min": AUTO_DELETE_DAYS_MIN, + "auto_delete_days_max": AUTO_DELETE_DAYS_MAX, "max_duration_sec": get_max_duration_sec(), # The clamp bounds, so the UI does not need its own copy of them. It # had one, it was stale (20 min against a 60 min ceiling), and the @@ -375,7 +372,10 @@ async def update_settings(request: Request) -> dict[str, object]: body = {} if "allow_network" in body: set_allow_network(bool(body["allow_network"])) + if "auto_delete_jobs" in body: + set_auto_delete_jobs(bool(body["auto_delete_jobs"])) for key, setter in ( + ("auto_delete_days", set_auto_delete_days), ("max_duration_sec", set_max_duration_sec), ("playlist_max_items", set_playlist_max_items), ("video_max_height", set_video_max_height), diff --git a/app/pipeline/collect.py b/app/pipeline/collect.py index f78e9e27..81356fa2 100644 --- a/app/pipeline/collect.py +++ b/app/pipeline/collect.py @@ -250,17 +250,19 @@ def merge_stem_peaks(stems_dir: Path, new_names: list[str]) -> None: logger.warning("could not write peaks.json for %s", stems_dir.name, exc_info=True) -def sweep_old_jobs(jobs_dir: Path) -> None: - """Delete job directories older than JOB_TTL_SECONDS and remove them from - the in-memory registry. Called hourly from the background sweep loop - started at app startup. +def sweep_old_jobs(jobs_dir: Path, ttl_seconds: int | None = None) -> None: + """Delete job directories older than `ttl_seconds` (JOB_TTL_SECONDS when + not given) and remove them from the in-memory registry. Called hourly from + the background sweep loop started at app startup, and only when the user + has asked for automatic deletion -- deciding *whether* to sweep is the + caller's job, this one only decides what is old. Prefers Job.created_at over directory mtime (which can be touched by unrelated filesystem events), and never deletes the directory of an active (non-terminal) registered job even if its timestamp looks old. Falls back to mtime for orphan directories left over from a previous server run, since the registry is in-memory only.""" - cutoff = time.time() - JOB_TTL_SECONDS + cutoff = time.time() - (JOB_TTL_SECONDS if ttl_seconds is None else ttl_seconds) if not jobs_dir.is_dir(): return jobs = registry_all() diff --git a/static/js/catalog.js b/static/js/catalog.js index 2d23cb38..12342d4c 100644 --- a/static/js/catalog.js +++ b/static/js/catalog.js @@ -3028,7 +3028,11 @@ async function wireGeneralSettings(overlay) { const qualitySel = overlay.querySelector(".set-separation-quality"); const cookiesInput = overlay.querySelector(".set-cookies-file"); const cookiesMsg = overlay.querySelector(".cookies-file-msg"); - if (!durInput && !playlistInput && !heightSel && !sampleRateSel && !portInput && !deviceSel && !qualitySel && !cookiesInput) return; + const autoDeleteInput = overlay.querySelector(".auto-delete-input"); + const autoDeleteDaysRow = overlay.querySelector(".auto-delete-days-row"); + const autoDeleteDays = overlay.querySelector(".set-auto-delete-days"); + const autoDeleteDaysDesc = overlay.querySelector(".auto-delete-days-desc"); + if (!durInput && !playlistInput && !heightSel && !sampleRateSel && !portInput && !deviceSel && !qualitySel && !cookiesInput && !autoDeleteInput) return; // Last server-confirmed device choice, to revert the select when the server // rejects a forced device (e.g. CUDA not available on this machine). @@ -3051,6 +3055,19 @@ async function wireGeneralSettings(overlay) { // Unset is the normal case, so read the key rather than truthiness -- // clearing the field must survive the round trip and not be repopulated. if (cookiesInput && "cookies_file" in d) cookiesInput.value = d.cookies_file || ""; + // Read the key, not truthiness: false is the normal value here and the + // whole point of the setting, so `d.auto_delete_jobs &&` would leave the + // switch showing whatever it showed last. + if (autoDeleteInput && "auto_delete_jobs" in d) { + autoDeleteInput.checked = d.auto_delete_jobs === true; + autoDeleteDaysRow?.classList.toggle("hidden", !autoDeleteInput.checked); + } + if (autoDeleteDays && d.auto_delete_days) autoDeleteDays.value = String(d.auto_delete_days); + if (autoDeleteDaysDesc && d.auto_delete_days_max) { + autoDeleteDaysDesc.textContent = i18nT("settings.autoDelete.daysDesc", { + max: d.auto_delete_days_max, + }); + } if (deviceSel) { // Gray out devices this machine can't use (Auto and CPU are always // available). Label disabled options so it's clear WHY they're greyed. @@ -3087,6 +3104,7 @@ async function wireGeneralSettings(overlay) { digitsOnly(durInput); digitsOnly(playlistInput); digitsOnly(portInput); + digitsOnly(autoDeleteDays); try { const r = await fetch("/api/settings", { cache: "no-store" }); @@ -3150,6 +3168,18 @@ async function wireGeneralSettings(overlay) { qualitySel?.addEventListener("change", () => { post({ separation_quality: qualitySel.value }); }); + autoDeleteInput?.addEventListener("change", () => { + // Reveal the days field immediately rather than waiting for the round + // trip, so the switch does not appear to do nothing on a slow response. + // apply() sets it again from the server's answer either way. + autoDeleteDaysRow?.classList.toggle("hidden", !autoDeleteInput.checked); + post({ auto_delete_jobs: autoDeleteInput.checked }); + }); + autoDeleteDays?.addEventListener("change", () => { + // Floor of 1 only. The server owns the ceiling and returns what it kept, + // the same arrangement as max track length, so the two cannot drift. + post({ auto_delete_days: Math.max(1, parseInt(autoDeleteDays.value, 10) || 30) }); + }); // Compute device needs its own POST path: unlike the clamped numeric // settings, the server can REJECT a forced device (422 with a reason, e.g. // "cuda is not available on this machine") -- surface that and revert. @@ -3527,6 +3557,23 @@ function openLibraryEditor() {
+
+
+
Automatically delete finished tracks
+
Off unless you turn it on. Separated tracks are kept forever by default. Deleting them cannot be undone.
+
+ +
+
diff --git a/static/js/i18n.js b/static/js/i18n.js index 99fae118..6450efc1 100644 --- a/static/js/i18n.js +++ b/static/js/i18n.js @@ -464,6 +464,10 @@ const en = { "settings.cookies.placeholder": "Path to cookies.txt", "settings.cookies.invalid": "File not found, or not readable.", "settings.stemsLocation.title": "StemData location", + "settings.autoDelete.title": "Automatically delete finished tracks", + "settings.autoDelete.desc": "Off unless you turn it on. Separated tracks are kept forever by default. Deleting them cannot be undone.", + "settings.autoDelete.daysTitle": "Delete after", + "settings.autoDelete.daysDesc": "Days a finished track is kept before it is deleted (max {max}).", "settings.stemsLocation.change": "Change…", "settings.stemsLocation.resetting": "Resetting…", "settings.stemsLocation.resetFailed": "Reset failed — check your connection.", @@ -475,6 +479,10 @@ const en = { "settings.stemsLocation.restartNote": "Restart StemDeck to finish switching over.", "settings.stemsLocation.pickerFailed": "Could not open the folder picker.", "settings.stemsLocation.moving": "Moving stems… this can take a while for a large library.", + "settings.autoDelete.title": "Eliminar automaticamente as faixas concluídas", + "settings.autoDelete.desc": "Desativado por predefinição. As faixas separadas são mantidas para sempre. A eliminação não pode ser anulada.", + "settings.autoDelete.daysTitle": "Eliminar após", + "settings.autoDelete.daysDesc": "Dias que uma faixa concluída é mantida antes de ser eliminada (máx. {max}).", "settings.stemsLocation.moveFailed": "Could not move the stems folder.", "settings.stemsLocation.serverUnreachable": "Could not reach the server.", "settings.stemsLocation.movedPersistFailed.one": "Moved {count} item, but StemDeck could not save this as your new location (check that the folder is writable). Restarting now would revert to the old location. Try setting it again.", @@ -985,6 +993,10 @@ const pl = { "settings.cookies.placeholder": "Ścieżka do cookies.txt", "settings.cookies.invalid": "Nie znaleziono pliku lub nie można go odczytać.", "settings.stemsLocation.title": "Lokalizacja StemData", + "settings.autoDelete.title": "Automatycznie usuwaj ukończone utwory", + "settings.autoDelete.desc": "Domyślnie wyłączone. Rozdzielone utwory są przechowywane bez końca. Usunięcia nie można cofnąć.", + "settings.autoDelete.daysTitle": "Usuń po", + "settings.autoDelete.daysDesc": "Liczba dni przechowywania ukończonego utworu przed usunięciem (maks. {max}).", "settings.stemsLocation.change": "Zmień…", "settings.stemsLocation.resetting": "Resetowanie…", "settings.stemsLocation.resetFailed": "Resetowanie nie powiodło się — sprawdź połączenie.", @@ -1498,6 +1510,10 @@ const ja = { "settings.cookies.placeholder": "cookies.txt のパス", "settings.cookies.invalid": "ファイルが見つからないか、読み取れません。", "settings.stemsLocation.title": "StemDataの保存場所", + "settings.autoDelete.title": "完了したトラックを自動的に削除", + "settings.autoDelete.desc": "初期状態ではオフです。分離したトラックは既定で保持され続けます。削除は取り消せません。", + "settings.autoDelete.daysTitle": "削除するまでの日数", + "settings.autoDelete.daysDesc": "完了したトラックを削除するまで保持する日数(最大{max}日)。", "settings.stemsLocation.change": "変更…", "settings.stemsLocation.resetting": "リセット中…", "settings.stemsLocation.resetFailed": "リセットに失敗しました — 接続を確認してください。", @@ -1987,6 +2003,10 @@ const zhHans = { "settings.cookies.placeholder": "cookies.txt 的路径", "settings.cookies.invalid": "找不到文件,或无法读取。", "settings.stemsLocation.title": "StemData 存储位置", + "settings.autoDelete.title": "自动删除已完成的音轨", + "settings.autoDelete.desc": "默认关闭。分离后的音轨默认会一直保留。删除后无法恢复。", + "settings.autoDelete.daysTitle": "保留天数", + "settings.autoDelete.daysDesc": "已完成的音轨在删除前保留的天数(最多 {max} 天)。", "settings.stemsLocation.change": "更改…", "settings.stemsLocation.resetting": "正在重置…", "settings.stemsLocation.resetFailed": "重置失败 — 请检查你的网络连接。", @@ -2477,6 +2497,10 @@ const de = { "settings.cookies.placeholder": "Pfad zu cookies.txt", "settings.cookies.invalid": "Datei nicht gefunden oder nicht lesbar.", "settings.stemsLocation.title": "StemData-Speicherort", + "settings.autoDelete.title": "Fertige Titel automatisch löschen", + "settings.autoDelete.desc": "Standardmäßig aus. Getrennte Titel bleiben dauerhaft erhalten. Das Löschen lässt sich nicht rückgängig machen.", + "settings.autoDelete.daysTitle": "Löschen nach", + "settings.autoDelete.daysDesc": "Tage, die ein fertiger Titel aufbewahrt wird, bevor er gelöscht wird (max. {max}).", "settings.stemsLocation.change": "Ändern…", "settings.stemsLocation.resetting": "Wird zurückgesetzt…", "settings.stemsLocation.resetFailed": "Zurücksetzen fehlgeschlagen — Verbindung prüfen.", @@ -2977,6 +3001,10 @@ const pt = { "settings.cookies.placeholder": "Caminho para cookies.txt", "settings.cookies.invalid": "Arquivo não encontrado ou ilegível.", "settings.stemsLocation.title": "Local do StemData", + "settings.autoDelete.title": "Excluir automaticamente as faixas concluídas", + "settings.autoDelete.desc": "Desativado por padrão. As faixas separadas são mantidas para sempre. A exclusão não pode ser desfeita.", + "settings.autoDelete.daysTitle": "Excluir após", + "settings.autoDelete.daysDesc": "Dias que uma faixa concluída é mantida antes de ser excluída (máx. {max}).", "settings.stemsLocation.change": "Alterar…", "settings.stemsLocation.resetting": "Redefinindo…", "settings.stemsLocation.resetFailed": "Falha ao redefinir — verifique sua conexão.", @@ -3478,6 +3506,10 @@ const id = { "settings.cookies.placeholder": "Jalur ke cookies.txt", "settings.cookies.invalid": "Berkas tidak ditemukan atau tidak dapat dibaca.", "settings.stemsLocation.title": "Lokasi StemData", + "settings.autoDelete.title": "Hapus otomatis trek yang selesai", + "settings.autoDelete.desc": "Nonaktif secara bawaan. Trek yang sudah dipisah disimpan selamanya. Penghapusan tidak dapat dibatalkan.", + "settings.autoDelete.daysTitle": "Hapus setelah", + "settings.autoDelete.daysDesc": "Jumlah hari trek yang selesai disimpan sebelum dihapus (maks. {max}).", "settings.stemsLocation.change": "Ubah…", "settings.stemsLocation.resetting": "Mengatur ulang…", "settings.stemsLocation.resetFailed": "Gagal mengatur ulang — periksa koneksi Anda.", @@ -3969,6 +4001,10 @@ const fr = { "settings.cookies.placeholder": "Chemin vers cookies.txt", "settings.cookies.invalid": "Fichier introuvable ou illisible.", "settings.stemsLocation.title": "Emplacement des StemData", + "settings.autoDelete.title": "Supprimer automatiquement les morceaux terminés", + "settings.autoDelete.desc": "Désactivé par défaut. Les morceaux séparés sont conservés indéfiniment. La suppression est irréversible.", + "settings.autoDelete.daysTitle": "Supprimer après", + "settings.autoDelete.daysDesc": "Nombre de jours pendant lesquels un morceau terminé est conservé avant suppression (max. {max}).", "settings.stemsLocation.change": "Modifier…", "settings.stemsLocation.resetting": "Réinitialisation…", "settings.stemsLocation.resetFailed": "Échec de la réinitialisation — vérifiez votre connexion.", diff --git a/tests/e2e/retention-setting.spec.mjs b/tests/e2e/retention-setting.spec.mjs new file mode 100644 index 00000000..f74a4211 --- /dev/null +++ b/tests/e2e/retention-setting.spec.mjs @@ -0,0 +1,108 @@ +// The automatic-deletion setting in Settings > General (#459). +// +// Worth driving in a real browser rather than asserting on the API alone, +// because the risk here is not the value, it is the control. This setting +// destroys work that cannot be recovered, and the days field only exists while +// the switch is on. A reveal that fails leaves someone unable to see, let alone +// change, how long their library survives -- while the API happily reports a +// number they never chose. + +import { test, expect } from "@playwright/test"; +import { seedLibrary } from "./helpers.mjs"; + +/** Open Settings on the General tab, which is where this setting lives. */ +async function openSettings(page) { + await seedLibrary(page); + await page.goto("/", { waitUntil: "domcontentloaded" }); + await page.locator("#settingsBtn").click(); + await expect(page.locator(".auto-delete-input")).toBeVisible(); +} + +/** Whatever the server currently holds, so each test starts from a known state. */ +async function setRetention(page, patch) { + await page.request.post("/api/settings", { data: patch }); +} + +// The checkbox itself sits under the styled switch track, so clicking it +// directly is intercepted. Clicking the label is both what Playwright can do +// and what a user actually does. +const switchFor = (page) => page.locator("label.settings-switch:has(.auto-delete-input)"); + +test.describe("automatic deletion", () => { + test.beforeEach(async ({ page }) => { + await setRetention(page, { auto_delete_jobs: false, auto_delete_days: 30 }); + }); + + // The backend is shared across the suite and this setting deletes jobs. + // Leaving it switched on would hand every later spec a server that is armed + // to remove the fixture track they all depend on. + test.afterEach(async ({ page }) => { + await setRetention(page, { auto_delete_jobs: false, auto_delete_days: 30 }); + }); + + test("is off, and hides the days field until it is on", async ({ page }) => { + await openSettings(page); + + // The default is the whole point of the setting: an install nobody has + // configured must not delete anything. + await expect(page.locator(".auto-delete-input")).not.toBeChecked(); + await expect(page.locator(".auto-delete-days-row")).toHaveClass(/hidden/); + + await switchFor(page).click(); + await expect(page.locator(".auto-delete-days-row")).not.toHaveClass(/hidden/); + // Revealed with a value rather than empty, so there is nothing to mistake + // for "no limit". + await expect(page.locator(".set-auto-delete-days")).toHaveValue("30"); + }); + + test("the toggle reaches the server and survives a reopen", async ({ page }) => { + await openSettings(page); + await switchFor(page).click(); + + await expect + .poll(async () => (await (await page.request.get("/api/settings")).json()).auto_delete_jobs) + .toBe(true); + + await page.reload({ waitUntil: "domcontentloaded" }); + await page.locator("#settingsBtn").click(); + await expect(page.locator(".auto-delete-input")).toBeChecked(); + await expect(page.locator(".auto-delete-days-row")).not.toHaveClass(/hidden/); + }); + + test("turning it back off hides the field and stops deletion", async ({ page }) => { + await setRetention(page, { auto_delete_jobs: true, auto_delete_days: 14 }); + await openSettings(page); + await expect(page.locator(".auto-delete-input")).toBeChecked(); + await expect(page.locator(".set-auto-delete-days")).toHaveValue("14"); + + await switchFor(page).click(); + await expect(page.locator(".auto-delete-days-row")).toHaveClass(/hidden/); + await expect + .poll(async () => (await (await page.request.get("/api/settings")).json()).auto_delete_jobs) + .toBe(false); + }); + + test("the server owns the ceiling, and says so in the field", async ({ page }) => { + await openSettings(page); + await switchFor(page).click(); + + const days = page.locator(".set-auto-delete-days"); + await days.fill("9999"); + await days.blur(); + + // Clamped by the server and written back, the same arrangement as max + // track length. The field must not keep showing a number that is not what + // the library will actually be kept for. + await expect(days).toHaveValue("365"); + }); + + test("the days field refuses non-digits as they are typed", async ({ page }) => { + await openSettings(page); + await switchFor(page).click(); + + const days = page.locator(".set-auto-delete-days"); + await days.fill(""); + await days.pressSequentially("1a2"); + await expect(days).toHaveValue("12"); + }); +}); diff --git a/tests/test_config.py b/tests/test_config.py index e86ed948..c76013d3 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -82,3 +82,48 @@ def test_configure_portable_environment_leaves_dev_cache_env_alone(monkeypatch): monkeypatch.delenv("XDG_CACHE_HOME", raising=False) monkeypatch.delenv("TORCH_HOME", raising=False) importlib.reload(original) + + +# --- packaged data directory discovery (#459) -------------------------------- +# +# The desktop shell always passes STEMDECK_DATA_DIR. These cover what happens +# when the backend is started some other way: by hand, from a terminal, out of +# the package the shell would normally launch. That used to resolve DATA_DIR to +# backend/ itself, so the backend read a settings.json that did not exist and +# quietly ignored every choice the user had made in the app. + + +def test_packaged_layout_finds_the_data_dir_beside_the_backend(tmp_path, monkeypatch): + from app.core import config + + package = tmp_path / "StemDeck-Windows-x64" + backend = package / "backend" + data = package / "data" + backend.mkdir(parents=True) + data.mkdir() + + monkeypatch.setattr(config, "ROOT", backend) + assert config._packaged_data_dir() == data + + +def test_a_source_checkout_is_not_a_package(tmp_path, monkeypatch): + """ROOT is the repo root in a checkout and /app under Docker. Neither is + named backend, so neither changes behaviour.""" + from app.core import config + + for name in ("stemdeck", "app"): + root = tmp_path / name + (root / "data").mkdir(parents=True) + monkeypatch.setattr(config, "ROOT", root) + assert config._packaged_data_dir() is None + + +def test_a_backend_without_a_data_sibling_is_not_a_package(tmp_path, monkeypatch): + """Half a layout is not a layout. Returning a path that is not there would + invent a data directory next to whatever the backend happened to sit in.""" + from app.core import config + + backend = tmp_path / "somewhere" / "backend" + backend.mkdir(parents=True) + monkeypatch.setattr(config, "ROOT", backend) + assert config._packaged_data_dir() is None diff --git a/tests/test_retention_setting.py b/tests/test_retention_setting.py new file mode 100644 index 00000000..492f67c4 --- /dev/null +++ b/tests/test_retention_setting.py @@ -0,0 +1,82 @@ +"""Automatic deletion of finished jobs, and the setting that controls it (#459). + +Deleting a finished separation destroys work that cannot be recovered. The +behaviour used to be on by default and switched off by an environment variable, +which meant every documented way of starting StemDeck set that variable and +anyone who started the backend directly lost their library within a day. These +tests hold the direction: off unless asked, and the user's stored choice beats +the environment. +""" + +from __future__ import annotations + +import pytest +from fastapi.testclient import TestClient + + +@pytest.fixture +def client(): + from app.main import app + + with TestClient(app) as c: + yield c + + +def test_settings_report_deletion_off_with_bounds(client): + body = client.get("/api/settings").json() + assert body["auto_delete_jobs"] is False + # Published even when off, so the field the toggle reveals opens with a + # value rather than empty. + assert body["auto_delete_days"] == 30 + assert body["auto_delete_days_min"] == 1 + assert body["auto_delete_days_max"] == 365 + + +def test_toggle_and_days_round_trip(client): + r = client.post("/api/settings", json={"auto_delete_jobs": True, "auto_delete_days": 7}) + assert r.status_code == 200 + body = client.get("/api/settings").json() + assert body["auto_delete_jobs"] is True + assert body["auto_delete_days"] == 7 + + +def test_days_are_clamped_not_rejected(client): + from app.core.settings import AUTO_DELETE_DAYS_MAX, AUTO_DELETE_DAYS_MIN + + client.post("/api/settings", json={"auto_delete_days": 0}) + assert client.get("/api/settings").json()["auto_delete_days"] == AUTO_DELETE_DAYS_MIN + client.post("/api/settings", json={"auto_delete_days": 99_999}) + assert client.get("/api/settings").json()["auto_delete_days"] == AUTO_DELETE_DAYS_MAX + + +def test_days_reject_nonsense(client): + r = client.post("/api/settings", json={"auto_delete_days": "soon"}) + assert r.status_code == 422 + + +def test_turning_it_off_again_sticks_over_the_environment(client, monkeypatch): + """The stored choice has to survive an env var that says otherwise, or the + setting is decorative on exactly the deployments that need it most.""" + monkeypatch.setenv("STEMDECK_PERSIST_LIBRARY", "0") + client.post("/api/settings", json={"auto_delete_jobs": False}) + assert client.get("/api/settings").json()["auto_delete_jobs"] is False + + +def test_job_ttl_seconds_seeds_the_default_days(monkeypatch): + """A deployment that already tuned STEMDECK_JOB_TTL_SECONDS keeps its + intent when it opts in, rather than silently jumping to 30 days.""" + from app.core import settings as _settings + + monkeypatch.setenv("STEMDECK_JOB_TTL_SECONDS", str(3 * 86400)) + _settings._state = None + assert _settings.get_auto_delete_days() == 3 + + # Sub-day TTLs round to one day, not to none. Rounding a deliberately + # aggressive sweep down to zero would turn it into a much slower one. + monkeypatch.setenv("STEMDECK_JOB_TTL_SECONDS", "3600") + _settings._state = None + assert _settings.get_auto_delete_days() == 1 + + monkeypatch.setenv("STEMDECK_JOB_TTL_SECONDS", "not-a-number") + _settings._state = None + assert _settings.get_auto_delete_days() == _settings.DEFAULT_AUTO_DELETE_DAYS diff --git a/tests/test_sweep.py b/tests/test_sweep.py index 5cfbcb7d..dcac2db2 100644 --- a/tests/test_sweep.py +++ b/tests/test_sweep.py @@ -55,46 +55,76 @@ def test_sweeps_terminal_old_job(tmp_path: Path): assert job.id not in _jobs -def test_sweep_disabled_under_desktop(monkeypatch): - """The desktop shell (STEMDECK_DESKTOP=1) opts out of the TTL sweep so a - user's curated library isn't purged; the server/Docker default keeps it.""" - from app.main import _sweep_disabled - - monkeypatch.delenv("STEMDECK_PERSIST_LIBRARY", raising=False) +def test_deletion_is_off_unless_asked_for(monkeypatch): + """The default has to be "keep". Deleting a finished separation destroys + work that cannot be recovered, so an install nobody configured must not do + it. It used to be the other way round, which is how a directly-run backend + silently emptied a user's library within a day (#459).""" + from app.core.settings import get_auto_delete_jobs + + for env in ("STEMDECK_DESKTOP", "STEMDECK_PERSIST_LIBRARY"): + monkeypatch.delenv(env, raising=False) + assert get_auto_delete_jobs() is False + + # The two variables that used to be the only thing standing between a user + # and an empty library are now redundant rather than load-bearing. monkeypatch.setenv("STEMDECK_DESKTOP", "1") - assert _sweep_disabled() is True - monkeypatch.delenv("STEMDECK_DESKTOP", raising=False) - assert _sweep_disabled() is False + assert get_auto_delete_jobs() is False + monkeypatch.setenv("STEMDECK_PERSIST_LIBRARY", "1") + assert get_auto_delete_jobs() is False + + +def test_persist_library_zero_still_opts_into_deletion(monkeypatch): + """The one env-based way in, kept for deployments that set it deliberately + (the Unraid template exposes it, run.sh defaults it to 1).""" + from app.core.settings import get_auto_delete_jobs + monkeypatch.setenv("STEMDECK_PERSIST_LIBRARY", "0") + assert get_auto_delete_jobs() is True -def test_sweep_disabled_under_persistent_library(monkeypatch): - """A self-hosted server (run.sh) opts into a persistent library - (STEMDECK_PERSIST_LIBRARY=1) so its processed tracks aren't purged.""" - from app.main import _sweep_disabled - monkeypatch.delenv("STEMDECK_DESKTOP", raising=False) +def test_stored_setting_beats_the_environment(monkeypatch): + """Retention is the user's call, not the deployment's. Unlike jobs_dir, + where an env pin wins because a mounted volume is not the user's to move, + how long their own work is kept is theirs to decide.""" + from app.core.settings import get_auto_delete_jobs, set_auto_delete_jobs + + monkeypatch.setenv("STEMDECK_PERSIST_LIBRARY", "0") + set_auto_delete_jobs(False) + assert get_auto_delete_jobs() is False + monkeypatch.setenv("STEMDECK_PERSIST_LIBRARY", "1") - assert _sweep_disabled() is True - monkeypatch.delenv("STEMDECK_PERSIST_LIBRARY", raising=False) - assert _sweep_disabled() is False + set_auto_delete_jobs(True) + assert get_auto_delete_jobs() is True -@pytest.mark.asyncio -async def test_sweep_loop_desktop_skips_ttl_but_sweeps_failed(monkeypatch): - """Desktop mode skips the library TTL sweep but the failed-job quarantine - still expires (#277) -- failure evidence isn't library content.""" +def _sweep_loop_calls(monkeypatch): + """Run one pass of the sweep loop, returning what each sweep was called + with. sweep_old_jobs now takes a TTL, so record both arguments.""" from app import main as main_mod - monkeypatch.setenv("STEMDECK_DESKTOP", "1") ttl_calls: list = [] failed_calls: list = [] - monkeypatch.setattr(main_mod, "sweep_old_jobs", ttl_calls.append) + monkeypatch.setattr(main_mod, "sweep_old_jobs", lambda d, ttl: ttl_calls.append((d, ttl))) monkeypatch.setattr(main_mod, "sweep_failed_jobs", failed_calls.append) async def stop_loop(_delay): raise RuntimeError("stop-loop") monkeypatch.setattr(main_mod.asyncio, "sleep", stop_loop) + return main_mod, ttl_calls, failed_calls + + +@pytest.mark.asyncio +async def test_sweep_loop_keeps_jobs_but_still_expires_failures(monkeypatch): + """With deletion off the library is untouched, but the failed-job + quarantine still expires (#277) -- failure evidence isn't library + content, and that half was never opt-in.""" + from app.core.settings import set_auto_delete_jobs + + set_auto_delete_jobs(False) + main_mod, ttl_calls, failed_calls = _sweep_loop_calls(monkeypatch) + with pytest.raises(RuntimeError, match="stop-loop"): await main_mod._sweep_loop() @@ -103,27 +133,46 @@ async def stop_loop(_delay): @pytest.mark.asyncio -async def test_sweep_loop_server_runs_both_sweeps(monkeypatch): - from app import main as main_mod +async def test_sweep_loop_uses_the_configured_number_of_days(monkeypatch): + from app.core.settings import set_auto_delete_days, set_auto_delete_jobs - monkeypatch.delenv("STEMDECK_DESKTOP", raising=False) - monkeypatch.delenv("STEMDECK_PERSIST_LIBRARY", raising=False) - ttl_calls: list = [] - failed_calls: list = [] - monkeypatch.setattr(main_mod, "sweep_old_jobs", ttl_calls.append) - monkeypatch.setattr(main_mod, "sweep_failed_jobs", failed_calls.append) - - async def stop_loop(_delay): - raise RuntimeError("stop-loop") + set_auto_delete_jobs(True) + set_auto_delete_days(7) + main_mod, ttl_calls, failed_calls = _sweep_loop_calls(monkeypatch) - monkeypatch.setattr(main_mod.asyncio, "sleep", stop_loop) with pytest.raises(RuntimeError, match="stop-loop"): await main_mod._sweep_loop() - assert ttl_calls == [main_mod.JOBS_DIR] + assert ttl_calls == [(main_mod.JOBS_DIR, 7 * 86400)] assert failed_calls == [main_mod.JOBS_DIR] +@pytest.mark.asyncio +async def test_sweep_loop_rereads_the_setting_every_pass(monkeypatch): + """Turning deletion on must not need a restart. The loop used to decide + once, before its first iteration, which would have made this setting the + only one in the panel that did nothing until relaunch.""" + from app.core.settings import set_auto_delete_jobs + + set_auto_delete_jobs(False) + main_mod, ttl_calls, _ = _sweep_loop_calls(monkeypatch) + + passes = {"n": 0} + + async def sleep_then_enable(_delay): + passes["n"] += 1 + if passes["n"] >= 2: + raise RuntimeError("stop-loop") + set_auto_delete_jobs(True) + + monkeypatch.setattr(main_mod.asyncio, "sleep", sleep_then_enable) + with pytest.raises(RuntimeError, match="stop-loop"): + await main_mod._sweep_loop() + + # First pass swept nothing, second one did. + assert len(ttl_calls) == 1 + + def test_keeps_recent_terminal_job(tmp_path: Path): d = _mkdir(tmp_path, "abcdefabcded") job = Job(id="abcdefabcded") From 886e84f78980c286762e1e8eaa5fdc1fe3dfb1b2 Mon Sep 17 00:00:00 2001 From: Thales <> Date: Wed, 26 Aug 2026 15:05:13 +0100 Subject: [PATCH 2/3] Put the European Portuguese overrides in the ptPT table The four auto-delete strings landed in `en` instead. The script that inserted them anchored on settings.stemsLocation.moving and took the first match, and that key exists in every table, so the first one is English. A later duplicate key wins in a JS object literal, so English rendered "Eliminar automaticamente as faixas concluidas" and ptPT was left with nothing to override. Caught by looking at the running app in English, which is the only place it was visible. The i18n coverage script in .claude/rules/i18n.md builds a set of keys per table, so the duplicate collapsed into one entry and it reported en complete at 443 keys while English was serving Portuguese. It checks that a key exists, never that it appears once or that its value belongs to that language. ptPT goes from 84 own keys to 88. --- static/js/i18n.js | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/static/js/i18n.js b/static/js/i18n.js index 6450efc1..d73aea73 100644 --- a/static/js/i18n.js +++ b/static/js/i18n.js @@ -479,10 +479,6 @@ const en = { "settings.stemsLocation.restartNote": "Restart StemDeck to finish switching over.", "settings.stemsLocation.pickerFailed": "Could not open the folder picker.", "settings.stemsLocation.moving": "Moving stems… this can take a while for a large library.", - "settings.autoDelete.title": "Eliminar automaticamente as faixas concluídas", - "settings.autoDelete.desc": "Desativado por predefinição. As faixas separadas são mantidas para sempre. A eliminação não pode ser anulada.", - "settings.autoDelete.daysTitle": "Eliminar após", - "settings.autoDelete.daysDesc": "Dias que uma faixa concluída é mantida antes de ser eliminada (máx. {max}).", "settings.stemsLocation.moveFailed": "Could not move the stems folder.", "settings.stemsLocation.serverUnreachable": "Could not reach the server.", "settings.stemsLocation.movedPersistFailed.one": "Moved {count} item, but StemDeck could not save this as your new location (check that the folder is writable). Restarting now would revert to the old location. Try setting it again.", @@ -4255,6 +4251,10 @@ const fr = { // through pt (see FALLBACK), so the two variants cannot drift and a key // added to pt later is picked up here rather than reverting to English. const ptPT = { + "settings.autoDelete.title": "Eliminar automaticamente as faixas concluídas", + "settings.autoDelete.desc": "Desativado por predefinição. As faixas separadas são mantidas para sempre. A eliminação não pode ser anulada.", + "settings.autoDelete.daysTitle": "Eliminar após", + "settings.autoDelete.daysDesc": "Dias que uma faixa concluída é mantida antes de ser eliminada (máx. {max}).", "metro.note.full": "Clique a {bpm} BPM, {conf}% das batidas coincidem com uma batida de bateria, acentuando a cada {accent} batidas. Use /2 ou x2 se o clique parecer metade ou o dobro da velocidade.", "metro.note.noAccent": "Clique a {bpm} BPM, {conf}% das batidas coincidem com uma batida de bateria. Use /2 ou x2 se o clique parecer metade ou o dobro da velocidade.", "metro.note.fallback": "Clique a {bpm} BPM do rastreador alternativo. Use /2 ou x2 se o clique parecer metade ou o dobro da velocidade.", From 278f1fe79ade1c6050d355e96c863b934f096a58 Mon Sep 17 00:00:00 2001 From: Thales <> Date: Wed, 26 Aug 2026 15:21:52 +0100 Subject: [PATCH 3/3] Dim "Delete after" instead of removing it, and stop clobbering it Two things, both found by looking at the running app. The days row was meant to be hidden while automatic deletion is off, and it was not. `hidden` does nothing to a settings row: base.css is not loaded by index.html at all, and daw.css scopes its rule to `.daw` while the settings overlay is appended to document.body. Nothing matched, so `.settings-row { display: flex }` won and the row showed regardless. That is also why .settings-pane.hidden had to be written explicitly. Dimmed and inert is the better answer anyway, and it does not depend on that rule existing. Someone deciding whether to switch deletion on can read how long tracks would be kept without having to switch the destructive setting on to find out. The second one the browser tests caught. Flipping the switch POSTs, and apply() writes the server's days value back from that response. If the user had already started typing into the field the switch had just enabled, their number was silently replaced about a round trip later. apply() now leaves a focused field alone, and the days handler writes its own result back explicitly so committing with Enter still shows the value the server actually kept. 46 browser tests pass, 41 existing and 5 for this. --- static/css/daw.css | 9 ++++++ static/js/catalog.js | 42 ++++++++++++++++++++++------ tests/e2e/retention-setting.spec.mjs | 32 +++++++++++++-------- 3 files changed, 63 insertions(+), 20 deletions(-) diff --git a/static/css/daw.css b/static/css/daw.css index 49b8c9c2..252b8f11 100644 --- a/static/css/daw.css +++ b/static/css/daw.css @@ -976,6 +976,15 @@ input, textarea { font-family: inherit; } .settings-switch input:checked + .settings-switch-track .settings-switch-thumb { transform: translateX(18px); background: var(--accent); } .settings-switch.disabled { cursor: default; opacity: 0.7; } .settings-switch.disabled input { cursor: default; } +/* A row whose setting only applies while another one is on. Dimmed and inert + rather than removed, so the value stays readable: someone deciding whether to + switch automatic deletion on can see how long tracks would be kept before + committing to it, instead of having to turn the destructive setting on to + find out. Note that `hidden` would not work here anyway -- base.css is not + loaded by index.html and daw.css scopes its rule to `.daw`, while the + settings overlay is appended to document.body. */ +.settings-row.disabled { opacity: 0.45; } +.settings-row.disabled input { cursor: not-allowed; } .settings-net { margin-top: 10px; font-size: 11px; color: var(--muted); } .settings-net.hidden { display: none; } .settings-net-empty { color: var(--muted); } diff --git a/static/js/catalog.js b/static/js/catalog.js index 12342d4c..ed7c2a2f 100644 --- a/static/js/catalog.js +++ b/static/js/catalog.js @@ -3038,6 +3038,15 @@ async function wireGeneralSettings(overlay) { // rejects a forced device (e.g. CUDA not available on this machine). let lastDevice = "auto"; + // "Delete after" only means anything while automatic deletion is on. Dim it + // and take it out of the tab order rather than removing it, so the number is + // still readable: deciding whether to switch deletion on is easier when you + // can already see how long tracks would be kept. + const setDaysEnabled = (on) => { + autoDeleteDaysRow?.classList.toggle("disabled", !on); + if (autoDeleteDays) autoDeleteDays.disabled = !on; + }; + const apply = (d) => { if (durInput && d.max_duration_sec) durInput.value = String(Math.round(d.max_duration_sec / 60)); // Same reason: the copy in the description text went stale alongside the @@ -3060,9 +3069,16 @@ async function wireGeneralSettings(overlay) { // switch showing whatever it showed last. if (autoDeleteInput && "auto_delete_jobs" in d) { autoDeleteInput.checked = d.auto_delete_jobs === true; - autoDeleteDaysRow?.classList.toggle("hidden", !autoDeleteInput.checked); + setDaysEnabled(autoDeleteInput.checked); + } + // Never overwrite a field the user is currently in. Flipping the switch + // POSTs, and that response used to land on top of whatever they had just + // started typing into the field the switch had only just enabled. The + // days handler below writes its own result back explicitly, so the + // server still owns the ceiling. + if (autoDeleteDays && d.auto_delete_days && document.activeElement !== autoDeleteDays) { + autoDeleteDays.value = String(d.auto_delete_days); } - if (autoDeleteDays && d.auto_delete_days) autoDeleteDays.value = String(d.auto_delete_days); if (autoDeleteDaysDesc && d.auto_delete_days_max) { autoDeleteDaysDesc.textContent = i18nT("settings.autoDelete.daysDesc", { max: d.auto_delete_days_max, @@ -3118,8 +3134,13 @@ async function wireGeneralSettings(overlay) { headers: { "Content-Type": "application/json" }, body: JSON.stringify(patch), }); - if (r.ok) apply(await r.json()); // reflect the server's clamped value + if (r.ok) { + const data = await r.json(); + apply(data); // reflect the server's clamped value + return data; + } } catch { /* ignore */ } + return null; }; durInput?.addEventListener("change", () => { @@ -3169,16 +3190,21 @@ async function wireGeneralSettings(overlay) { post({ separation_quality: qualitySel.value }); }); autoDeleteInput?.addEventListener("change", () => { - // Reveal the days field immediately rather than waiting for the round + // Enable the days field immediately rather than waiting for the round // trip, so the switch does not appear to do nothing on a slow response. // apply() sets it again from the server's answer either way. - autoDeleteDaysRow?.classList.toggle("hidden", !autoDeleteInput.checked); + setDaysEnabled(autoDeleteInput.checked); post({ auto_delete_jobs: autoDeleteInput.checked }); }); - autoDeleteDays?.addEventListener("change", () => { + autoDeleteDays?.addEventListener("change", async () => { // Floor of 1 only. The server owns the ceiling and returns what it kept, // the same arrangement as max track length, so the two cannot drift. - post({ auto_delete_days: Math.max(1, parseInt(autoDeleteDays.value, 10) || 30) }); + const days = Math.max(1, parseInt(autoDeleteDays.value, 10) || 30); + const data = await post({ auto_delete_days: days }); + // Written back here rather than left to apply(), which skips a focused + // field: committing with Enter keeps focus, and the user still has to see + // the number the server actually kept. + if (data?.auto_delete_days) autoDeleteDays.value = String(data.auto_delete_days); }); // Compute device needs its own POST path: unlike the clamped numeric // settings, the server can REJECT a forced device (422 with a reason, e.g. @@ -3567,7 +3593,7 @@ function openLibraryEditor() {
-