From 4cd844ab7900c0096836f6d22ff95874cd27f149 Mon Sep 17 00:00:00 2001
From: Thales <>
Date: Tue, 25 Aug 2026 08:52:15 +0100
Subject: [PATCH 1/6] Playback speed: drop 0.25x, move 0.5x to 0.75x (#433)
Below roughly 0.7x the time-stretch artefacts dominate and the part gets
harder to follow, which is the opposite of what a practice speed is for.
The preset list, the button id list in state.js and the markup all have to
agree, so all three move together. applySpeed already snaps to the nearest
preset, so a stored or stale 0.5 lands on 0.75 rather than being rejected.
No i18n keys change: the labels are numeric.
---
static/index.html | 3 +--
static/js/state.js | 2 +-
static/js/transport.js | 5 ++++-
3 files changed, 6 insertions(+), 4 deletions(-)
diff --git a/static/index.html b/static/index.html
index edc88323..9bcf43a2 100644
--- a/static/index.html
+++ b/static/index.html
@@ -728,8 +728,7 @@
Speed
diff --git a/static/js/state.js b/static/js/state.js
index 20c54c72..5d23e6da 100644
--- a/static/js/state.js
+++ b/static/js/state.js
@@ -17,7 +17,7 @@ export const keyChip = $("t-key");
export const stemsChip = $("t-stems-chip");
export const timeEl = $("t-time");
export const masterFader = $("t-master");
-export const speedBtns = ["t-speed-025", "t-speed-05", "t-speed-1"].map($);
+export const speedBtns = ["t-speed-075", "t-speed-1"].map($);
export const npArt = $("np-art");
export const npThumb = $("np-thumb");
diff --git a/static/js/transport.js b/static/js/transport.js
index 6a5f9c48..6c5f8c5e 100644
--- a/static/js/transport.js
+++ b/static/js/transport.js
@@ -602,7 +602,10 @@ export function wireTransportButtons() {
// Fixed presets, not a continuous dial -- practice speeds for slowing a part
// down, not a general-purpose tempo control (issue #269 follow-up).
-const SPEED_PRESETS = [0.25, 0.5, 1];
+// 0.75x rather than 0.5x/0.25x (#433): below ~0.7x the time-stretch artefacts
+// dominate and the part gets harder to follow, which is the opposite of what
+// a practice speed is for.
+const SPEED_PRESETS = [0.75, 1];
function applySpeed(rate) {
// Snap to the nearest preset rather than clamping continuously: every
From 7a2d9a85c51bb16207b1ac1891cdacafaa6c74e6 Mon Sep 17 00:00:00 2001
From: Thales <>
Date: Tue, 25 Aug 2026 08:52:24 +0100
Subject: [PATCH 2/6] A failed import now says what went wrong (#434)
Every yt-dlp failure arrived as the bare word "unknown", so diagnosing one
meant reading data/logs/. Two independent causes, either enough on its own:
- _CAUSE_PATTERNS had no download category at all, so bot checks, HTTP 429
and "Requested format is not available" all fell through to the honest
default.
- _quarantine_failed_job only appended a message when the exception carried
a stderr tail, and only SeparationError does. A yt-dlp DownloadError never
has one, so even a correct cause arrived with no detail.
Adds source-blocked and source-unavailable, ordered after the resource-level
causes so a disk-full during a download is still a disk-full, and falls back
to the exception message when there is no tail. The message goes through
redact() first: yt-dlp embeds the source URL in its errors and error_detail
is served to the client and pasted into public reports.
---
app/pipeline/errors.py | 27 ++++++++++++++-
app/pipeline/runner.py | 17 +++++----
tests/test_errors.py | 26 ++++++++++++++
tests/test_pipeline_runner.py | 65 +++++++++++++++++++++++++++++++++++
4 files changed, 128 insertions(+), 7 deletions(-)
diff --git a/app/pipeline/errors.py b/app/pipeline/errors.py
index 48b68747..7a617e9d 100644
--- a/app/pipeline/errors.py
+++ b/app/pipeline/errors.py
@@ -63,6 +63,31 @@ def __init__(
"disk quota exceeded",
),
),
+ # Source-fetch failures (#434). Placed after the resource-level causes above
+ # so a genuine disk-full or OOM during a download still classifies as such,
+ # and before "bad-input" so yt-dlp's wording wins over the generic
+ # file-read patterns there.
+ (
+ "source-blocked",
+ (
+ # Apostrophe-insensitive: yt-dlp's wording is "Sign in to confirm
+ # you're not a bot", and the quoting has changed between releases.
+ "sign in to confirm",
+ "http error 429",
+ "too many requests",
+ ),
+ ),
+ (
+ "source-unavailable",
+ (
+ "requested format is not available",
+ "only images are available",
+ "n challenge solving failed",
+ "video unavailable",
+ "private video",
+ "has been removed",
+ ),
+ ),
(
"bad-input",
(
@@ -80,7 +105,7 @@ def __init__(
def classify_failure(text: str) -> str:
"""Map failure output to one of: out-of-memory, unsupported-device,
- disk-full, bad-input, unknown."""
+ disk-full, source-blocked, source-unavailable, bad-input, unknown."""
low = text.lower()
for cause, patterns in _CAUSE_PATTERNS:
if any(p in low for p in patterns):
diff --git a/app/pipeline/runner.py b/app/pipeline/runner.py
index 577a6337..8293d309 100644
--- a/app/pipeline/runner.py
+++ b/app/pipeline/runner.py
@@ -265,12 +265,17 @@ def _quarantine_failed_job(job: Job, job_dir: Path, jobs_dir: Path, exc: Excepti
tail: list[str] = getattr(exc, "tail", None) or []
cause = classify_failure("\n".join([*tail, repr(exc)]))
detail = cause
- if tail:
- # 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]}"
+ # 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.
+ #
+ # Prefer the stderr tail, which only SeparationError carries. Without the
+ # fallback, every yt-dlp failure arrived as the bare word "unknown" with no
+ # message at all, and the only way to find out what happened was to read
+ # data/logs/ (#434).
+ message = redact(tail[-1]) if tail else redact(str(exc))
+ if message.strip():
+ detail += f" — {message[:200]}"
job.error_detail = detail
try:
diff --git a/tests/test_errors.py b/tests/test_errors.py
index a6f2e61b..8f6a8c74 100644
--- a/tests/test_errors.py
+++ b/tests/test_errors.py
@@ -20,12 +20,38 @@
("RuntimeError: no stems produced by demucs", "bad-input"),
("something entirely novel went wrong", "unknown"),
("", "unknown"),
+ # Source-fetch failures (#434). Strings taken verbatim from the yt-dlp
+ # output in the issue rather than paraphrased.
+ ("ERROR: [youtube] abc: Sign in to confirm you're not a bot.", "source-blocked"),
+ ("HTTP Error 429: Too Many Requests", "source-blocked"),
+ ("ERROR: [youtube] abc: Requested format is not available", "source-unavailable"),
+ ("WARNING: Only images are available for download.", "source-unavailable"),
+ ("n challenge solving failed: Some formats may be missing.", "source-unavailable"),
+ ("ERROR: [youtube] abc: Video unavailable", "source-unavailable"),
+ (
+ "ERROR: [youtube] abc: Private video. Sign in if you've been granted access",
+ "source-unavailable",
+ ),
],
)
def test_classify_failure(text: str, expected: str):
assert classify_failure(text) == expected
+def test_resource_causes_win_over_source_causes():
+ """A disk-full that happens mid-download is a disk-full, not a fetch
+ failure: the resource-level patterns are ordered first for exactly this."""
+ text = "ERROR: unable to write; OSError: [Errno 28] No space left on device"
+ assert classify_failure(text) == "disk-full"
+
+
+def test_bot_check_matches_regardless_of_apostrophe():
+ """yt-dlp has changed the quoting of this message between releases, so the
+ pattern deliberately stops before the apostrophe."""
+ for variant in ("you're not a bot", "you’re not a bot", "you are not a bot"):
+ assert classify_failure(f"Sign in to confirm {variant}.") == "source-blocked"
+
+
def test_classify_is_case_insensitive():
assert classify_failure("CUDA OUT OF MEMORY") == "out-of-memory"
diff --git a/tests/test_pipeline_runner.py b/tests/test_pipeline_runner.py
index fc2afd92..431602e8 100644
--- a/tests/test_pipeline_runner.py
+++ b/tests/test_pipeline_runner.py
@@ -149,6 +149,71 @@ def boom(*args, **kwargs):
assert not (tmp_path / job.id).exists(), "job dir should be removed on local error"
+@pytest.mark.asyncio
+async def test_download_failure_carries_its_message(tmp_path: Path):
+ """#434: a yt-dlp failure has no stderr tail (only SeparationError carries
+ one), so error_detail used to arrive as the bare word "unknown". It must
+ now classify the cause AND carry the message."""
+ job = Job(id="abcdefabcde7")
+ job_dir = tmp_path / job.id
+ job_dir.mkdir(parents=True)
+ source = job_dir / "source.wav"
+ source.write_bytes(b"RIFF" + bytes(64))
+
+ def boom(*args, **kwargs):
+ raise RuntimeError("ERROR: [youtube] dQw4w9WgXcQ: Sign in to confirm you're not a bot.")
+
+ with patch("app.pipeline.runner._run_local_blocking", side_effect=boom):
+ await run_local_pipeline(job, source, tmp_path)
+
+ assert job.status == "error"
+ assert job.error_detail is not None
+ assert job.error_detail.startswith("source-blocked")
+ assert "Sign in to confirm" in job.error_detail
+ assert job.error_detail != "source-blocked"
+
+
+@pytest.mark.asyncio
+async def test_error_detail_stays_bare_when_exception_has_no_message(tmp_path: Path):
+ """The message fallback must not append an empty separator: a bare cause is
+ correct when there is genuinely nothing to say."""
+ job = Job(id="abcdefabcde8")
+ job_dir = tmp_path / job.id
+ job_dir.mkdir(parents=True)
+ source = job_dir / "source.wav"
+ source.write_bytes(b"RIFF" + bytes(64))
+
+ with patch("app.pipeline.runner._run_local_blocking", side_effect=RuntimeError()):
+ await run_local_pipeline(job, source, tmp_path)
+
+ assert job.error_detail == "unknown"
+
+
+@pytest.mark.asyncio
+async def test_download_failure_message_is_redacted(tmp_path: Path):
+ """error_detail is served to the client and pasted into public reports, so
+ the source URL yt-dlp embeds in its errors must not survive."""
+ job = Job(id="abcdefabcde9")
+ job_dir = tmp_path / job.id
+ job_dir.mkdir(parents=True)
+ source = job_dir / "source.wav"
+ source.write_bytes(b"RIFF" + bytes(64))
+
+ def boom(*args, **kwargs):
+ raise RuntimeError(
+ "ERROR: Unable to download https://www.youtube.com/watch?v=dQw4w9WgXcQ: "
+ "Requested format is not available"
+ )
+
+ with patch("app.pipeline.runner._run_local_blocking", side_effect=boom):
+ await run_local_pipeline(job, source, tmp_path)
+
+ assert job.error_detail is not None
+ assert job.error_detail.startswith("source-unavailable")
+ assert "youtube.com" not in job.error_detail
+ assert "dQw4w9WgXcQ" not in job.error_detail
+
+
@pytest.mark.asyncio
async def test_pipeline_error_quarantines_evidence(tmp_path: Path):
"""#277: a failed job's dir moves to jobs/failed/ with error.txt
From 79b1b4249a6f15b31f05c49718d8bad41c6eabe1 Mon Sep 17 00:00:00 2001
From: Thales <>
Date: Tue, 25 Aug 2026 08:52:40 +0100
Subject: [PATCH 3/6] One yt-dlp option builder, plus an opt-in cookies file
(#435, #432)
download.py built its YoutubeDL options in four separate places: playlist
expansion, the metadata probe, the audio fetch and the MP4 video fetch. They
had already drifted (ffmpeg_location in one of four, noplaylist in three),
and the drift is invisible: _download_video_track swallows every exception
and falls back to audio-only, so an option missing there costs the user
their MP4 export with nothing surfaced anywhere. _base_ydl_opts() now
carries what all four must share. allowed_extractors stays a required
argument because it is the SSRF boundary from #173 and the two valid values
are genuinely different.
On top of that, a cookies.txt path in Settings. YouTube's bot check has no
other remedy in-tree, since yt-dlp ships no PO token generator, so an IP
YouTube has flagged cannot import anything without credentials. A file path
rather than cookiesfrombrowser: reading a live browser profile means
touching the user's logged-in session and only works while that browser is
closed.
Empty by default, and that matters. Supplying cookies makes yt-dlp skip
every client that does not support them, which removes the unauthenticated
fallback clients that work for most people today. Turning this on when you
do not need it makes imports worse.
The field does not route through the shared post() helper, which drops a
non-ok response silently. A path the user typed has to say when it is wrong.
config.bundled_js_runtime() is the hook the challenge solver needs; it
returns None until a build actually ships a runtime, so nothing changes yet.
No dependency change, so uv.lock and the desktop runtimeId are untouched and
existing installs can still take this as an in-app update.
---
app/core/config.py | 32 +++++++++
app/core/settings.py | 48 +++++++++++++
app/main.py | 13 ++++
app/pipeline/download.py | 67 +++++++++++-------
static/css/daw.css | 13 ++++
static/js/catalog.js | 36 +++++++++-
static/js/i18n.js | 32 +++++++++
tests/test_cookies_setting.py | 108 +++++++++++++++++++++++++++++
tests/test_download_opts.py | 124 ++++++++++++++++++++++++++++++++++
9 files changed, 449 insertions(+), 24 deletions(-)
create mode 100644 tests/test_cookies_setting.py
create mode 100644 tests/test_download_opts.py
diff --git a/app/core/config.py b/app/core/config.py
index 07cc2d1f..0352c004 100644
--- a/app/core/config.py
+++ b/app/core/config.py
@@ -127,6 +127,38 @@ def _stored_jobs_dir() -> Path | None:
"STEMDECK_FFPROBE",
FFMPEG_DIR / ("ffprobe.exe" if sys.platform.startswith("win") else "ffprobe"),
)
+# JavaScript runtime for yt-dlp's YouTube challenge solver (#432). Portable
+# builds drop a binary here because nothing is on PATH in a portable install;
+# Docker ships deno on PATH and source checkouts have whatever the developer
+# installed, so both leave this directory absent and yt-dlp resolves its own.
+JS_RUNTIME_DIR = _env_path("STEMDECK_JS_RUNTIME_DIR", DATA_DIR / "jsruntime")
+
+# Ordered by yt-dlp's own JS challenge provider preference (deno 1000 >
+# node 900 > quickjs 850), so a build that ships more than one still gets the
+# solver yt-dlp would have picked itself.
+_JS_RUNTIME_BINARIES = (("deno", "deno"), ("node", "node"), ("quickjs", "qjs"))
+
+
+def bundled_js_runtime() -> tuple[str, Path] | None:
+ """The JS runtime shipped with this install, as (yt-dlp name, path).
+
+ None when nothing is bundled, which is the normal case outside a portable
+ build -- yt-dlp then falls back to its own PATH lookup. Never raises: a
+ missing or unreadable directory just means "not bundled".
+ """
+ try:
+ if not JS_RUNTIME_DIR.is_dir():
+ return None
+ suffix = ".exe" if sys.platform.startswith("win") else ""
+ for name, stem in _JS_RUNTIME_BINARIES:
+ exe = JS_RUNTIME_DIR / f"{stem}{suffix}"
+ if exe.is_file():
+ return name, exe
+ except OSError:
+ return None
+ return None
+
+
DEMUCS_MODEL = os.environ.get("STEMDECK_DEMUCS_MODEL", "htdemucs_6s").strip() or "htdemucs_6s"
MAX_DURATION_SEC = max(60, _env_int("STEMDECK_MAX_DURATION_SEC", 1200)) # 20 min default
JOB_TTL_SECONDS = max(300, _env_int("STEMDECK_JOB_TTL_SECONDS", 24 * 3600)) # 24 h default
diff --git a/app/core/settings.py b/app/core/settings.py
index 18da5259..2ff2cd9f 100644
--- a/app/core/settings.py
+++ b/app/core/settings.py
@@ -11,6 +11,7 @@
- `export_sample_rate` — sample rate for exported mixes/regions (WAV/FLAC/MP3).
- `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.
Defaults fall back to the config.py constants (which honor their env vars), so
nothing changes until the user overrides a value.
@@ -208,6 +209,53 @@ def set_jobs_dir(value: str | None) -> tuple[str | None, bool]:
return resolved, _save()
+# ── cookies_file ──
+# Path to a Netscape-format cookies.txt handed to yt-dlp as `cookiefile`.
+#
+# This exists because YouTube's bot check ("Sign in to confirm you're not a
+# bot") has no other remedy: yt-dlp ships no PO token generator, so an IP that
+# YouTube has flagged cannot import anything without credentials (#432).
+#
+# Deliberately a file path and not `cookiesfrombrowser`: reading a live browser
+# profile means touching the user's logged-in session on disk, and yt-dlp can
+# only do it reliably while that browser is closed. Exporting a cookies.txt is
+# an explicit, revocable act the user controls.
+#
+# Empty by default, and that matters. Supplying cookies makes yt-dlp skip every
+# client that does not support them, which removes the unauthenticated fallback
+# clients that work for most people today. Turning this on when you do not need
+# it makes imports worse, not better.
+def get_cookies_file() -> str | None:
+ with _LOCK:
+ value = _ensure().get("cookies_file")
+ return value if isinstance(value, str) and value.strip() else None
+
+
+def set_cookies_file(value: str | None) -> str | None:
+ """Persist the cookies.txt path, or clear it when given empty/None.
+
+ Raises ValueError when the path does not point at a readable file, so the
+ Settings UI can say so immediately rather than the user discovering it as a
+ failed import an hour later.
+ """
+ with _LOCK:
+ if value is None or not str(value).strip():
+ _ensure().pop("cookies_file", None)
+ _save()
+ return None
+ resolved = Path(str(value)).expanduser().resolve()
+ if not resolved.is_file():
+ raise ValueError("cookies file not found")
+ try:
+ with resolved.open("rb") as fh:
+ fh.read(1)
+ except OSError as e:
+ raise ValueError("cookies file is not readable") from e
+ _ensure()["cookies_file"] = str(resolved)
+ _save()
+ return str(resolved)
+
+
# ── playlist_max_items ──
# How many tracks one playlist import may queue. A waiting link costs a registry
# record, so the ceiling is generous; the real reason to keep this adjustable is
diff --git a/app/main.py b/app/main.py
index 784b0228..ecca117e 100644
--- a/app/main.py
+++ b/app/main.py
@@ -41,6 +41,7 @@
from app.core.registry import restore as restore_registry
from app.core.settings import (
get_allow_network,
+ get_cookies_file,
get_demucs_device,
get_demucs_device_choice,
get_export_sample_rate,
@@ -51,6 +52,7 @@
get_separation_quality,
get_video_max_height,
set_allow_network,
+ set_cookies_file,
set_demucs_device,
set_export_sample_rate,
set_jobs_dir,
@@ -321,6 +323,9 @@ def _settings_payload() -> dict[str, object]:
"video_max_height": get_video_max_height(),
"export_sample_rate": get_export_sample_rate(),
"separation_quality": get_separation_quality(),
+ # Absent unless the user set one. Only the path is exposed, never the
+ # file's contents -- those are the user's YouTube session.
+ "cookies_file": get_cookies_file(),
"port": get_port(),
# The user's choice ("auto" | "cuda" | "mps" | "cpu") drives the UI
# select; the resolved value shows what jobs will actually run on;
@@ -371,6 +376,14 @@ async def update_settings(request: Request) -> dict[str, object]:
except ValueError as e:
# Allowlist violation / non-integer -- the message names the valid rates.
raise HTTPException(status_code=422, detail=str(e)) from None
+ if "cookies_file" in body:
+ try:
+ set_cookies_file(body["cookies_file"])
+ except ValueError as e:
+ # "cookies file not found" / "not readable" -- both safe to show.
+ raise HTTPException(status_code=422, detail=str(e)) from None
+ except (TypeError, OSError):
+ raise HTTPException(status_code=422, detail="invalid cookies file") from None
if "demucs_device" in body:
try:
set_demucs_device(str(body["demucs_device"]))
diff --git a/app/pipeline/download.py b/app/pipeline/download.py
index 30e3d62b..56206c53 100644
--- a/app/pipeline/download.py
+++ b/app/pipeline/download.py
@@ -8,9 +8,9 @@
from yt_dlp import YoutubeDL
-from app.core.config import FFMPEG_DIR
+from app.core.config import FFMPEG_DIR, bundled_js_runtime
from app.core.models import Job, JobCancelled, _set
-from app.core.settings import get_max_duration_sec, get_video_max_height
+from app.core.settings import get_cookies_file, get_max_duration_sec, get_video_max_height
logger = logging.getLogger("stemdeck.download")
@@ -125,6 +125,44 @@ def _with_retries(job: Job, fn, *, what: str):
"soundcloud",
]
+
+def _base_ydl_opts(extractors: list[str]) -> dict:
+ """Options every YoutubeDL built in this module must share (#435).
+
+ There are four call sites -- playlist expansion, the metadata probe, the
+ audio fetch and the MP4 video fetch -- and they used to build their options
+ independently. They had already drifted (`ffmpeg_location` set in one of
+ four, `noplaylist` in three), and the drift is invisible: a fix applied to
+ the audio fetch silently misses the probe that runs before it and the video
+ fetch that runs after. `_download_video_track` swallows every exception and
+ falls back to audio-only, so a missing option there costs the user their
+ MP4 export with nothing surfaced anywhere.
+
+ `allowed_extractors` is a required argument rather than a default because
+ it is the SSRF boundary (#173) and the two valid values are genuinely
+ different; a caller must state which one it means.
+ """
+ opts: dict = {
+ "quiet": True,
+ "allowed_extractors": extractors,
+ "socket_timeout": _SOCKET_TIMEOUT_SEC,
+ }
+ # Portable builds have no ffmpeg on PATH; needed wherever a DASH stream
+ # might be remuxed. Inert for the metadata-only calls.
+ if FFMPEG_DIR.is_dir():
+ opts["ffmpeg_location"] = str(FFMPEG_DIR)
+ # YouTube's n-challenge solver needs a JS runtime. Absent outside portable
+ # builds, where yt-dlp resolves its own from PATH instead (#432).
+ if (runtime := bundled_js_runtime()) is not None:
+ name, exe = runtime
+ opts["js_runtimes"] = {name: {"path": str(exe)}}
+ # Opt-in, empty by default: cookies clear the bot check but make yt-dlp
+ # drop every client that does not support them (#432).
+ if (cookies := get_cookies_file()) is not None:
+ opts["cookiefile"] = cookies
+ return opts
+
+
# YouTube list ids. RD-prefixed ones are algorithmic radio: effectively endless
# and different for every viewer, so there is no meaningful set to import.
_PLAYLIST_ID_RE = re.compile(r"^[A-Za-z0-9_-]{2,64}$")
@@ -282,7 +320,7 @@ def expand_playlist(url: str, limit: int) -> dict:
"""
playlist_url = validate_playlist_url(url)
ydl_opts = {
- "quiet": True,
+ **_base_ydl_opts(_ALLOWED_PLAYLIST_EXTRACTORS),
"noprogress": True,
"skip_download": True,
"extract_flat": "in_playlist",
@@ -290,8 +328,6 @@ def expand_playlist(url: str, limit: int) -> dict:
# One past the cap, so a playlist longer than the cap can be reported as
# truncated rather than silently looking like it ends there.
"playlistend": max(1, limit) + 1,
- "allowed_extractors": _ALLOWED_PLAYLIST_EXTRACTORS,
- "socket_timeout": _SOCKET_TIMEOUT_SEC,
}
with YoutubeDL(ydl_opts) as ydl:
info = ydl.extract_info(playlist_url, download=False) or {}
@@ -356,22 +392,16 @@ def vhook(d: dict) -> None:
# devices) can't decode. Fall back to any <=cap mp4 only if no avc1 exists.
max_height = get_video_max_height()
ydl_opts = {
+ **_base_ydl_opts(_ALLOWED_EXTRACTORS),
"format": (
f"bestvideo[height<={max_height}][vcodec^=avc1]"
f"/bestvideo[height<={max_height}][ext=mp4]"
),
"outtmpl": str(job_dir / "video.%(ext)s"),
- "quiet": True,
"noprogress": True,
"noplaylist": True,
- "allowed_extractors": _ALLOWED_EXTRACTORS,
"progress_hooks": [vhook],
- "socket_timeout": _SOCKET_TIMEOUT_SEC,
}
- # Point yt-dlp at the bundled ffmpeg in case a DASH stream needs remuxing;
- # in portable builds ffmpeg is not on PATH.
- if FFMPEG_DIR.is_dir():
- ydl_opts["ffmpeg_location"] = str(FFMPEG_DIR)
_set(job, stage="Fetching video...")
try:
@@ -403,14 +433,7 @@ def download(job: Job, url: str, job_dir: Path) -> Path:
# policy as the download itself -- a transient blip on this first request
# used to fail the whole job immediately (#279).
def _probe() -> dict:
- with YoutubeDL(
- {
- "quiet": True,
- "noplaylist": True,
- "allowed_extractors": _ALLOWED_EXTRACTORS,
- "socket_timeout": _SOCKET_TIMEOUT_SEC,
- }
- ) as ydl:
+ with YoutubeDL({**_base_ydl_opts(_ALLOWED_EXTRACTORS), "noplaylist": True}) as ydl:
return ydl.extract_info(url, download=False) or {}
meta = _with_retries(job, _probe, what="metadata probe")
@@ -441,14 +464,12 @@ def hook(d: dict) -> None:
# directly via torchaudio + ffmpeg. Skipping the WAV transcode saves the slowest
# part of the download pipeline and a lot of disk.
ydl_opts = {
+ **_base_ydl_opts(_ALLOWED_EXTRACTORS),
"format": "bestaudio/best",
"outtmpl": str(job_dir / "source.%(ext)s"),
- "quiet": True,
"noprogress": True,
"noplaylist": True,
- "allowed_extractors": _ALLOWED_EXTRACTORS,
"progress_hooks": [hook],
- "socket_timeout": _SOCKET_TIMEOUT_SEC,
}
def _fetch() -> dict:
diff --git a/static/css/daw.css b/static/css/daw.css
index 5499fc77..74d82e49 100644
--- a/static/css/daw.css
+++ b/static/css/daw.css
@@ -3201,6 +3201,19 @@ input, textarea { font-family: inherit; }
.stems-location-msg.error { color: var(--danger); }
.stems-location-msg.ok { color: var(--accent); }
+/* Cookies path field (#432). Full width rather than the 84px num input: this
+ holds an absolute filesystem path, which is long and worth reading back. */
+.settings-text-input {
+ width: 100%; box-sizing: border-box; margin-top: 8px;
+ background: rgba(10,17,24,0.6); border: 1px solid var(--border-strong);
+ border-radius: 7px; color: var(--fg);
+ font-family: var(--font-mono); font-size: 12px; padding: 6px 9px;
+}
+.settings-text-input:focus { outline: none; border-color: rgba(244,183,64,0.5); }
+.cookies-file-msg {
+ margin-top: 6px; font-size: 11px; line-height: 1.4; color: var(--danger);
+}
+
/* Logs sub-navigation: Location / Application / Setup. */
.settings-subtabs {
display: flex; gap: 2px; margin: 0 0 10px;
diff --git a/static/js/catalog.js b/static/js/catalog.js
index a9b6ee20..5ab31404 100644
--- a/static/js/catalog.js
+++ b/static/js/catalog.js
@@ -3024,7 +3024,9 @@ async function wireGeneralSettings(overlay) {
const deviceSel = overlay.querySelector(".set-demucs-device");
const deviceDesc = overlay.querySelector(".set-demucs-desc");
const qualitySel = overlay.querySelector(".set-separation-quality");
- if (!durInput && !playlistInput && !heightSel && !sampleRateSel && !portInput && !deviceSel && !qualitySel) return;
+ const cookiesInput = overlay.querySelector(".set-cookies-file");
+ const cookiesMsg = overlay.querySelector(".cookies-file-msg");
+ if (!durInput && !playlistInput && !heightSel && !sampleRateSel && !portInput && !deviceSel && !qualitySel && !cookiesInput) 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).
@@ -3037,6 +3039,9 @@ async function wireGeneralSettings(overlay) {
if (sampleRateSel && d.export_sample_rate) sampleRateSel.value = String(d.export_sample_rate);
if (portInput && d.port) portInput.value = String(d.port);
if (qualitySel && d.separation_quality) qualitySel.value = d.separation_quality;
+ // 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 || "";
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.
@@ -3098,6 +3103,27 @@ async function wireGeneralSettings(overlay) {
const items = Math.max(1, Math.min(200, parseInt(playlistInput.value, 10) || 50));
post({ playlist_max_items: items });
});
+ // Not routed through post(): that helper drops a non-ok response silently,
+ // which is exactly the wrong behaviour for a path the user typed. A bad path
+ // has to say so, or the user retypes it and never learns why nothing
+ // happened.
+ cookiesInput?.addEventListener("change", async () => {
+ if (cookiesMsg) cookiesMsg.textContent = "";
+ try {
+ const r = await fetch("/api/settings", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ cookies_file: cookiesInput.value.trim() }),
+ });
+ if (r.ok) {
+ apply(await r.json());
+ } else if (cookiesMsg) {
+ // The server's detail is an English string; show the translated key
+ // instead so this reads correctly in every locale.
+ cookiesMsg.textContent = i18nT("settings.cookies.invalid");
+ }
+ } catch { /* offline: leave the field as typed */ }
+ });
heightSel?.addEventListener("change", () => {
post({ video_max_height: parseInt(heightSel.value, 10) });
});
@@ -3469,6 +3495,14 @@ function openLibraryEditor() {
+
+
+
YouTube cookies
+
Optional. Path to a cookies.txt file, used only when YouTube asks StemDeck to confirm it is not a bot. Leave this empty unless imports are failing.
+
+
+
+
StemData location
diff --git a/static/js/i18n.js b/static/js/i18n.js
index 34ab19b9..ba7d6117 100644
--- a/static/js/i18n.js
+++ b/static/js/i18n.js
@@ -420,6 +420,10 @@ const en = {
"settings.maxDuration.desc": "Longest track accepted for processing, in minutes (max 20).",
"settings.playlistLimit.title": "Playlist import limit",
"settings.playlistLimit.desc": "Most tracks one playlist import will queue (max 200).",
+ "settings.cookies.title": "YouTube cookies",
+ "settings.cookies.desc": "Optional. Path to a cookies.txt file, used only when YouTube asks StemDeck to confirm it is not a bot. Leave this empty unless imports are failing.",
+ "settings.cookies.placeholder": "Path to cookies.txt",
+ "settings.cookies.invalid": "File not found, or not readable.",
"settings.stemsLocation.title": "StemData location",
"settings.stemsLocation.change": "Change…",
"settings.stemsLocation.resetting": "Resetting…",
@@ -923,6 +927,10 @@ const pl = {
"settings.maxDuration.desc": "Najdłuższy utwór akceptowany do przetworzenia, w minutach (maks. 20).",
"settings.playlistLimit.title": "Limit importu playlisty",
"settings.playlistLimit.desc": "Ile utworów najwyżej zakolejkuje jeden import playlisty (maks. 200).",
+ "settings.cookies.title": "Pliki cookie YouTube",
+ "settings.cookies.desc": "Opcjonalne. Ścieżka do pliku cookies.txt, używana tylko wtedy, gdy YouTube prosi StemDeck o potwierdzenie, że nie jest botem. Zostaw puste, chyba że importy zawodzą.",
+ "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.stemsLocation.change": "Zmień…",
"settings.stemsLocation.resetting": "Resetowanie…",
@@ -1418,6 +1426,10 @@ const ja = {
"settings.maxDuration.desc": "処理を受け付ける最長トラック長(分単位、最大20分)。",
"settings.playlistLimit.title": "プレイリストインポート上限",
"settings.playlistLimit.desc": "プレイリストのインポート1回でキューされる最大トラック数(最大200)。",
+ "settings.cookies.title": "YouTube の Cookie",
+ "settings.cookies.desc": "任意。cookies.txt ファイルのパスです。YouTube が StemDeck にボットでないことの確認を求めた場合にのみ使われます。インポートが失敗しない限り空のままにしてください。",
+ "settings.cookies.placeholder": "cookies.txt のパス",
+ "settings.cookies.invalid": "ファイルが見つからないか、読み取れません。",
"settings.stemsLocation.title": "StemDataの保存場所",
"settings.stemsLocation.change": "変更…",
"settings.stemsLocation.resetting": "リセット中…",
@@ -1889,6 +1901,10 @@ const zhHans = {
"settings.maxDuration.desc": "可处理的最长曲目时长,单位为分钟(最长20分钟)。",
"settings.playlistLimit.title": "播放列表导入上限",
"settings.playlistLimit.desc": "单次播放列表导入最多排队的曲目数(最多200首)。",
+ "settings.cookies.title": "YouTube Cookie",
+ "settings.cookies.desc": "可选。cookies.txt 文件的路径,仅在 YouTube 要求 StemDeck 确认自己不是机器人时使用。除非导入失败,否则请留空。",
+ "settings.cookies.placeholder": "cookies.txt 的路径",
+ "settings.cookies.invalid": "找不到文件,或无法读取。",
"settings.stemsLocation.title": "StemData 存储位置",
"settings.stemsLocation.change": "更改…",
"settings.stemsLocation.resetting": "正在重置…",
@@ -2361,6 +2377,10 @@ const de = {
"settings.maxDuration.desc": "Längster zur Verarbeitung akzeptierter Track, in Minuten (max. 20).",
"settings.playlistLimit.title": "Playlist-Import-Limit",
"settings.playlistLimit.desc": "Höchste Anzahl Tracks, die ein Playlist-Import einreiht (max. 200).",
+ "settings.cookies.title": "YouTube-Cookies",
+ "settings.cookies.desc": "Optional. Pfad zu einer cookies.txt-Datei, die nur verwendet wird, wenn YouTube von StemDeck eine Bestätigung verlangt, dass es kein Bot ist. Leer lassen, solange Importe funktionieren.",
+ "settings.cookies.placeholder": "Pfad zu cookies.txt",
+ "settings.cookies.invalid": "Datei nicht gefunden oder nicht lesbar.",
"settings.stemsLocation.title": "StemData-Speicherort",
"settings.stemsLocation.change": "Ändern…",
"settings.stemsLocation.resetting": "Wird zurückgesetzt…",
@@ -2843,6 +2863,10 @@ const pt = {
"settings.maxDuration.desc": "Faixa mais longa aceita para processamento, em minutos (máx. 20).",
"settings.playlistLimit.title": "Limite de importação de playlist",
"settings.playlistLimit.desc": "Máximo de faixas que uma importação de playlist enfileira (máx. 200).",
+ "settings.cookies.title": "Cookies do YouTube",
+ "settings.cookies.desc": "Opcional. Caminho para um arquivo cookies.txt, usado apenas quando o YouTube pede ao StemDeck para confirmar que não é um robô. Deixe vazio a menos que as importações estejam falhando.",
+ "settings.cookies.placeholder": "Caminho para cookies.txt",
+ "settings.cookies.invalid": "Arquivo não encontrado ou ilegível.",
"settings.stemsLocation.title": "Local do StemData",
"settings.stemsLocation.change": "Alterar…",
"settings.stemsLocation.resetting": "Redefinindo…",
@@ -3326,6 +3350,10 @@ const id = {
"settings.maxDuration.desc": "Trek terpanjang yang diterima untuk diproses, dalam menit (maks. 20).",
"settings.playlistLimit.title": "Batas impor playlist",
"settings.playlistLimit.desc": "Jumlah trek terbanyak yang akan diantrekan satu kali impor playlist (maks. 200).",
+ "settings.cookies.title": "Cookie YouTube",
+ "settings.cookies.desc": "Opsional. Jalur ke berkas cookies.txt, dipakai hanya ketika YouTube meminta StemDeck memastikan bahwa ia bukan bot. Biarkan kosong kecuali impor gagal.",
+ "settings.cookies.placeholder": "Jalur ke cookies.txt",
+ "settings.cookies.invalid": "Berkas tidak ditemukan atau tidak dapat dibaca.",
"settings.stemsLocation.title": "Lokasi StemData",
"settings.stemsLocation.change": "Ubah…",
"settings.stemsLocation.resetting": "Mengatur ulang…",
@@ -3799,6 +3827,10 @@ const fr = {
"settings.maxDuration.desc": "Durée maximale acceptée pour le traitement, en minutes (max. 20).",
"settings.playlistLimit.title": "Limite d'import de playlist",
"settings.playlistLimit.desc": "Nombre maximal de morceaux mis en file lors d'un import de playlist (max. 200).",
+ "settings.cookies.title": "Cookies YouTube",
+ "settings.cookies.desc": "Facultatif. Chemin vers un fichier cookies.txt, utilisé uniquement lorsque YouTube demande à StemDeck de confirmer qu'il n'est pas un robot. Laissez vide sauf si les imports échouent.",
+ "settings.cookies.placeholder": "Chemin vers cookies.txt",
+ "settings.cookies.invalid": "Fichier introuvable ou illisible.",
"settings.stemsLocation.title": "Emplacement des StemData",
"settings.stemsLocation.change": "Modifier…",
"settings.stemsLocation.resetting": "Réinitialisation…",
diff --git a/tests/test_cookies_setting.py b/tests/test_cookies_setting.py
new file mode 100644
index 00000000..b7395196
--- /dev/null
+++ b/tests/test_cookies_setting.py
@@ -0,0 +1,108 @@
+"""The optional cookies.txt handed to yt-dlp (#432).
+
+YouTube's bot check has no other remedy in-tree: yt-dlp ships no PO token
+generator, so an IP YouTube has flagged cannot import anything without
+credentials. This is deliberately a file path rather than `cookiesfrombrowser`,
+and deliberately empty by default -- supplying cookies makes yt-dlp skip every
+client that does not support them, which removes the unauthenticated fallback
+that works for most people.
+"""
+
+from __future__ import annotations
+
+import pytest
+from fastapi.testclient import TestClient
+
+from app.core import settings as _settings
+
+
+@pytest.fixture
+def client():
+ from app.main import app
+
+ with TestClient(app) as c:
+ yield c
+
+
+@pytest.fixture
+def cookies_txt(tmp_path):
+ p = tmp_path / "cookies.txt"
+ p.write_text("# Netscape HTTP Cookie File\n", encoding="utf-8")
+ return p
+
+
+def test_unset_by_default():
+ assert _settings.get_cookies_file() is None
+
+
+def test_set_and_read_back(cookies_txt):
+ stored = _settings.set_cookies_file(str(cookies_txt))
+ assert stored == str(cookies_txt.resolve())
+ assert _settings.get_cookies_file() == str(cookies_txt.resolve())
+
+
+def test_path_is_resolved(tmp_path, cookies_txt):
+ """Stored absolute and resolved, so a later cwd change can't move it."""
+ relative = f"{tmp_path}/.//cookies.txt"
+ assert _settings.set_cookies_file(relative) == str(cookies_txt.resolve())
+
+
+def test_empty_clears(cookies_txt):
+ _settings.set_cookies_file(str(cookies_txt))
+ assert _settings.set_cookies_file("") is None
+ assert _settings.get_cookies_file() is None
+
+
+def test_none_clears(cookies_txt):
+ _settings.set_cookies_file(str(cookies_txt))
+ assert _settings.set_cookies_file(None) is None
+ assert _settings.get_cookies_file() is None
+
+
+def test_missing_file_is_rejected(tmp_path):
+ """Told at the point of setting, not discovered as a failed import later."""
+ with pytest.raises(ValueError):
+ _settings.set_cookies_file(str(tmp_path / "nope.txt"))
+ assert _settings.get_cookies_file() is None
+
+
+def test_directory_is_rejected(tmp_path):
+ with pytest.raises(ValueError):
+ _settings.set_cookies_file(str(tmp_path))
+
+
+def test_a_rejected_value_does_not_replace_a_good_one(tmp_path, cookies_txt):
+ _settings.set_cookies_file(str(cookies_txt))
+ with pytest.raises(ValueError):
+ _settings.set_cookies_file(str(tmp_path / "nope.txt"))
+ assert _settings.get_cookies_file() == str(cookies_txt.resolve())
+
+
+def test_api_exposes_the_path(client, cookies_txt):
+ _settings.set_cookies_file(str(cookies_txt))
+ body = client.get("/api/settings").json()
+ assert body["cookies_file"] == str(cookies_txt.resolve())
+
+
+def test_api_never_exposes_the_contents(client, cookies_txt):
+ """The file is the user's YouTube session. Only the path is public."""
+ cookies_txt.write_text("# Netscape HTTP Cookie File\nSECRETVALUE\n", encoding="utf-8")
+ _settings.set_cookies_file(str(cookies_txt))
+ assert "SECRETVALUE" not in client.get("/api/settings").text
+
+
+def test_api_sets_and_clears(client, cookies_txt):
+ assert client.post("/api/settings", json={"cookies_file": str(cookies_txt)}).status_code == 200
+ assert _settings.get_cookies_file() == str(cookies_txt.resolve())
+ assert client.post("/api/settings", json={"cookies_file": ""}).status_code == 200
+ assert _settings.get_cookies_file() is None
+
+
+def test_api_rejects_a_missing_file(client, tmp_path):
+ resp = client.post("/api/settings", json={"cookies_file": str(tmp_path / "nope.txt")})
+ assert resp.status_code == 422
+ assert "not found" in resp.json()["detail"]
+
+
+def test_api_rejects_a_non_string(client):
+ assert client.post("/api/settings", json={"cookies_file": 17}).status_code == 422
diff --git a/tests/test_download_opts.py b/tests/test_download_opts.py
new file mode 100644
index 00000000..4ce82c49
--- /dev/null
+++ b/tests/test_download_opts.py
@@ -0,0 +1,124 @@
+"""Every YoutubeDL in download.py must be built from the same base (#435).
+
+The four call sites -- playlist expansion, the metadata probe, the audio fetch
+and the MP4 video fetch -- used to build their options independently and had
+already drifted. These tests capture what each one actually hands to
+YoutubeDL, so an option added to fix one caller can't silently miss the rest.
+"""
+
+from __future__ import annotations
+
+from pathlib import Path
+
+import pytest
+
+from app.core.models import Job
+from app.pipeline import download as dl_mod
+
+
+class _FakeYDL:
+ """Records the options dict and returns just enough to get through."""
+
+ captured: list[dict] = []
+
+ def __init__(self, opts):
+ _FakeYDL.captured.append(opts)
+ self._opts = opts
+
+ def __enter__(self):
+ return self
+
+ def __exit__(self, *_exc):
+ return False
+
+ def extract_info(self, url, download=False):
+ return {"title": "t", "duration": 10, "entries": []}
+
+
+@pytest.fixture(autouse=True)
+def _capture(monkeypatch):
+ _FakeYDL.captured = []
+ monkeypatch.setattr(dl_mod, "YoutubeDL", _FakeYDL)
+ # Nothing bundled by default, so the base carries no runtime/cookie keys
+ # unless a test opts in.
+ monkeypatch.setattr(dl_mod, "bundled_js_runtime", lambda: None)
+ monkeypatch.setattr(dl_mod, "get_cookies_file", lambda: None)
+ return _FakeYDL
+
+
+def _all_call_sites(tmp_path: Path) -> list[dict]:
+ """Drive every YoutubeDL construction in the module once."""
+ _FakeYDL.captured = []
+ dl_mod.expand_playlist("https://www.youtube.com/playlist?list=PL123", 10)
+ job = Job(id="abcdefabc435")
+ job_dir = tmp_path / job.id
+ job_dir.mkdir(parents=True, exist_ok=True)
+ (job_dir / "source.webm").write_bytes(b"x")
+ dl_mod.download(job, "https://www.youtube.com/watch?v=dQw4w9WgXcQ", job_dir)
+ return list(_FakeYDL.captured)
+
+
+def test_every_call_site_is_covered(tmp_path):
+ """Playlist, probe, audio fetch, video fetch: four constructions."""
+ opts = _all_call_sites(tmp_path)
+ assert len(opts) == 4, f"expected 4 YoutubeDL constructions, saw {len(opts)}"
+
+
+def test_ssrf_allowlist_is_set_everywhere(tmp_path):
+ """The extractor allowlist is the #173 SSRF boundary. No call site may
+ omit it, and none may fall back to the permissive playlist list."""
+ opts = _all_call_sites(tmp_path)
+ for o in opts:
+ assert o.get("allowed_extractors"), "a call site has no extractor allowlist"
+ # Only the playlist expansion may use the wider list.
+ wide = [o for o in opts if o["allowed_extractors"] is dl_mod._ALLOWED_PLAYLIST_EXTRACTORS]
+ assert len(wide) == 1
+
+
+def test_socket_timeout_is_set_everywhere(tmp_path):
+ """#279: a stalled TCP connection must not hang a job at any call site."""
+ for o in _all_call_sites(tmp_path):
+ assert o.get("socket_timeout") == dl_mod._SOCKET_TIMEOUT_SEC
+
+
+def test_cookies_reach_every_call_site(tmp_path, monkeypatch):
+ """The bot check hits the probe first, so a cookie file that only reached
+ the audio fetch would never be used (#432)."""
+ monkeypatch.setattr(dl_mod, "get_cookies_file", lambda: "/tmp/cookies.txt")
+ for o in _all_call_sites(tmp_path):
+ assert o.get("cookiefile") == "/tmp/cookies.txt"
+
+
+def test_no_cookie_key_when_unset(tmp_path):
+ """Absent, not empty-string: yt-dlp treats a falsy cookiefile differently
+ from an unset one, and the default must be a plain unauthenticated fetch."""
+ for o in _all_call_sites(tmp_path):
+ assert "cookiefile" not in o
+
+
+def test_js_runtime_reaches_every_call_site(tmp_path, monkeypatch):
+ """Format resolution happens in the probe and both fetches, so the solver
+ has to be configured for all of them (#432)."""
+ monkeypatch.setattr(dl_mod, "bundled_js_runtime", lambda: ("quickjs", Path("/opt/js/qjs")))
+ for o in _all_call_sites(tmp_path):
+ assert "quickjs" in o.get("js_runtimes", {})
+
+
+def test_no_js_runtime_key_when_nothing_bundled(tmp_path):
+ """Docker and source checkouts resolve a runtime from PATH; passing an
+ empty dict would clear yt-dlp's own defaults instead."""
+ for o in _all_call_sites(tmp_path):
+ assert "js_runtimes" not in o
+
+
+def test_per_call_options_do_not_clobber_the_base(tmp_path):
+ """The base is spread first so a caller can layer on top of it. That also
+ means a caller could overwrite a security option by accident -- this is
+ the guard that it hasn't happened."""
+ opts = _all_call_sites(tmp_path)
+ for o in opts:
+ assert o["socket_timeout"] == dl_mod._SOCKET_TIMEOUT_SEC
+ assert o["allowed_extractors"] in (
+ dl_mod._ALLOWED_EXTRACTORS,
+ dl_mod._ALLOWED_PLAYLIST_EXTRACTORS,
+ )
From 8922b8da372cbd2d5dc8ebfd8c27493b2226dd24 Mon Sep 17 00:00:00 2001
From: Thales <>
Date: Tue, 25 Aug 2026 09:20:01 +0100
Subject: [PATCH 4/6] Cookies are a fallback, not the default path (#432)
Sending cookies on every request would have made imports worse for most
people. yt-dlp skips every client that does not support cookies, and those
skipped clients are exactly the ones resolving formats today without any JS
challenge solver. The setting would have removed a working path in order to
fix a broken one for the smaller group whose IP YouTube has flagged.
So the first attempt never carries cookies. They are used only after YouTube
has actually turned us away with a bot check or a 429. By then the path they
displace has already failed, which is what makes the setting incapable of
making anything worse. Same shape as separate()'s GPU to CPU retry: the
fallback runs only once the primary path is known to be dead.
The probe is where the bot check lands, so that is where the decision is
made. Whether cookies were needed is returned rather than re-derived, and
passed to the audio fetch and the video fetch so one job never spends a
second round trip proving the same thing twice.
Second half: when cookies clear the bot check and the job then dies for want
of a solver, say so. Without it the user sees 'Requested format is not
available' with no way to connect it to the setting they just turned on. The
rewrite only happens when no JS runtime can be found, so a genuine format
failure keeps its real message, and the original error stays chained.
---
app/core/config.py | 13 +++
app/pipeline/download.py | 117 +++++++++++++++++++++------
tests/test_cookie_fallback.py | 144 ++++++++++++++++++++++++++++++++++
tests/test_download_opts.py | 10 ++-
4 files changed, 258 insertions(+), 26 deletions(-)
create mode 100644 tests/test_cookie_fallback.py
diff --git a/app/core/config.py b/app/core/config.py
index 0352c004..43102938 100644
--- a/app/core/config.py
+++ b/app/core/config.py
@@ -1,6 +1,7 @@
import json
import os
import re
+import shutil
import sys
from pathlib import Path
@@ -159,6 +160,18 @@ def bundled_js_runtime() -> tuple[str, Path] | None:
return None
+def js_solver_available() -> bool:
+ """Whether anything on this machine could run YouTube's challenge solver.
+
+ Used to explain a failure, never to gate one: yt-dlp does its own runtime
+ discovery and this is only a best-effort mirror of it. A false positive
+ just means the user gets the generic error instead of the specific one.
+ """
+ if bundled_js_runtime() is not None:
+ return True
+ return any(shutil.which(exe) for _, exe in _JS_RUNTIME_BINARIES)
+
+
DEMUCS_MODEL = os.environ.get("STEMDECK_DEMUCS_MODEL", "htdemucs_6s").strip() or "htdemucs_6s"
MAX_DURATION_SEC = max(60, _env_int("STEMDECK_MAX_DURATION_SEC", 1200)) # 20 min default
JOB_TTL_SECONDS = max(300, _env_int("STEMDECK_JOB_TTL_SECONDS", 24 * 3600)) # 24 h default
diff --git a/app/pipeline/download.py b/app/pipeline/download.py
index 56206c53..046ed39b 100644
--- a/app/pipeline/download.py
+++ b/app/pipeline/download.py
@@ -8,7 +8,7 @@
from yt_dlp import YoutubeDL
-from app.core.config import FFMPEG_DIR, bundled_js_runtime
+from app.core.config import FFMPEG_DIR, bundled_js_runtime, js_solver_available
from app.core.models import Job, JobCancelled, _set
from app.core.settings import get_cookies_file, get_max_duration_sec, get_video_max_height
@@ -86,6 +86,70 @@ def _with_retries(job: Job, fn, *, what: str):
raise
+# YouTube refusing to serve us at all, as opposed to a video being unavailable.
+# Cookies are the only remedy in-tree: yt-dlp ships no PO token generator.
+_BOT_CHECK = ("sign in to confirm", "http error 429", "too many requests")
+
+# What a missing challenge solver looks like once cookies ARE in play.
+_NEEDS_SOLVER = (
+ "requested format is not available",
+ "only images are available",
+ "n challenge solving failed",
+)
+
+
+def _is_bot_check(exc: Exception) -> bool:
+ low = str(exc).lower()
+ return any(s in low for s in _BOT_CHECK)
+
+
+def _with_cookie_fallback(job: Job, fn, *, what: str) -> tuple[object, bool]:
+ """Run `fn(use_cookies)` without cookies first, with them only if YouTube
+ turned us away (#432).
+
+ Cookies are not a better way to fetch: supplying them makes yt-dlp skip
+ every client that does not support them, which removes the unauthenticated
+ fallback clients that resolve formats today without any JS challenge
+ solver. Applying them to every request would therefore break imports that
+ currently work, in order to fix imports for the smaller group whose IP
+ YouTube has flagged.
+
+ Trying without them first means the setting cannot make anything worse: by
+ the time cookies are used, the path they would have displaced has already
+ failed. Same shape as separate()'s GPU->CPU retry -- the fallback runs only
+ once the primary path is known to be dead.
+
+ Returns (result, used_cookies). The caller passes that flag into any later
+ request for the same URL, so one job never re-derives the answer.
+ """
+
+ def attempt(use_cookies: bool):
+ return _with_retries(job, lambda: fn(use_cookies), what=what)
+
+ try:
+ return attempt(False), False
+ except Exception as exc:
+ if job.cancel_requested or not _is_bot_check(exc) or get_cookies_file() is None:
+ raise
+ logger.info("[%s] %s hit YouTube's bot check; retrying with cookies", job.id, what)
+ _set(job, stage="Retrying with cookies...")
+ try:
+ return attempt(True), True
+ except Exception as retry_exc:
+ # The cookies cleared the bot check and the job then died for want
+ # of a challenge solver. Say that, rather than leaving the user to
+ # infer it from "Requested format is not available" (#432).
+ low = str(retry_exc).lower()
+ if any(p in low for p in _NEEDS_SOLVER) and not js_solver_available():
+ raise RuntimeError(
+ "Cookies cleared YouTube's bot check, but no JavaScript runtime is "
+ "available to solve YouTube's format challenge, so no audio format "
+ "could be resolved. Clearing the cookies path in Settings restores "
+ "the fallback that does not need one."
+ ) from retry_exc
+ raise
+
+
_VIDEO_ID_RE = re.compile(r"^[A-Za-z0-9_-]{11}$")
_YOUTUBE_HOSTS = frozenset(
(
@@ -126,7 +190,7 @@ def _with_retries(job: Job, fn, *, what: str):
]
-def _base_ydl_opts(extractors: list[str]) -> dict:
+def _base_ydl_opts(extractors: list[str], *, use_cookies: bool = False) -> dict:
"""Options every YoutubeDL built in this module must share (#435).
There are four call sites -- playlist expansion, the metadata probe, the
@@ -156,9 +220,9 @@ def _base_ydl_opts(extractors: list[str]) -> dict:
if (runtime := bundled_js_runtime()) is not None:
name, exe = runtime
opts["js_runtimes"] = {name: {"path": str(exe)}}
- # Opt-in, empty by default: cookies clear the bot check but make yt-dlp
- # drop every client that does not support them (#432).
- if (cookies := get_cookies_file()) is not None:
+ # Only on an explicit retry, never on the first attempt. See
+ # _with_cookie_fallback for why (#432).
+ if use_cookies and (cookies := get_cookies_file()) is not None:
opts["cookiefile"] = cookies
return opts
@@ -365,7 +429,7 @@ def expand_playlist(url: str, limit: int) -> dict:
}
-def _download_video_track(job: Job, url: str, job_dir: Path) -> None:
+def _download_video_track(job: Job, url: str, job_dir: Path, *, use_cookies: bool = False) -> None:
"""Best-effort: download a video-only H.264/MP4 stream to video.mp4 for the
MP4 export (issue #219). The audio source is downloaded separately as
usual; this is a second, additive fetch so the audio pipeline is untouched.
@@ -392,7 +456,7 @@ def vhook(d: dict) -> None:
# devices) can't decode. Fall back to any <=cap mp4 only if no avc1 exists.
max_height = get_video_max_height()
ydl_opts = {
- **_base_ydl_opts(_ALLOWED_EXTRACTORS),
+ **_base_ydl_opts(_ALLOWED_EXTRACTORS, use_cookies=use_cookies),
"format": (
f"bestvideo[height<={max_height}][vcodec^=avc1]"
f"/bestvideo[height<={max_height}][ext=mp4]"
@@ -432,11 +496,16 @@ def download(job: Job, url: str, job_dir: Path) -> Path:
# too long before wasting bandwidth and disk. Runs under the same retry
# policy as the download itself -- a transient blip on this first request
# used to fail the whole job immediately (#279).
- def _probe() -> dict:
- with YoutubeDL({**_base_ydl_opts(_ALLOWED_EXTRACTORS), "noplaylist": True}) as ydl:
+ def _probe(use_cookies: bool) -> dict:
+ opts = {**_base_ydl_opts(_ALLOWED_EXTRACTORS, use_cookies=use_cookies), "noplaylist": True}
+ with YoutubeDL(opts) as ydl:
return ydl.extract_info(url, download=False) or {}
- meta = _with_retries(job, _probe, what="metadata probe")
+ # The bot check lands on this first request, so this is where the cookie
+ # fallback is decided. Whether it engaged is remembered below so the fetch
+ # does not have to rediscover it.
+ probed, needs_cookies = _with_cookie_fallback(job, _probe, what="metadata probe")
+ meta: dict = probed if isinstance(probed, dict) else {}
duration = meta.get("duration") or 0
max_duration = get_max_duration_sec()
if duration > max_duration:
@@ -463,20 +532,24 @@ def hook(d: dict) -> None:
# No postprocessors -- Demucs reads the raw audio container (webm/m4a/opus/...)
# directly via torchaudio + ffmpeg. Skipping the WAV transcode saves the slowest
# part of the download pipeline and a lot of disk.
- ydl_opts = {
- **_base_ydl_opts(_ALLOWED_EXTRACTORS),
- "format": "bestaudio/best",
- "outtmpl": str(job_dir / "source.%(ext)s"),
- "noprogress": True,
- "noplaylist": True,
- "progress_hooks": [hook],
- }
-
- def _fetch() -> dict:
+ def _fetch(use_cookies: bool) -> dict:
+ ydl_opts = {
+ **_base_ydl_opts(_ALLOWED_EXTRACTORS, use_cookies=use_cookies),
+ "format": "bestaudio/best",
+ "outtmpl": str(job_dir / "source.%(ext)s"),
+ "noprogress": True,
+ "noplaylist": True,
+ "progress_hooks": [hook],
+ }
with YoutubeDL(ydl_opts) as ydl:
return ydl.extract_info(url, download=True) or {}
- info: dict = _with_retries(job, _fetch, what="download")
+ # If the probe already needed cookies, this request will too -- go straight
+ # there rather than spending another round trip proving it again.
+ if needs_cookies:
+ info: dict = _with_retries(job, lambda: _fetch(True), what="download")
+ else:
+ info, needs_cookies = _with_cookie_fallback(job, _fetch, what="download")
_set(
job,
@@ -497,7 +570,7 @@ def _fetch() -> dict:
# Best-effort: fetch the real video stream for the MP4 export.
# Non-fatal -- on any failure the job proceeds audio-only.
if is_youtube:
- _download_video_track(job, url, job_dir)
+ _download_video_track(job, url, job_dir, use_cookies=needs_cookies)
candidates = sorted(job_dir.glob("source.*"))
if not candidates:
diff --git a/tests/test_cookie_fallback.py b/tests/test_cookie_fallback.py
new file mode 100644
index 00000000..cc1a1ae0
--- /dev/null
+++ b/tests/test_cookie_fallback.py
@@ -0,0 +1,144 @@
+"""Cookies are a fallback, never the default path (#432).
+
+Supplying cookies makes yt-dlp skip every client that does not support them,
+which removes the unauthenticated fallback clients that resolve formats today
+without any JS challenge solver. Applying them to every request would break
+imports that currently work in order to fix imports for the smaller group whose
+IP YouTube has flagged.
+
+So the first attempt never carries cookies, and they are used only once YouTube
+has actually turned us away. By then the path they displace has already failed,
+which is what makes the setting incapable of making anything worse.
+"""
+
+from __future__ import annotations
+
+import pytest
+
+from app.core.models import Job, JobCancelled
+from app.pipeline import download as dl_mod
+
+BOT_CHECK = "ERROR: [youtube] abc: Sign in to confirm you're not a bot."
+NO_FORMAT = "ERROR: [youtube] abc: Requested format is not available"
+
+
+@pytest.fixture(autouse=True)
+def _no_sleep(monkeypatch):
+ monkeypatch.setattr(dl_mod.time, "sleep", lambda _s: None)
+
+
+@pytest.fixture
+def job():
+ return Job(id="abcdefabc432")
+
+
+def _recorder(failures):
+ """fn(use_cookies) that raises `failures` in order, then succeeds."""
+ seen: list[bool] = []
+ remaining = list(failures)
+
+ def fn(use_cookies: bool):
+ seen.append(use_cookies)
+ if remaining:
+ raise RuntimeError(remaining.pop(0))
+ return {"ok": True}
+
+ return fn, seen
+
+
+def test_happy_path_never_touches_cookies(job, monkeypatch):
+ monkeypatch.setattr(dl_mod, "get_cookies_file", lambda: "/tmp/c.txt")
+ fn, seen = _recorder([])
+ result, used = dl_mod._with_cookie_fallback(job, fn, what="probe")
+ assert result == {"ok": True}
+ assert used is False
+ assert seen == [False], "a succeeding request must never be retried with cookies"
+
+
+def test_bot_check_retries_with_cookies(job, monkeypatch):
+ monkeypatch.setattr(dl_mod, "get_cookies_file", lambda: "/tmp/c.txt")
+ fn, seen = _recorder([BOT_CHECK])
+ result, used = dl_mod._with_cookie_fallback(job, fn, what="probe")
+ assert result == {"ok": True}
+ assert used is True
+ assert seen == [False, True], "cookie-less first, cookies only on the retry"
+
+
+def test_no_retry_without_a_configured_cookie_file(job, monkeypatch):
+ monkeypatch.setattr(dl_mod, "get_cookies_file", lambda: None)
+ fn, seen = _recorder([BOT_CHECK])
+ with pytest.raises(RuntimeError, match="not a bot"):
+ dl_mod._with_cookie_fallback(job, fn, what="probe")
+ assert seen == [False]
+
+
+def test_a_non_bot_failure_is_not_retried(job, monkeypatch):
+ """Cookies fix a bot check. They do nothing for a deleted video, and
+ retrying would just cost the user another round trip."""
+ monkeypatch.setattr(dl_mod, "get_cookies_file", lambda: "/tmp/c.txt")
+ fn, seen = _recorder(["ERROR: [youtube] abc: Video unavailable"])
+ with pytest.raises(RuntimeError, match="Video unavailable"):
+ dl_mod._with_cookie_fallback(job, fn, what="probe")
+ assert seen == [False]
+
+
+def test_cancel_beats_the_retry(job, monkeypatch):
+ """A cancel mid-attempt surfaces as JobCancelled (via _with_retries) and
+ must not be followed by a second, cookie-bearing request."""
+ monkeypatch.setattr(dl_mod, "get_cookies_file", lambda: "/tmp/c.txt")
+ job.cancel_requested = True
+ fn, seen = _recorder([BOT_CHECK])
+ with pytest.raises(JobCancelled):
+ dl_mod._with_cookie_fallback(job, fn, what="probe")
+ assert seen == [False], "a cancelled job must not start a second attempt"
+
+
+def test_missing_solver_is_explained(job, monkeypatch):
+ """Cookies clear the bot check, then the job dies for want of a challenge
+ solver. Without this the user sees 'Requested format is not available' and
+ has no way to connect it to the setting they just turned on."""
+ monkeypatch.setattr(dl_mod, "get_cookies_file", lambda: "/tmp/c.txt")
+ monkeypatch.setattr(dl_mod, "js_solver_available", lambda: False)
+ fn, _ = _recorder([BOT_CHECK, NO_FORMAT])
+ with pytest.raises(RuntimeError) as excinfo:
+ dl_mod._with_cookie_fallback(job, fn, what="probe")
+ msg = str(excinfo.value)
+ assert "JavaScript runtime" in msg
+ assert "Clearing the cookies path" in msg, "the message must name the way out"
+
+
+def test_the_real_error_survives_when_a_solver_exists(job, monkeypatch):
+ """With a runtime present, a format failure is a genuine format failure and
+ must not be rewritten into a misleading solver explanation."""
+ monkeypatch.setattr(dl_mod, "get_cookies_file", lambda: "/tmp/c.txt")
+ monkeypatch.setattr(dl_mod, "js_solver_available", lambda: True)
+ fn, _ = _recorder([BOT_CHECK, NO_FORMAT])
+ with pytest.raises(RuntimeError, match="Requested format is not available"):
+ dl_mod._with_cookie_fallback(job, fn, what="probe")
+
+
+def test_the_original_error_is_chained(job, monkeypatch):
+ """The rewritten message must not throw away the yt-dlp text underneath."""
+ monkeypatch.setattr(dl_mod, "get_cookies_file", lambda: "/tmp/c.txt")
+ monkeypatch.setattr(dl_mod, "js_solver_available", lambda: False)
+ fn, _ = _recorder([BOT_CHECK, NO_FORMAT])
+ with pytest.raises(RuntimeError) as excinfo:
+ dl_mod._with_cookie_fallback(job, fn, what="probe")
+ assert "Requested format is not available" in str(excinfo.value.__cause__)
+
+
+@pytest.mark.parametrize(
+ "text",
+ [
+ "ERROR: [youtube] abc: Sign in to confirm you're not a bot.",
+ "HTTP Error 429: Too Many Requests",
+ "ERROR: too many requests, try again later",
+ ],
+)
+def test_bot_check_detection(text):
+ assert dl_mod._is_bot_check(RuntimeError(text))
+
+
+def test_bot_check_detection_is_not_greedy():
+ assert not dl_mod._is_bot_check(RuntimeError("Video unavailable"))
+ assert not dl_mod._is_bot_check(RuntimeError("HTTP Error 404: Not Found"))
diff --git a/tests/test_download_opts.py b/tests/test_download_opts.py
index 4ce82c49..0fb41328 100644
--- a/tests/test_download_opts.py
+++ b/tests/test_download_opts.py
@@ -81,12 +81,14 @@ def test_socket_timeout_is_set_everywhere(tmp_path):
assert o.get("socket_timeout") == dl_mod._SOCKET_TIMEOUT_SEC
-def test_cookies_reach_every_call_site(tmp_path, monkeypatch):
- """The bot check hits the probe first, so a cookie file that only reached
- the audio fetch would never be used (#432)."""
+def test_cookies_are_not_used_when_nothing_has_failed(tmp_path, monkeypatch):
+ """The whole point of the fallback design (#432): a configured cookie file
+ must not touch a request that was going to succeed. Sending cookies makes
+ yt-dlp drop every client that does not support them, which is exactly the
+ unauthenticated fallback carrying most imports today."""
monkeypatch.setattr(dl_mod, "get_cookies_file", lambda: "/tmp/cookies.txt")
for o in _all_call_sites(tmp_path):
- assert o.get("cookiefile") == "/tmp/cookies.txt"
+ assert "cookiefile" not in o
def test_no_cookie_key_when_unset(tmp_path):
From f3b79c78b22c66d49dbb7db155da502480d4065b Mon Sep 17 00:00:00 2001
From: Thales <>
Date: Tue, 25 Aug 2026 10:49:03 +0100
Subject: [PATCH 5/6] Tell the user when the MP4 export went missing (#436)
Both video paths are best-effort by design, and they should stay that way:
a track that separated fine must not fail because a video stream could not
be had. But collapsing every outcome into has_video = False meant a user who
imported a track specifically to export a karaoke video could not tell 'this
never had video' from 'the video fetch broke', and nothing surfaced either.
The job just completed, with the MP4 option quietly absent.
video_status now records which it was: ok, unavailable when the source
genuinely offers no video stream, failed when the fetch or extract errored.
None stays the default for SoundCloud and non-mp4 uploads, which never try.
Only 'failed' is shown. An absent MP4 button on an audio-only source is
normal and not worth a message; a broken one nobody mentioned is the bug.
The local .mp4 path gained the same distinction, and a try/except it did not
have: a missing or timed-out ffmpeg used to surface as 'source has no video
stream', which sends the user looking at their file instead of their
install.
---
app/core/models.py | 10 +++
app/pipeline/download.py | 7 ++
app/pipeline/runner.py | 13 +++-
static/css/daw.css | 10 +++
static/index.html | 1 +
static/js/catalog.js | 3 +-
static/js/i18n.js | 8 +++
static/js/player.js | 8 ++-
tests/test_video_status.py | 134 +++++++++++++++++++++++++++++++++++++
9 files changed, 190 insertions(+), 4 deletions(-)
create mode 100644 tests/test_video_status.py
diff --git a/app/core/models.py b/app/core/models.py
index 6d5d6bcf..77fefde8 100644
--- a/app/core/models.py
+++ b/app/core/models.py
@@ -61,6 +61,15 @@ class Job:
# True when a silent video track (video.mp4) was preserved from an .mp4
# upload, enabling the "Export Mix (with video)" MP4 export.
has_video: bool = False
+ # Why has_video is what it is (#436). None when video was never attempted
+ # (SoundCloud, a non-mp4 upload); "ok" when a track was preserved;
+ # "unavailable" when the source simply offers no video stream; "failed"
+ # when the fetch or extract errored.
+ #
+ # has_video alone collapses the last two into the same silent absence, so a
+ # user who imported a track specifically to export a karaoke video could not
+ # tell "this never had video" from "the video fetch broke".
+ video_status: str | None = None
error: str | None = None
# Classified failure cause + last stderr line (e.g. "out-of-memory — ...").
# Shown by the UI as a secondary line under the generic error message so
@@ -127,6 +136,7 @@ def to_state(self) -> dict[str, Any]:
"mix_url": self.mix_url,
"source_url": self.source_url,
"has_video": self.has_video,
+ "video_status": self.video_status,
"error": self.error,
"error_detail": self.error_detail,
"compute_device": self.compute_device,
diff --git a/app/pipeline/download.py b/app/pipeline/download.py
index 046ed39b..12afb24f 100644
--- a/app/pipeline/download.py
+++ b/app/pipeline/download.py
@@ -468,6 +468,10 @@ def vhook(d: dict) -> None:
}
_set(job, stage="Fetching video...")
+ # Distinguishes "this video has no MP4 stream to offer" from "the fetch
+ # broke", which has_video alone cannot (#436). Only the second is worth
+ # telling the user about.
+ failed = False
try:
with YoutubeDL(ydl_opts) as ydl:
ydl.extract_info(url, download=True)
@@ -476,12 +480,15 @@ def vhook(d: dict) -> None:
except Exception as exc:
if job.cancel_requested:
raise JobCancelled() from exc
+ failed = True
logger.warning("[%s] video track unavailable (audio-only): %s", job.id, exc)
video = job_dir / "video.mp4"
if video.is_file() and video.stat().st_size > 0:
job.has_video = True
+ job.video_status = "ok"
else:
+ job.video_status = "failed" if failed else "unavailable"
# Drop any partial/non-mp4 leftover so the export endpoint sees nothing.
for f in job_dir.glob("video.*"):
f.unlink(missing_ok=True)
diff --git a/app/pipeline/runner.py b/app/pipeline/runner.py
index 8293d309..ccd63d02 100644
--- a/app/pipeline/runner.py
+++ b/app/pipeline/runner.py
@@ -74,12 +74,22 @@ def _extract_video_track(job: Job, source: Path, job_dir: Path) -> None:
"-y",
str(dest),
]
- result = subprocess.run(cmd, capture_output=True, timeout=TIMEOUT_FFMPEG)
+ try:
+ result = subprocess.run(cmd, capture_output=True, timeout=TIMEOUT_FFMPEG)
+ except (OSError, subprocess.SubprocessError) as e:
+ # ffmpeg missing or timed out. Distinct from an .mp4 that simply has no
+ # video stream, and the only one of the two worth surfacing (#436).
+ dest.unlink(missing_ok=True)
+ job.video_status = "failed"
+ logger.warning("video extract failed for job %s: %s", job.id, e)
+ return
if result.returncode != 0 or not dest.is_file() or dest.stat().st_size == 0:
dest.unlink(missing_ok=True)
+ job.video_status = "unavailable"
logger.info("no video track preserved for job %s (source has no video stream?)", job.id)
return
job.has_video = True
+ job.video_status = "ok"
def _prepare_local_source(job: Job, source: Path, job_dir: Path) -> Path:
@@ -238,6 +248,7 @@ def _write_metadata(job: Job, job_dir: Path) -> None:
"stem_presence": job.stem_presence,
"tags": job.tags,
"has_video": job.has_video,
+ "video_status": job.video_status,
"compute_device": job.compute_device,
"gpu_fallback": job.gpu_fallback,
"stage_timings": job.stage_timings,
diff --git a/static/css/daw.css b/static/css/daw.css
index 74d82e49..83c8179e 100644
--- a/static/css/daw.css
+++ b/static/css/daw.css
@@ -2615,6 +2615,16 @@ input, textarea { font-family: inherit; }
.export-fmt-video { display: none; }
#footer-export-wrap.has-video .export-fmt-video { display: block; flex: 1; }
+/* Shown only when the video fetch actually failed, never when the source
+ simply has no video stream. An absent MP4 button is normal; a broken one
+ the user was never told about is not (#436). */
+.export-video-failed { display: none; }
+#footer-export-wrap.video-failed .export-video-failed {
+ display: block;
+ margin: 2px 4px 4px; padding: 5px 6px;
+ font-size: 11px; line-height: 1.4; color: var(--muted);
+}
+
/* In MP4 mode only "Export Mix" applies — the audio-only Stems/Region rows
are hidden. main.js toggles .fmt-mp4 on the panel. */
#t-export-panel.fmt-mp4 #t-export-stems,
diff --git a/static/index.html b/static/index.html
index 9bcf43a2..e4d1e715 100644
--- a/static/index.html
+++ b/static/index.html
@@ -633,6 +633,7 @@
+
MP4 export is unavailable: the video could not be fetched for this track. The audio stems are unaffected.