Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 30 additions & 4 deletions app/core/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 -- `<package>/backend/app` here,
`<package>/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:
Expand Down
80 changes: 80 additions & 0 deletions app/core/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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:
Expand Down
48 changes: 24 additions & 24 deletions app/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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),
Expand Down
12 changes: 7 additions & 5 deletions app/pipeline/collect.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
9 changes: 9 additions & 0 deletions static/css/daw.css
Original file line number Diff line number Diff line change
Expand Up @@ -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); }
Expand Down
77 changes: 75 additions & 2 deletions static/js/catalog.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.
Expand Down Expand Up @@ -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" });
Expand All @@ -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", () => {
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -3527,6 +3583,23 @@ function openLibraryEditor() {
</div>
<div class="stems-location-msg" role="status" aria-live="polite"></div>
</div>
<div class="settings-row">
<div class="settings-row-text">
<div class="settings-row-title" data-i18n="settings.autoDelete.title">Automatically delete finished tracks</div>
<div class="settings-row-desc" data-i18n="settings.autoDelete.desc">Off unless you turn it on. Separated tracks are kept forever by default. Deleting them cannot be undone.</div>
</div>
<label class="settings-switch">
<input type="checkbox" class="auto-delete-input" />
<span class="settings-switch-track"><span class="settings-switch-thumb"></span></span>
</label>
</div>
<div class="settings-row auto-delete-days-row disabled">
<div class="settings-row-text">
<div class="settings-row-title" data-i18n="settings.autoDelete.daysTitle">Delete after</div>
<div class="settings-row-desc auto-delete-days-desc">Days a finished track is kept before it is deleted.</div>
</div>
<input type="text" class="settings-num-input set-auto-delete-days" inputmode="numeric" maxlength="3" aria-label="Days a track is kept" data-i18n-aria-label="settings.autoDelete.daysTitle" />
</div>
</div>
<div class="settings-section">
<div class="settings-row">
Expand Down
Loading
Loading