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.