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
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ htmlcov/
# Runtime job artifacts and local build scratch
data/
jobs/
settings.json
.run/
.build

Expand Down Expand Up @@ -77,3 +78,6 @@ Thumbs.db

# Tool versions
.python-version

# Imported design references (kept local, not shipped)
design/
17 changes: 5 additions & 12 deletions app/api/jobs.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,21 +12,15 @@
from fastapi import APIRouter, HTTPException, Request
from pydantic import BaseModel, field_validator

from app.core.config import (
JOB_ID_RE,
JOBS_DIR,
MAX_DURATION_SEC,
MAX_PENDING_JOBS,
STEM_NAMES,
ffprobe_executable,
)
from app.core.config import JOB_ID_RE, JOBS_DIR, MAX_PENDING_JOBS, STEM_NAMES, ffprobe_executable
from app.core.models import Job
from app.core.registry import all_jobs as registry_all_jobs
from app.core.registry import get as registry_get
from app.core.registry import get_proc as registry_get_proc
from app.core.registry import persist as registry_persist
from app.core.registry import register_if_capacity as registry_register_if_capacity
from app.core.registry import remove as registry_remove
from app.core.settings import get_max_duration_sec
from app.pipeline import run_local_pipeline, run_pipeline
from app.pipeline.download import InvalidYouTubeURL, validate_youtube_url

Expand Down Expand Up @@ -213,12 +207,11 @@ async def _create_local_job(request: Request) -> dict[str, str]:
except Exception as e:
raise HTTPException(status_code=422, detail=f"Could not read file duration: {e}") from e

if duration > MAX_DURATION_SEC:
max_duration = get_max_duration_sec()
if duration > max_duration:
raise HTTPException(
status_code=422,
detail=(
f"File is {int(duration // 60)} min — limit is {MAX_DURATION_SEC // 60} min"
),
detail=(f"File is {int(duration // 60)} min — limit is {max_duration // 60} min"),
)
except HTTPException:
shutil.rmtree(job_dir, ignore_errors=True)
Expand Down
65 changes: 63 additions & 2 deletions app/api/stems.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
import re
import subprocess
import tempfile
import uuid
import zipfile
from pathlib import Path

Expand Down Expand Up @@ -99,6 +100,50 @@ async def _stream_ffmpeg(cmd: list[str]):
await proc.wait()


async def _ensure_cached_mp3(src: Path) -> Path:
"""Transcode `src` (a stem WAV) to a sibling `<name>.mp3`, cached on disk.
Re-encoding a full song on every request is the slow part of loading a track
on mobile (≈3s/stem × 6 in parallel); caching makes repeat loads instant.
Written atomically (temp + rename) so concurrent fetches can't serve a
partial file."""
dest = src.with_suffix(".mp3")
if dest.is_file() and dest.stat().st_mtime >= src.stat().st_mtime:
return dest
tmp = dest.with_name(f".{dest.name}.{uuid.uuid4().hex}.tmp")
cmd = [
ffmpeg_executable(),
"-nostdin",
"-loglevel",
"error",
"-y",
"-i",
str(src),
"-q:a",
"2", # VBR ~190 kbps
"-f",
"mp3",
str(tmp),
]
proc = await asyncio.create_subprocess_exec(
*cmd, stdout=asyncio.subprocess.DEVNULL, stderr=asyncio.subprocess.PIPE
)
try:
_, stderr = await asyncio.wait_for(proc.communicate(), timeout=TIMEOUT_FFMPEG)
except (TimeoutError, asyncio.TimeoutError):
proc.kill()
await proc.wait()
tmp.unlink(missing_ok=True)
raise HTTPException(status_code=504, detail="mp3 transcode timed out") from None
if proc.returncode != 0:
tmp.unlink(missing_ok=True)
logger.warning(
"mp3 transcode failed for %s: %s", src.name, (stderr or b"").decode("utf-8", "replace")
)
raise HTTPException(status_code=500, detail="mp3 transcode failed")
os.replace(tmp, dest)
return dest


@router.get("/jobs/{job_id}/stems/peaks.json")
async def get_stem_peaks(job_id: str) -> Response:
"""Return pre-computed waveform peaks for all stems."""
Expand Down Expand Up @@ -166,8 +211,9 @@ async def get_stem_mp3(
name: str,
start: float | None = Query(default=None, ge=0, description="Trim start in seconds"),
end: float | None = Query(default=None, gt=0, description="Trim end in seconds"),
) -> StreamingResponse:
"""Stream a stem as MP3 (VBR ~190 kbps). Optional ?start=&end= trims to a time region."""
) -> Response:
"""Stem as MP3 (VBR ~190 kbps). Full stems are cached to disk; ?start=&end=
streams a freshly-trimmed region (uncached)."""
path = _validate_stem_path(job_id, name)

