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/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 2d23cb38..ed7c2a2f 100644 --- a/static/js/catalog.js +++ b/static/js/catalog.js @@ -3028,12 +3028,25 @@ 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). 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 @@ -3051,6 +3064,26 @@ 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; + 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 (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 +3120,7 @@ async function wireGeneralSettings(overlay) { digitsOnly(durInput); digitsOnly(playlistInput); digitsOnly(portInput); + digitsOnly(autoDeleteDays); try { const r = await fetch("/api/settings", { cache: "no-store" }); @@ -3100,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", () => { @@ -3150,6 +3189,23 @@ async function wireGeneralSettings(overlay) { qualitySel?.addEventListener("change", () => { post({ separation_quality: qualitySel.value }); }); + autoDeleteInput?.addEventListener("change", () => { + // 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. + setDaysEnabled(autoDeleteInput.checked); + post({ auto_delete_jobs: autoDeleteInput.checked }); + }); + 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. + 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. // "cuda is not available on this machine") -- surface that and revert. @@ -3527,6 +3583,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.
+
+ +
+
+
+
Delete after
+
Days a finished track is kept before it is deleted.
+
+ +
diff --git a/static/js/i18n.js b/static/js/i18n.js index 99fae118..d73aea73 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.", @@ -985,6 +989,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 +1506,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 +1999,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 +2493,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 +2997,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 +3502,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 +3997,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.", @@ -4219,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.", diff --git a/tests/e2e/retention-setting.spec.mjs b/tests/e2e/retention-setting.spec.mjs new file mode 100644 index 00000000..4a16c117 --- /dev/null +++ b/tests/e2e/retention-setting.spec.mjs @@ -0,0 +1,116 @@ +// 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 is only live while +// the switch is on. If that coupling breaks, someone edits a number that does +// nothing, or cannot edit one that does -- while the API happily reports a +// value they never chose. +// +// The field is dimmed rather than removed. `hidden` would not have worked here +// in any case: 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. + +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 leaves the days field inert 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(/disabled/); + await expect(page.locator(".set-auto-delete-days")).toBeDisabled(); + + await switchFor(page).click(); + await expect(page.locator(".auto-delete-days-row")).not.toHaveClass(/disabled/); + await expect(page.locator(".set-auto-delete-days")).toBeEnabled(); + // Readable the whole time, so deciding whether to switch deletion on does + // not require switching it on to find out what it would do. + 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(/disabled/); + await expect(page.locator(".set-auto-delete-days")).toBeEnabled(); + }); + + test("turning it back off makes the field inert 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(/disabled/); + await expect(page.locator(".set-auto-delete-days")).toBeDisabled(); + 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")