diff --git a/app/core/config.py b/app/core/config.py
index 07cc2d1f..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
@@ -127,6 +128,50 @@ 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
+
+
+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/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/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..12afb24f 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, js_solver_available
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")
@@ -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(
(
@@ -125,6 +189,44 @@ def _with_retries(job: Job, fn, *, what: str):
"soundcloud",
]
+
+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
+ 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)}}
+ # 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
+
+
# 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 +384,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 +392,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 {}
@@ -329,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.
@@ -356,24 +456,22 @@ 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, use_cookies=use_cookies),
"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...")
+ # 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)
@@ -382,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)
@@ -402,18 +503,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(
- {
- "quiet": True,
- "noplaylist": True,
- "allowed_extractors": _ALLOWED_EXTRACTORS,
- "socket_timeout": _SOCKET_TIMEOUT_SEC,
- }
- ) 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:
@@ -440,22 +539,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 = {
- "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:
+ 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,
@@ -476,7 +577,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/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..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,
@@ -265,12 +276,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/static/css/daw.css b/static/css/daw.css
index 5499fc77..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,
@@ -3201,6 +3211,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/index.html b/static/index.html
index edc88323..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.