No tracks yet. Head to Extract to split your first song.
`;
}
return `
+ ${state.tracks.map((t) => {
+ const unavailable = t.status === "unavailable";
+ const canReimport = unavailable && t.sourceUrl && !t.sourceUrl.startsWith("local:");
+ const infoHtml = unavailable
+ ? `
${esc(t.title)}
${
+ canReimport ? "Track unavailable · tap to reimport" : "Track unavailable · re-upload to restore"
+ }
`
+ : `
${esc(t.title)}
${esc(t.sub)}
${esc(t.meta)}
`;
+ return `
Delete
-
+
${artLabel(t)}
-
${esc(t.title)}
${esc(t.sub)}
${esc(t.meta)}
+
${infoHtml}
-
Load
+
${unavailable ? "Fix" : "Load"}
-
`).join("")}`;
+
`;
+ }).join("")}`;
}
function libraryScreen() {
@@ -898,6 +907,11 @@ app.addEventListener("click", (e) => {
if (track) openTrack(track);
return;
}
+ case "reimport": {
+ const track = state.tracks.find((x) => x.id === t.dataset.id);
+ if (track) reimportTrack(track);
+ return;
+ }
case "reload":
loadLibrary();
return;
@@ -919,6 +933,31 @@ app.addEventListener("change", (e) => {
}
});
+// A track's files are gone (folder deleted or moved outside the app).
+// URL-sourced tracks can be rebuilt by re-running the import; a local upload
+// has no source bytes left (the pipeline deletes the upload once it's done
+// with it), so that just gets a toast pointing at re-upload instead.
+async function reimportTrack(card) {
+ if (!card.sourceUrl || card.sourceUrl.startsWith("local:")) {
+ toast("This track's audio is gone. Re-upload it to restore it.");
+ return;
+ }
+ toast("Reimporting…");
+ try {
+ const res = await fetch("/api/jobs", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ url: card.sourceUrl, stems: card.selectedStems || [] }),
+ });
+ const data = await res.json().catch(() => ({}));
+ if (!res.ok) throw new Error(data.detail || res.statusText);
+ await loadLibrary();
+ } catch (e) {
+ console.warn("[mobile] reimport failed:", e);
+ toast(`Couldn't reimport: ${e.message}`);
+ }
+}
+
// Load the real library from /api/jobs (newest first). On success, seed the
// Mixer with the most recent track if nothing is selected yet.
async function loadLibrary() {
diff --git a/static/mobile/styles.css b/static/mobile/styles.css
index 81d9ef9b..fd79276e 100644
--- a/static/mobile/styles.css
+++ b/static/mobile/styles.css
@@ -655,6 +655,10 @@ button {
color: var(--muted-3);
margin-top: 1px;
}
+.track-info .s.track-warn {
+ color: var(--accent);
+ font-weight: 600;
+}
.track-info .m {
font-family: var(--mono);
font-size: 10.5px;
diff --git a/tests/test_jobs_api.py b/tests/test_jobs_api.py
index 1b126b3b..10e5e338 100644
--- a/tests/test_jobs_api.py
+++ b/tests/test_jobs_api.py
@@ -361,3 +361,134 @@ def test_sse_503_when_connection_cap_reached(client):
assert r.status_code == 503
finally:
events_mod._sse_active = original
+
+
+# ─── Track unavailable when stem files are missing from disk ─────────────────
+#
+# A "done" job's status field in the registry stays "done" even after its
+# stems folder is deleted or moved outside the app (the registry has no way to
+# know) - list_jobs/get_job check disk on every request and report
+# "unavailable" instead, so the client never has to guess from a 404 or from
+# the job disappearing off the list, neither of which catches this case.
+
+
+def test_list_jobs_flags_a_done_job_with_no_stems_dir_as_unavailable(client, tmp_path, monkeypatch):
+ import app.api.jobs as jobs_mod
+
+ monkeypatch.setattr(jobs_mod, "JOBS_DIR", tmp_path)
+ job = Job(id="abcdefabcdef", status="done", title="Gone")
+ _jobs[job.id] = job
+
+ r = client.get("/api/jobs")
+ assert r.status_code == 200
+ [state] = r.json()
+ assert state["status"] == "unavailable"
+
+
+def test_get_job_flags_a_done_job_with_an_empty_stems_dir_as_unavailable(
+ client, tmp_path, monkeypatch
+):
+ import app.api.jobs as jobs_mod
+
+ monkeypatch.setattr(jobs_mod, "JOBS_DIR", tmp_path)
+ job = Job(id="abcdefabcdef", status="done", title="Emptied")
+ _jobs[job.id] = job
+ (tmp_path / job.id / "stems").mkdir(parents=True)
+
+ r = client.get(f"/api/jobs/{job.id}")
+ assert r.status_code == 200
+ assert r.json()["status"] == "unavailable"
+
+
+def test_list_jobs_reports_done_when_stems_are_present(client, tmp_path, monkeypatch):
+ import app.api.jobs as jobs_mod
+
+ monkeypatch.setattr(jobs_mod, "JOBS_DIR", tmp_path)
+ job = Job(id="abcdefabcdef", status="done", title="Still here")
+ _jobs[job.id] = job
+ stems_dir = tmp_path / job.id / "stems"
+ stems_dir.mkdir(parents=True)
+ (stems_dir / "vocals.wav").write_bytes(b"RIFF1234")
+
+ r = client.get("/api/jobs")
+ [state] = r.json()
+ assert state["status"] == "done"
+
+
+def test_missing_stems_are_not_flagged_while_relocating(client, tmp_path, monkeypatch):
+ """A library move (#354) makes the stems folder briefly absent on purpose -
+ that is not the same failure as a user deleting it, and must not flash the
+ unavailable badge mid-move."""
+ import app.api.jobs as jobs_mod
+ import app.core.stems_location as stems_location
+
+ monkeypatch.setattr(jobs_mod, "JOBS_DIR", tmp_path)
+ monkeypatch.setattr(stems_location, "_relocating", True)
+ job = Job(id="abcdefabcdef", status="done", title="Mid-move")
+ _jobs[job.id] = job
+
+ r = client.get("/api/jobs")
+ [state] = r.json()
+ assert state["status"] == "done"
+
+
+# ─── GET /jobs/{id}/failure: stderr tail and traceback sections ──────────────
+
+
+def test_get_failure_separates_tail_from_traceback(client, tmp_path, monkeypatch):
+ """error.txt can carry both a `--- stderr tail ---` and a
+ `--- traceback ---` section; the parser must not lump the second into the
+ first just because it also comes after a "---" marker."""
+ import app.api.jobs as jobs_mod
+
+ monkeypatch.setattr(jobs_mod, "JOBS_DIR", tmp_path)
+ failed_dir = tmp_path / "failed" / "abcdefabcdef"
+ failed_dir.mkdir(parents=True)
+ (failed_dir / "error.txt").write_text(
+ "\n".join(
+ [
+ "time: 2026-08-17T16:50:02+00:00",
+ "stage: Error: Processing failed",
+ "device: cpu",
+ "cause: unknown",
+ "exception: RuntimeError('boom')",
+ "",
+ "--- stderr tail ---",
+ "line one of stderr",
+ "line two of stderr",
+ "",
+ "--- traceback ---",
+ "Traceback (most recent call last):",
+ ' File "
/app/pipeline/runner.py", line 320, in _run_async',
+ "RuntimeError: boom",
+ ]
+ )
+ + "\n",
+ encoding="utf-8",
+ )
+
+ r = client.get("/api/jobs/abcdefabcdef/failure")
+ assert r.status_code == 200
+ body = r.json()
+ assert body["tail"] == ["line one of stderr", "line two of stderr"]
+ assert body["traceback"] == [
+ "Traceback (most recent call last):",
+ ' File "/app/pipeline/runner.py", line 320, in _run_async',
+ "RuntimeError: boom",
+ ]
+
+
+def test_get_failure_traceback_is_empty_list_when_absent(client, tmp_path, monkeypatch):
+ """Older quarantined jobs (or ones that never reached the pipeline) may
+ have no traceback section -- the field must still be present as [], not
+ missing, so the client doesn't have to special-case it."""
+ import app.api.jobs as jobs_mod
+
+ monkeypatch.setattr(jobs_mod, "JOBS_DIR", tmp_path)
+ failed_dir = tmp_path / "failed" / "abcdefabcdef"
+ failed_dir.mkdir(parents=True)
+ (failed_dir / "error.txt").write_text("stage: Error\ncause: unknown\n", encoding="utf-8")
+
+ r = client.get("/api/jobs/abcdefabcdef/failure")
+ assert r.status_code == 200
+ assert r.json()["traceback"] == []
diff --git a/tests/test_registry_persistence.py b/tests/test_registry_persistence.py
index 82aa9e40..10f4e9b7 100644
--- a/tests/test_registry_persistence.py
+++ b/tests/test_registry_persistence.py
@@ -289,6 +289,7 @@ def test_restored_job_serves_stems(tmp_path: Path, monkeypatch):
(tmp_path / "registry.json").write_text(json.dumps(data), encoding="utf-8")
monkeypatch.setattr("app.api.stems.JOBS_DIR", tmp_path)
+ monkeypatch.setattr("app.api.jobs.JOBS_DIR", tmp_path)
restore_registry(tmp_path)
from app.main import app
From ab3832bfa34b7b31ee30f3f2d1bd9c116b1ef236 Mon Sep 17 00:00:00 2001
From: Thales <>
Date: Mon, 17 Aug 2026 17:46:41 +0100
Subject: [PATCH 2/5] Accept /live/, /embed/, and youtube-nocookie.com links
for YouTube import
normalize_youtube_url() rejected these outright with "could not extract a
video ID from URL" or "unsupported host". /live/ is what premieres and
creator livestreams keep once they end and become a normal VOD - common for
concert/DJ-set recordings. youtube-nocookie.com (the privacy-embed domain)
wasn't recognized as a YouTube host at all; added alongside /embed/
support on the regular domain too.
Closes #382
---
app/pipeline/download.py | 25 ++++++++++++++-----
tests/test_url_validation.py | 48 ++++++++++++++++++++++++++++++++++++
2 files changed, 67 insertions(+), 6 deletions(-)
diff --git a/app/pipeline/download.py b/app/pipeline/download.py
index c482afea..30e3d62b 100644
--- a/app/pipeline/download.py
+++ b/app/pipeline/download.py
@@ -94,6 +94,10 @@ def _with_retries(job: Job, fn, *, what: str):
"m.youtube.com",
"music.youtube.com",
"youtu.be",
+ # The "privacy-enhanced mode" embed domain -- same site, same extractor,
+ # shows up in copy-pasted embed/share code rather than the address bar.
+ "youtube-nocookie.com",
+ "www.youtube-nocookie.com",
)
)
# Note: on.soundcloud.com (the share shortener) is intentionally excluded — it
@@ -169,12 +173,18 @@ def normalize_youtube_url(url: str) -> str:
the playlist extractor. Pass non-YouTube URLs through unchanged.
Cases handled:
- * `watch?v=X&list=...` -> `watch?v=X` (drop the playlist context)
+ * `watch?v=X&list=...` -> `watch?v=X` (drop the playlist context,
+ regardless of what other tracking/context params ride along --
+ `si=`, `t=`, `app=desktop`, etc.)
* `?list=RD&start_radio=1` -> `watch?v=` (Radio
playlists embed the seed in the list ID; YouTube refuses to view the
playlist directly with "This playlist type is unviewable.")
* `youtu.be/` -> `watch?v=`
* `youtube.com/shorts/` -> `watch?v=`
+ * `youtube.com/live/` -> `watch?v=` (premieres and
+ creator livestreams keep this URL once they end and become a normal
+ VOD -- common for concert/DJ-set recordings)
+ * `youtube-nocookie.com/...` -> the same forms on `youtube.com`
Everything else (PL/OL/algorithmic playlists with no derivable seed) is
left alone -- yt-dlp will surface its own error.
"""
@@ -187,7 +197,7 @@ def normalize_youtube_url(url: str) -> str:
if host.startswith(prefix):
host = host[len(prefix) :]
break
- if host not in ("youtube.com", "youtu.be"):
+ if host not in ("youtube.com", "youtu.be", "youtube-nocookie.com"):
return url
qs = urllib.parse.parse_qs(parsed.query)
@@ -206,10 +216,13 @@ def normalize_youtube_url(url: str) -> str:
if _VIDEO_ID_RE.match(vid):
return f"https://www.youtube.com/watch?v={vid}"
- if host == "youtube.com" and parsed.path.startswith("/shorts/"):
- vid = parsed.path[len("/shorts/") :].lstrip("/").split("/")[0]
- if _VIDEO_ID_RE.match(vid):
- return f"https://www.youtube.com/watch?v={vid}"
+ if host in ("youtube.com", "youtube-nocookie.com"):
+ for path_prefix in ("/shorts/", "/live/", "/embed/"):
+ if parsed.path.startswith(path_prefix):
+ vid = parsed.path[len(path_prefix) :].lstrip("/").split("/")[0]
+ if _VIDEO_ID_RE.match(vid):
+ return f"https://www.youtube.com/watch?v={vid}"
+ break
return url
diff --git a/tests/test_url_validation.py b/tests/test_url_validation.py
index b1ee9c39..8f4a4c61 100644
--- a/tests/test_url_validation.py
+++ b/tests/test_url_validation.py
@@ -36,6 +36,54 @@
"https://m.youtube.com/shorts/dQw4w9WgXcQ",
"https://www.youtube.com/watch?v=dQw4w9WgXcQ",
),
+ # Radio/"Mix" context: RD embeds the video id, and YouTube
+ # refuses to view the playlist directly ("unviewable"), so this must
+ # fall through to the single video rather than 422 or reach yt-dlp's
+ # playlist extractor.
+ (
+ "https://www.youtube.com/watch?v=dQw4w9WgXcQ&list=RDdQw4w9WgXcQ&start_radio=1",
+ "https://www.youtube.com/watch?v=dQw4w9WgXcQ",
+ ),
+ # A real (non-radio) playlist attached to a specific video: the video
+ # still wins, since `v=` is matched before `list=` is even inspected.
+ (
+ "https://www.youtube.com/watch?v=dQw4w9WgXcQ&list=PLfoo&index=3",
+ "https://www.youtube.com/watch?v=dQw4w9WgXcQ",
+ ),
+ # Mobile share links carry a `si=` tracking token; timestamp links
+ # carry `t=`. Neither should affect extraction.
+ (
+ "https://www.youtube.com/watch?v=dQw4w9WgXcQ&si=aBcDeFgHiJkLmNoP",
+ "https://www.youtube.com/watch?v=dQw4w9WgXcQ",
+ ),
+ (
+ "https://youtu.be/dQw4w9WgXcQ?si=aBcDeFgHiJkLmNoP&t=42",
+ "https://www.youtube.com/watch?v=dQw4w9WgXcQ",
+ ),
+ # Param order is not guaranteed by every client that builds these URLs.
+ (
+ "https://www.youtube.com/watch?app=desktop&v=dQw4w9WgXcQ",
+ "https://www.youtube.com/watch?v=dQw4w9WgXcQ",
+ ),
+ # Premieres/livestreams keep this URL once they end and become a
+ # normal VOD -- common for concert/DJ-set recordings.
+ (
+ "https://www.youtube.com/live/dQw4w9WgXcQ?feature=share",
+ "https://www.youtube.com/watch?v=dQw4w9WgXcQ",
+ ),
+ # Embed src URLs, on both the regular and "privacy-enhanced" domain.
+ (
+ "https://www.youtube.com/embed/dQw4w9WgXcQ",
+ "https://www.youtube.com/watch?v=dQw4w9WgXcQ",
+ ),
+ (
+ "https://www.youtube-nocookie.com/embed/dQw4w9WgXcQ",
+ "https://www.youtube.com/watch?v=dQw4w9WgXcQ",
+ ),
+ (
+ "https://www.youtube-nocookie.com/watch?v=dQw4w9WgXcQ",
+ "https://www.youtube.com/watch?v=dQw4w9WgXcQ",
+ ),
],
)
def test_accepts_youtube_urls(url: str, expected: str) -> None:
From d0f51de6c20ed15b6ec3a18284e1c3327fd6dca3 Mon Sep 17 00:00:00 2001
From: Thales <>
Date: Mon, 17 Aug 2026 17:46:47 +0100
Subject: [PATCH 3/5] Raise max track duration ceiling from 20 to 60 minutes
The Settings API silently clamped any requested max_duration_sec back down
to 1200 seconds regardless of what was sent - _DURATION_MAX was a hardcoded
product ceiling, not just a default. Full albums, DJ sets, and concert
recordings routinely exceed 20 minutes.
Closes #383
---
app/core/settings.py | 4 ++--
tests/test_network_gate.py | 2 +-
2 files changed, 3 insertions(+), 3 deletions(-)
diff --git a/app/core/settings.py b/app/core/settings.py
index 21ae06ef..20efc2e8 100644
--- a/app/core/settings.py
+++ b/app/core/settings.py
@@ -39,8 +39,8 @@
_LOCK = threading.RLock()
_state: dict | None = None # whole settings dict, loaded lazily
-# Clamp bounds. Max track length is capped at 20 min (the product ceiling).
-_DURATION_MIN, _DURATION_MAX = 60, 1200 # 1 min .. 20 min
+# Clamp bounds. Max track length is capped at 60 min (the product ceiling).
+_DURATION_MIN, _DURATION_MAX = 60, 3600 # 1 min .. 60 min
_HEIGHT_MIN, _HEIGHT_MAX = 144, 2160
_PLAYLIST_MIN, _PLAYLIST_MAX = 1, 200
_PORT_MIN, _PORT_MAX = 1024, 65535
diff --git a/tests/test_network_gate.py b/tests/test_network_gate.py
index e0e02ca7..7984d42a 100644
--- a/tests/test_network_gate.py
+++ b/tests/test_network_gate.py
@@ -67,7 +67,7 @@ def test_runtime_settings_round_trip_and_clamp():
# Out-of-range values are clamped, not rejected.
assert settings_mod.set_max_duration_sec(5) == 60 # floor
- assert settings_mod.set_max_duration_sec(99999) == 1200 # ceiling = 20 min
+ assert settings_mod.set_max_duration_sec(99999) == 3600 # ceiling = 60 min
assert settings_mod.set_video_max_height(99999) == 2160 # ceil
From 7d0ef1230253137b2ae5820eba2745b8025d4111 Mon Sep 17 00:00:00 2001
From: Thales <>
Date: Mon, 17 Aug 2026 17:47:07 +0100
Subject: [PATCH 4/5] Rework the notification centre's failure report: fix the
Windows Explorer bug, add Discord, full traceback, opt-in logs, and
anonymization
Root cause of the Explorer bug: the pre-filled GitHub URL carried the full
diagnostic dump (up to 6000 chars) as a query param, and Windows opens it via
explorer.exe, which silently falls back to a plain File Explorer window past
roughly 2000 characters instead of erroring. buildReportUrl() now fills the
"Logs / screenshots" field directly with as much of the traceback/stderr
tail as fits (keeping the end, where the actual error is - no paste needed
for the common case), and only points at the clipboard for what doesn't fit.
buildReportText() always has the complete, untruncated version.
Also added:
- A second "Report on Discord" button next to "Report on GitHub".
- Full backend traceback capture (_quarantine_failed_job), not just a
one-line exception repr - fixed a latent bug in the same change where the
tail parser would have silently swallowed a second section into the first.
- An opt-in "Include recent logs" button pulling from the backend/
application/setup log views already exposed by Settings -> Logs, scoped to
a window around the failure's own timestamp.
- Anonymization (app/core/redact.py): strips the reporter's home directory,
any YouTube/SoundCloud source URL (download.py logs every job's URL, not
just the failing one - a raw log tail would otherwise leak everything
imported in the fetched window), and any IPv4 address (the mobile UI talks
to this backend over the LAN). Applied unconditionally in GET
/api/logs/{view}, not just for the report flow, and to the per-job
traceback/tail/exception before error.txt is ever written. title:/source:
stay unredacted in that file on purpose - they're already excluded from
the public API response, so redacting them there loses local diagnostic
value for no privacy gain.
Closes #381, #384
---
app/api/jobs.py | 30 +++--
app/core/redact.py | 58 ++++++++++
app/main.py | 14 ++-
app/pipeline/runner.py | 29 ++++-
static/css/daw.css | 12 ++
static/index.html | 4 +-
static/js/notifications.js | 203 +++++++++++++++++++++++++++-------
tests/js/report-url.test.mjs | 120 ++++++++++++++++++--
tests/test_logs_api.py | 47 ++++++++
tests/test_pipeline_runner.py | 51 +++++++++
tests/test_redact.py | 75 +++++++++++++
11 files changed, 573 insertions(+), 70 deletions(-)
create mode 100644 app/core/redact.py
create mode 100644 tests/test_redact.py
diff --git a/app/api/jobs.py b/app/api/jobs.py
index e6545b84..9002005d 100644
--- a/app/api/jobs.py
+++ b/app/api/jobs.py
@@ -464,9 +464,10 @@ def get_failure(job_id: str) -> dict:
_quarantine_failed_job writes jobs/failed//error.txt on every pipeline
failure (#277) and until now nothing ever read it back: the UI had only the
- one-line `error_detail`, so a bug report could not carry the stderr tail
- that says *why* demucs died. Read-only, and never serves the whole file --
- only the technical keys above, plus the tail.
+ one-line `error_detail`, so a bug report could not carry the stderr tail or
+ the full traceback that say *why* demucs died. Read-only, and never serves
+ the whole file -- only the technical keys above, plus the tail and
+ traceback (both already home-directory-redacted by the writer).
"""
if not JOB_ID_RE.match(job_id):
raise HTTPException(status_code=404, detail="job not found")
@@ -488,19 +489,32 @@ def get_failure(job_id: str) -> dict:
fields: dict[str, str] = {}
tail: list[str] = []
- in_tail = False
+ tb: list[str] = []
+ section = "fields"
for line in text.splitlines():
- if line.strip() == "--- stderr tail ---":
- in_tail = True
+ stripped = line.strip()
+ if stripped == "--- stderr tail ---":
+ section = "tail"
continue
- if in_tail:
+ if stripped == "--- traceback ---":
+ # The writer separates sections with a blank line for readability
+ # in the raw file; drop it here rather than let it show up as a
+ # trailing empty entry in `tail`.
+ if tail and tail[-1] == "":
+ tail.pop()
+ section = "traceback"
+ continue
+ if section == "tail":
tail.append(line)
continue
+ if section == "traceback":
+ tb.append(line)
+ continue
key, sep, value = line.partition(":")
if sep and key in _FAILURE_PUBLIC_KEYS:
fields[key] = value.strip()
- return {"job_id": job_id, **fields, "tail": tail}
+ return {"job_id": job_id, **fields, "tail": tail, "traceback": tb}
@router.get("/{job_id}/beats")
diff --git a/app/core/redact.py b/app/core/redact.py
new file mode 100644
index 00000000..514baa5f
--- /dev/null
+++ b/app/core/redact.py
@@ -0,0 +1,58 @@
+"""Stripping locally- or personally-identifying text before it can reach a
+public report.
+
+The notification centre's "Report on GitHub"/"Report on Discord" flow and its
+opt-in "include recent logs" button both end with this text pasted somewhere
+public. Three things in particular have no business being there:
+
+ - The reporter's home directory. On Windows the Python install path alone
+ (`C:\\Users\\\\...`) carries the OS username.
+ - A track's source URL. `download.py` logs it for every job
+ ("download starting: "), so a raw log tail carries the YouTube/
+ SoundCloud link for everything the reporter has ever imported in the
+ fetched window -- not just the one that failed. The per-job failure
+ report has always kept this out deliberately (see
+ app/api/jobs.py's _FAILURE_PUBLIC_KEYS); a raw log has no such allowlist
+ of its own, so it is enforced here instead.
+ - An IP address. The mobile UI talks to this backend over the LAN when
+ network access is on, so uvicorn's access log (captured into
+ backend.log on desktop) can carry another device's address on the
+ reporter's home network, not just their own.
+
+Every place that can hand text to that report flow -- the per-job failure
+evidence (runner.py) and the Settings -> Logs viewer (main.py) -- redacts
+through this one function rather than each carrying its own copy.
+"""
+
+from __future__ import annotations
+
+import re
+from pathlib import Path
+
+# Mirrors app/pipeline/download.py's _YOUTUBE_HOSTS | _SOUNDCLOUD_HOSTS. Kept
+# as a literal copy rather than importing it: this module is used from
+# main.py's request path, and pipeline.download pulls in yt_dlp, which is not
+# something a log-tail redaction pass should need to import.
+_SOURCE_URL_RE = re.compile(
+ r"https?://(?:[\w-]+\.)*"
+ r"(?:youtube\.com|youtu\.be|youtube-nocookie\.com|soundcloud\.com)\S*",
+ re.IGNORECASE,
+)
+
+# IPv4 only. The app's own LAN detection (app/main.py's _is_lan_ipv4) is
+# IPv4-only too, and a loose IPv6 pattern is a real hazard here: something
+# like "(?:[0-9a-f]{1,4}:){2,7}[0-9a-f]{1,4}" also matches an HH:MM:SS
+# timestamp (every log line has one), which would mangle every line's time
+# instead of catching the rare home IPv6 address.
+_IPV4_RE = re.compile(r"\b(?:\d{1,3}\.){3}\d{1,3}\b")
+
+
+def redact(text: str) -> str:
+ """Strip the reporter's home directory, any YouTube/SoundCloud source
+ URL, and any IPv4 address from `text`."""
+ home = str(Path.home())
+ if home and home not in (".", "/"):
+ text = text.replace(home, "")
+ text = _SOURCE_URL_RE.sub("", text)
+ text = _IPV4_RE.sub("", text)
+ return text
diff --git a/app/main.py b/app/main.py
index 7e35b494..6cf27001 100644
--- a/app/main.py
+++ b/app/main.py
@@ -33,6 +33,7 @@
)
from app.core.logging_setup import configure_logging
from app.core.process import process_exists as _process_exists
+from app.core.redact import redact
from app.core.registry import all_jobs as registry_all_jobs
from app.core.registry import registry_path, take_pending_resume
from app.core.registry import reset_all as reset_registry
@@ -666,9 +667,16 @@ def get_log_tail(view: str, minutes: int = 60) -> PlainTextResponse:
if len(kept) > _LOG_TAIL_LINES:
truncated = f"[... {len(kept) - _LOG_TAIL_LINES} earlier lines not shown ...]\n"
kept = kept[-_LOG_TAIL_LINES:]
- return PlainTextResponse(
- truncated + "\n".join(kept) + "\n", media_type="text/plain; charset=utf-8"
- )
+ # Redacted unconditionally, not just for the report flow's callers: a log
+ # line is a log line regardless of who asks for it, and the notification
+ # centre's "include recent logs" button hands this straight to a public
+ # GitHub issue or Discord message without a second filtering step. Strips
+ # the reporter's home directory, any YouTube/SoundCloud source URL (every
+ # job's download start is logged at info level -- not just the failing
+ # one), and any IPv4 address (the mobile UI talks to this backend over
+ # the LAN, so uvicorn's access log can carry another device's address).
+ body = redact(truncated + "\n".join(kept) + "\n")
+ return PlainTextResponse(body, media_type="text/plain; charset=utf-8")
@app.get("/api/logs.zip", tags=["settings"])
diff --git a/app/pipeline/runner.py b/app/pipeline/runner.py
index 75be08c3..577a6337 100644
--- a/app/pipeline/runner.py
+++ b/app/pipeline/runner.py
@@ -6,11 +6,13 @@
import shutil
import subprocess
import time
+import traceback
from datetime import datetime, timezone
from pathlib import Path
from app.core.config import DEMUCS_MODEL, TIMEOUT_FFMPEG
from app.core.models import Job, JobCancelled, _set
+from app.core.redact import redact
from app.core.registry import persist as persist_registry
from app.pipeline.analyze import analyze
from app.pipeline.beatgrid import compute_beat_grid
@@ -256,18 +258,30 @@ def _quarantine_failed_job(job: Job, job_dir: Path, jobs_dir: Path, exc: Excepti
"""Preserve failure evidence instead of destroying it (#277).
Writes error.txt (stage, device, model, timings, classified cause, stderr
- tail), strips the heavy audio payloads, and moves the dir to
- jobs/failed/ where sweep_failed_jobs expires it after FAILED_TTL.
+ tail, full traceback), strips the heavy audio payloads, and moves the dir
+ to jobs/failed/ where sweep_failed_jobs expires it after FAILED_TTL.
Best-effort throughout: any step failing falls back to plain removal so a
pathological error can never leak disk."""
tail: list[str] = getattr(exc, "tail", None) or []
cause = classify_failure("\n".join([*tail, repr(exc)]))
detail = cause
if tail:
- detail += f" — {tail[-1][:200]}"
+ # error_detail reaches the client directly (job state, notification
+ # card, and the report URL's "what" field) -- redact before the [:200]
+ # truncation, not after, so a redaction placeholder never gets cut in
+ # half.
+ detail += f" — {redact(tail[-1])[:200]}"
job.error_detail = detail
try:
+ # title/source stay unredacted: they never leave this file (the
+ # /failure API's allowlist excludes both, see app/api/jobs.py), so
+ # this is purely local diagnostic value for the person looking at
+ # their own disk. Everything below IS served to the client and is
+ # redacted accordingly -- exc!r can embed a source URL (yt-dlp errors
+ # often do), and the stderr tail/traceback can carry either a source
+ # URL or the reporter's home directory.
+ redacted_tail = [redact(line) for line in tail]
lines = [
f"time: {datetime.now(timezone.utc).isoformat(timespec='seconds')}",
f"job: {job.id}",
@@ -278,10 +292,13 @@ def _quarantine_failed_job(job: Job, job_dir: Path, jobs_dir: Path, exc: Excepti
f"model: {DEMUCS_MODEL}",
f"cause: {cause}",
f"timings: {json.dumps(job.stage_timings) if job.stage_timings else '(none)'}",
- f"exception: {exc!r}",
+ f"exception: {redact(repr(exc))}",
]
- if tail:
- lines += ["", "--- stderr tail ---", *tail]
+ if redacted_tail:
+ lines += ["", "--- stderr tail ---", *redacted_tail]
+ tb = redact("".join(traceback.format_exception(type(exc), exc, exc.__traceback__))).rstrip()
+ if tb:
+ lines += ["", "--- traceback ---", tb]
(job_dir / "error.txt").write_text("\n".join(lines) + "\n", encoding="utf-8")
# Strip heavy payloads: the quarantine keeps diagnostics, not audio.
diff --git a/static/css/daw.css b/static/css/daw.css
index ba41f072..5d7111e7 100644
--- a/static/css/daw.css
+++ b/static/css/daw.css
@@ -2792,6 +2792,18 @@ input, textarea { font-family: inherit; }
font-family: var(--font-mono); font-size: 11px; line-height: 1.55;
color: var(--fg-2); white-space: pre-wrap; overflow-wrap: anywhere;
}
+/* Opt-in, not automatic: this pulls extra log content into what's about to be
+ copied into a public report, so it gets its own deliberate click rather
+ than firing every time the dialog opens (matches .library-editor-sync's
+ amber "extra action" treatment). */
+.failure-logs-btn {
+ align-self: flex-start;
+ min-height: 28px; margin-top: 8px; padding: 0 12px;
+ border-radius: 7px; border: 1px solid rgba(244, 183, 64, 0.3);
+ background: rgba(244, 183, 64, 0.16); color: var(--accent);
+ font-family: var(--font-mono); font-size: 11px; cursor: pointer;
+}
+.failure-logs-btn:disabled { opacity: 0.55; cursor: default; }
/* Reporting is the reason this dialog exists, so it gets the app's primary
button treatment (as Export Mix does) rather than the muted link style the
About/release dialogs use for their several equal-weight links. */
diff --git a/static/index.html b/static/index.html
index bf64997b..895ed1c3 100644
--- a/static/index.html
+++ b/static/index.html
@@ -886,10 +886,12 @@ New release available
Something failed
- These details go in the report. Your track title and source link are not included — add them yourself if they help.
+ These details go in the report. Your track title and source link are not included - add them yourself if they help. Clicking a button below copies this to your clipboard.
+ Include recent logs
diff --git a/static/js/notifications.js b/static/js/notifications.js
index 0e8eb0a0..830c5a4a 100644
--- a/static/js/notifications.js
+++ b/static/js/notifications.js
@@ -17,10 +17,14 @@ const FAILURES_KEY = "stemdeck:failures";
// Enough to cover a bad session without letting a crash loop fill the store.
const MAX_FAILURES = 20;
const NEW_ISSUE_URL = "https://github.com/stemdeckapp/stemdeck/issues/new";
-// Practical ceiling for a URL handed to a browser or, on Windows, to
-// explorer.exe. GitHub itself tolerates more, but nothing here is worth
-// risking a silently truncated link for -- the tail is trimmed to fit.
-const MAX_URL_LENGTH = 6000;
+// Support channel for a quicker back-and-forth than a public issue -- same
+// invite as the About dialog's Discord icon (index.html).
+const DISCORD_URL = "https://discord.gg/JGk7FdZb9N";
+// Named views the Settings -> Logs viewer offers (app/main.py _LOG_VIEWS).
+// Opt-in only (fetchRecentLogs): unlike the per-job traceback, these cover
+// more than one failure and are worth a deliberate click, not an automatic
+// fetch every time the dialog opens.
+const LOG_VIEWS = ["backend", "application", "setup"];
// What each failure class is called in the report and on the card.
const KIND_LABELS = {
@@ -82,7 +86,45 @@ function technicalBlock(record, diag) {
}
/**
- * Build the pre-filled bug-report URL.
+ * The technical block plus the full (untruncated) stderr tail and backend
+ * traceback, for copying to the clipboard rather than the URL -- and for the
+ * dialog's own on-screen preview, so what's shown matches what gets pasted.
+ * Pure and exported for the same reason as buildReportUrl.
+ */
+export function buildReportText(record, diag = {}) {
+ const tail = Array.isArray(record.tail) ? record.tail : [];
+ const tb = Array.isArray(record.traceback) ? record.traceback : [];
+ const parts = [technicalBlock(record, diag)];
+ if (tail.length) parts.push("", "Stderr tail:", "```", ...tail, "```");
+ if (tb.length) parts.push("", "Traceback:", "```", ...tb, "```");
+ if (record.logs) {
+ for (const view of LOG_VIEWS) {
+ const text = record.logs[view];
+ if (text) parts.push("", `Recent ${view} log:`, "```", text, "```");
+ }
+ }
+ return parts.join("\n");
+}
+
+// Windows opens this URL via explorer.exe, which silently falls back to a
+// plain File Explorer window past roughly 2000 characters rather than
+// erroring, instead of opening GitHub (the bug this whole scheme guards
+// against). Kept a healthy margin under that so the fill reliably lands.
+// Exported so the length-ceiling test can check against the real value
+// rather than a duplicated magic number.
+export const SAFE_URL_CHARS = 1800;
+
+/**
+ * Build the pre-filled bug-report URL, filling the "Logs / screenshots"
+ * field directly with as much of the traceback/stderr tail as safely fits --
+ * no paste needed for the common case.
+ *
+ * What doesn't fit (a long trace, or anything from the opt-in "include
+ * recent logs" button, which never goes in the URL at all -- it can be far
+ * larger than a URL should ever carry) falls back to the clipboard, which
+ * buildReportText always has the complete version of; a note in the field
+ * says so. Truncation keeps the END of the trace, since that is where the
+ * actual failure line is.
*
* Pure and exported so the field mapping can be tested without a browser --
* an OS string that does not match the dropdown option exactly is dropped by
@@ -90,7 +132,7 @@ function technicalBlock(record, diag) {
*/
export function buildReportUrl(record, diag = {}) {
const label = KIND_LABELS[record.kind] || "Something failed";
- const title = `[Bug]: ${label}${record.cause ? ` — ${record.cause}` : ""}`;
+ const title = `[Bug]: ${label}${record.cause ? ` - ${record.cause}` : ""}`;
const what = [
`${label} in StemDeck.`,
@@ -106,35 +148,48 @@ export function buildReportUrl(record, diag = {}) {
record.context?.stage ? `2. StemDeck failed at: ${record.context.stage}` : "2. It failed.",
].join("\n");
- const tail = Array.isArray(record.tail) ? record.tail : [];
- const build = (tailLines, note) => {
- const parts = [technicalBlock(record, diag)];
- if (tailLines.length) parts.push("", "```", ...tailLines, "```");
- if (note) parts.push("", note);
- const params = new URLSearchParams({
- template: "bug_report.yml",
- title,
- what,
- steps,
- os: osOption(diag.buildTarget),
- version: diag.version ? `v${diag.version}` : "",
- install: installOption(diag.buildTarget, Boolean(diag.isDesktop)),
- extra: parts.join("\n"),
- });
- return `${NEW_ISSUE_URL}?${params}`;
+ const base = {
+ template: "bug_report.yml",
+ title,
+ what,
+ steps,
+ os: osOption(diag.buildTarget),
+ version: diag.version ? `v${diag.version}` : "",
+ install: installOption(diag.buildTarget, Boolean(diag.isDesktop)),
+ };
+ const toUrl = (extra) => `${NEW_ISSUE_URL}?${new URLSearchParams({ ...base, extra })}`;
+
+ const tech = technicalBlock(record, diag);
+ const hasTraceback = Array.isArray(record.traceback) && record.traceback.length > 0;
+ const source = hasTraceback ? record.traceback : Array.isArray(record.tail) ? record.tail : [];
+ const sourceLabel = hasTraceback ? "Traceback" : "Stderr tail";
+ const hasLogs = Boolean(record.logs) && Object.values(record.logs).some(Boolean);
+ const pasteNote =
+ "_Full report (including any traceback beyond what fits here, and any recent logs you " +
+ "included) is on your clipboard from this click - paste to see everything._";
+
+ const build = (keep) => {
+ const parts = [tech];
+ if (keep > 0) {
+ const clipped = keep < source.length;
+ parts.push("", `${sourceLabel}${clipped ? " (end)" : ""}:`, "```", ...source.slice(-keep), "```");
+ }
+ if (keep < source.length || hasLogs) parts.push("", pasteNote);
+ return toUrl(parts.join("\n"));
};
- let url = build(tail, "");
- if (url.length <= MAX_URL_LENGTH) return url;
-
- // Too long: keep the end of the tail, which is where the actual error is,
- // and point at the full logs rather than silently losing them.
- const note = "_Tail truncated — full logs via Settings → Export logs._";
- for (let keep = Math.min(tail.length, 20); keep > 0; keep--) {
- url = build(tail.slice(-keep), note);
- if (url.length <= MAX_URL_LENGTH) return url;
+ let url = build(source.length);
+ if (url.length <= SAFE_URL_CHARS) return url;
+ for (let keep = Math.min(source.length, 60); keep > 0; keep--) {
+ url = build(keep);
+ if (url.length <= SAFE_URL_CHARS) return url;
}
- return build([], note);
+ url = build(0);
+ if (url.length <= SAFE_URL_CHARS) return url;
+ // Pathological case: even the bare technical block didn't fit (an
+ // enormous "what"/"steps"). Fall back to clipboard-only, same as the
+ // original fix for this bug.
+ return toUrl("Copied to your clipboard - paste it into this field.");
}
// ─── Records ──────────────────────────────────────────────────────────────
@@ -320,24 +375,35 @@ async function openFailureDialog(record) {
const msgEl = document.getElementById("failureMessage");
const techEl = document.getElementById("failureTech");
const reportEl = document.getElementById("failureReport");
+ const discordEl = document.getElementById("failureReportDiscord");
+ const logsBtn = document.getElementById("failureIncludeLogs");
if (titleEl) titleEl.textContent = KIND_LABELS[record.kind] || "Something failed";
if (whenEl) whenEl.textContent = new Date(record.at).toLocaleString();
if (msgEl) msgEl.textContent = record.detail ? `${record.message}\n${record.detail}` : record.message;
if (techEl) techEl.textContent = "Collecting details…";
if (reportEl) reportEl.removeAttribute("href");
+ if (discordEl) discordEl.href = DISCORD_URL;
+ if (logsBtn) {
+ delete record.logs;
+ logsBtn.disabled = false;
+ logsBtn.textContent = "Include recent logs";
+ }
dialog.classList.remove("hidden");
- // The stderr tail lives in the quarantined error.txt and is fetched now
- // rather than at capture time -- one request when a user actually looks,
- // instead of one per failure whether or not they care.
+ // The stderr tail and full traceback live in the quarantined error.txt and
+ // are fetched now rather than at capture time -- one request when a user
+ // actually looks, instead of one per failure whether or not they care.
+ // Both are already home-directory-redacted server-side (redact_home) before
+ // this response is built, since this is headed for a public report.
if (!record.tail && record.context?.jobId) {
try {
const res = await fetch(`/api/jobs/${record.context.jobId}/failure`);
if (res.ok) {
const data = await res.json();
record.tail = Array.isArray(data.tail) ? data.tail : [];
+ record.traceback = Array.isArray(data.traceback) ? data.traceback : [];
record.cause = record.cause || cleanCause(data.cause);
record.context = {
...record.context,
@@ -349,21 +415,64 @@ async function openFailureDialog(record) {
persist();
} else {
record.tail = [];
+ record.traceback = [];
}
} catch (e) {
console.warn("[notifications] failure detail fetch failed:", e);
record.tail = [];
+ record.traceback = [];
}
}
const diag = await getDiagnostics();
- if (techEl) {
- const tail = record.tail?.length ? `\n\n${record.tail.join("\n")}` : "";
- techEl.textContent = `${technicalBlock(record, diag)}${tail}`;
- }
+ if (techEl) techEl.textContent = buildReportText(record, diag);
+ // One shared payload: the logs button mutates record.logs in place, and the
+ // report buttons must see that mutation on their next click without a
+ // separate re-sync step.
+ const payload = { record, diag };
// Set at open time; the global external-link handler reads href on click and
// routes it through Tauri's open_url on desktop, a new tab in a browser.
- if (reportEl) reportEl.href = buildReportUrl(record, diag);
+ // The click handlers wired in wireFailureDialog read record/diag back off
+ // this element's dataset to copy the full report to the clipboard - the
+ // reason the URL itself can stay short (see buildReportUrl).
+ if (reportEl) {
+ reportEl.href = buildReportUrl(record, diag);
+ reportEl._reportPayload = payload;
+ }
+ if (discordEl) discordEl._reportPayload = payload;
+ if (logsBtn) logsBtn._reportPayload = payload;
+}
+
+/** Opt-in: the notification centre never fetches these on its own. Scoped to
+ * the failure's own timestamp (plus a small buffer) rather than a blind "last
+ * hour", since a user can open this dialog long after the failure happened.
+ * Already home-directory-redacted server-side (redact_home in main.py). */
+async function fetchRecentLogs(record) {
+ const minutes = Math.min(1440, Math.max(15, Math.ceil((Date.now() - record.at) / 60000) + 5));
+ const logs = {};
+ for (const view of LOG_VIEWS) {
+ try {
+ const res = await fetch(`/api/logs/${view}?minutes=${minutes}`);
+ logs[view] = res.ok ? (await res.text()).trim() : "";
+ } catch (e) {
+ console.warn(`[notifications] failed to fetch the ${view} log:`, e);
+ logs[view] = "";
+ }
+ }
+ return logs;
+}
+
+/** Full diagnostic text is only useful pasted somewhere, so both report
+ * buttons copy it to the clipboard on click - the GitHub link's `extra`
+ * field says as much, and Discord has nowhere to prefill at all. */
+async function copyReportToClipboard(el) {
+ const payload = el._reportPayload;
+ if (!payload) return;
+ try {
+ await navigator.clipboard.writeText(buildReportText(payload.record, payload.diag));
+ } catch (e) {
+ console.warn("[notifications] clipboard write failed:", e);
+ }
}
function wireFailureDialog() {
@@ -378,6 +487,20 @@ function wireFailureDialog() {
e.stopPropagation();
clearFailures();
});
+ document.getElementById("failureReport")?.addEventListener("click", (e) => copyReportToClipboard(e.currentTarget));
+ document.getElementById("failureReportDiscord")?.addEventListener("click", (e) => copyReportToClipboard(e.currentTarget));
+ document.getElementById("failureIncludeLogs")?.addEventListener("click", async (e) => {
+ const btn = e.currentTarget;
+ const payload = btn._reportPayload;
+ if (!payload || btn.disabled) return;
+ btn.disabled = true;
+ btn.textContent = "Fetching logs…";
+ payload.record.logs = await fetchRecentLogs(payload.record);
+ persist();
+ const techEl = document.getElementById("failureTech");
+ if (techEl) techEl.textContent = buildReportText(payload.record, payload.diag);
+ btn.textContent = "Logs included";
+ });
}
export async function initNotifications({ diagnostics } = {}) {
diff --git a/tests/js/report-url.test.mjs b/tests/js/report-url.test.mjs
index ca988241..cbde6ab3 100644
--- a/tests/js/report-url.test.mjs
+++ b/tests/js/report-url.test.mjs
@@ -13,7 +13,13 @@
//
// Run: node tests/js/report-url.test.mjs
-import { buildReportUrl, osOption, installOption } from "../../static/js/notifications.js";
+import {
+ buildReportUrl,
+ buildReportText,
+ osOption,
+ installOption,
+ SAFE_URL_CHARS,
+} from "../../static/js/notifications.js";
let pass = 0,
fail = 0;
@@ -28,6 +34,11 @@ const check = (name, cond, detail = "") => {
};
const params = (url) => new URL(url).searchParams;
+// Substring checks against the raw URL miss content URLSearchParams encoded
+// (a space becomes "+", which plain decodeURIComponent does NOT reverse --
+// that's an application/x-www-form-urlencoded convention) -- decode properly
+// so a check for e.g. "line 399" actually means what it says.
+const decoded = (url) => decodeURIComponent(url.replace(/\+/g, " "));
// The exact option strings from bug_report.yml. If the template changes, this
// list changes with it -- that is the point.
@@ -95,7 +106,7 @@ check(
"blank issues are disabled, so template must be present",
url.startsWith("https://github.com/stemdeckapp/stemdeck/issues/new?"),
);
-check("title leads with the cause", q.get("title") === "[Bug]: Import failed — out-of-memory");
+check("title leads with the cause", q.get("title") === "[Bug]: Import failed - out-of-memory");
check("version is prefixed with v", q.get("version") === "v0.9.1");
check("os matches the dropdown", q.get("os") === "Windows");
check("install matches the dropdown", q.get("install") === "Windows ZIP");
@@ -103,11 +114,82 @@ check("what carries StemDeck's own message", q.get("what").includes("Audio proce
check("what carries the classified detail", q.get("what").includes("out-of-memory"));
check("steps names the stage it died at", q.get("steps").includes("Error: Processing failed"));
-const extra = q.get("extra");
-check("extra reports the device and the CPU fallback", extra.includes("cuda (fell back to CPU)"));
-check("extra reports the model", extra.includes("htdemucs_6s"));
-check("extra carries the stderr tail", extra.includes("Tried to allocate 2.40 GiB"));
-check("tail is fenced so GitHub renders it as code", extra.includes("```"));
+// A short tail like this one fits comfortably inside the safe URL budget, so
+// it's filled directly into the "Logs / screenshots" field -- no paste
+// needed for the common case (see the length-ceiling section below for what
+// happens when it doesn't fit).
+check("extra carries the short stderr tail directly", decoded(url).includes("Tried to allocate 2.40 GiB"));
+check(
+ "extra is not just a clipboard pointer when the content already fit",
+ !q.get("extra").startsWith("Copied to your clipboard"),
+);
+
+const text = buildReportText(RECORD, DIAG);
+check("report text reports the device and the CPU fallback", text.includes("cuda (fell back to CPU)"));
+check("report text reports the model", text.includes("htdemucs_6s"));
+check("report text has no traceback section when none was fetched", !text.includes("Traceback:"));
+
+// ─── traceback ───
+//
+// get_failure() now also returns the full backend traceback (already
+// home-directory-redacted server-side), fetched into record.traceback the
+// same way record.tail is. It must show up in the clipboard text, and -- when
+// short enough, as it is here -- directly in the URL too, preferred over the
+// stderr tail when both are present.
+
+const WITH_TRACEBACK = {
+ ...RECORD,
+ traceback: [
+ "Traceback (most recent call last):",
+ ' File "