if (start is None) != (end is None) or (start is not None and start >= end):
Expand All @@ -176,6 +222,21 @@ async def get_stem_mp3(
detail="start and end are both required and start must be less than end",
)

# Full-stem requests (no trim) are cached to disk so repeat loads — the
# common case for the mobile player — are instant instead of re-encoding.
if start is None:
cached = await _ensure_cached_mp3(path)
return FileResponse(
cached,
media_type="audio/mpeg",
headers={
"Content-Disposition": f'attachment; filename="{name}.mp3"',
# Stems are immutable once a job is done — let the phone cache
# them so a re-load is instant and offline-friendly.
"Cache-Control": "public, max-age=31536000, immutable",
},
)

pre_seek = ["-ss", str(start)] if start is not None else []
post_seek = ["-t", str(end - start)] if start is not None else []

Expand Down
118 changes: 118 additions & 0 deletions app/core/settings.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
"""Runtime, user-toggleable settings (persisted to disk).

These are read live (unlike the env-var constants in config.py, which are fixed
at startup), so the Settings UI can change them without a restart:

- `allow_network` — whether StemDeck answers requests from other devices.
- `max_duration_sec` — longest track accepted for processing.
- `video_max_height` — max video resolution for MP4 export / YouTube pulls.

Defaults fall back to the config.py constants (which honor their env vars), so
nothing changes until the user overrides a value.
"""

from __future__ import annotations

import json
import logging
import os
import threading

from app.core.config import DATA_DIR, MAX_DURATION_SEC, VIDEO_MAX_HEIGHT

_log = logging.getLogger("stemdeck.settings")

_SETTINGS_PATH = DATA_DIR / "settings.json"
_LOCK = threading.RLock()
_state: dict | None = None # whole settings dict, loaded lazily

# Clamp bounds. Max track length is capped at 20 min (the product ceiling).
_DURATION_MIN, _DURATION_MAX = 60, 1200 # 1 min .. 20 min
_HEIGHT_MIN, _HEIGHT_MAX = 144, 2160


def _default_allow_network() -> bool:
# Off by default everywhere — the user explicitly opts other devices in.
# STEMDECK_ALLOW_NETWORK=1 can pre-enable it (e.g. headless/Docker deploys).
env = os.environ.get("STEMDECK_ALLOW_NETWORK")
if env is not None:
return env.strip() == "1"
return False


def _load() -> dict:
try:
data = json.loads(_SETTINGS_PATH.read_text(encoding="utf-8"))
if isinstance(data, dict):
return data
except FileNotFoundError:
pass # no settings file yet — first run; use defaults
except Exception:
Comment thread
github-code-quality[bot] marked this conversation as resolved.
Fixed
# Corrupt/unreadable file: fall back to defaults rather than crash.
_log.warning("could not read settings from %s", _SETTINGS_PATH, exc_info=True)
return {}


def _ensure() -> dict:
global _state
if _state is None:
_state = _load()
return _state


def _save() -> None:
try:
_SETTINGS_PATH.parent.mkdir(parents=True, exist_ok=True)
_SETTINGS_PATH.write_text(json.dumps(_ensure()), encoding="utf-8")
except Exception:
Comment thread
github-code-quality[bot] marked this conversation as resolved.
Fixed
# Persistence is best-effort (read-only FS, permissions): the in-memory
# value still applies for this session, so don't fail the request.
_log.warning("could not persist settings to %s", _SETTINGS_PATH, exc_info=True)


def _num(v: object) -> int | None:
return int(v) if isinstance(v, (int, float)) and not isinstance(v, bool) else None


# ── allow_network ──
def get_allow_network() -> bool:
with _LOCK:
v = _ensure().get("allow_network")
return v if isinstance(v, bool) else _default_allow_network()


def set_allow_network(value: bool) -> bool:
with _LOCK:
_ensure()["allow_network"] = bool(value)
_save()
return bool(value)


# ── max_duration_sec ──
def get_max_duration_sec() -> int:
with _LOCK:
v = _num(_ensure().get("max_duration_sec"))
return max(_DURATION_MIN, min(_DURATION_MAX, v)) if v is not None else MAX_DURATION_SEC


def set_max_duration_sec(value: int) -> int:
with _LOCK:
clamped = max(_DURATION_MIN, min(_DURATION_MAX, int(value)))
_ensure()["max_duration_sec"] = clamped
_save()
return clamped


# ── video_max_height ──
def get_video_max_height() -> int:
with _LOCK:
v = _num(_ensure().get("video_max_height"))
return max(_HEIGHT_MIN, min(_HEIGHT_MAX, v)) if v is not None else VIDEO_MAX_HEIGHT


def set_video_max_height(value: int) -> int:
with _LOCK:
clamped = max(_HEIGHT_MIN, min(_HEIGHT_MAX, int(value)))
_ensure()["video_max_height"] = clamped
_save()
return clamped
Loading