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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 45 additions & 0 deletions app/core/config.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import json
import os
import re
import shutil
import sys
from pathlib import Path

Expand Down Expand Up @@ -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
Expand Down
10 changes: 10 additions & 0 deletions app/core/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down
48 changes: 48 additions & 0 deletions app/core/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down
13 changes: 13 additions & 0 deletions app/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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"]))
Expand Down
Loading
Loading