chore: import upstream snapshot with attribution
CI / lint (push) Has been cancelled
CI / js-syntax (push) Successful in 10m24s
CI / deps-audit (push) Has been cancelled
CI / test (push) Failing after 24m55s
CI / sast-bandit (push) Failing after 11m13s
CI / trivy (push) Failing after 9m32s
Docker Publish / build-and-push (push) Failing after 34m3s

This commit is contained in:
wehub-resource-sync
2026-07-13 12:32:09 +08:00
commit 8f10353f0c
135 changed files with 37786 additions and 0 deletions
View File
+12
View File
@@ -0,0 +1,12 @@
from __future__ import annotations
from fastapi import APIRouter
from app.core.config import STEM_NAMES
router = APIRouter()
@router.get("/config")
def get_config() -> dict:
return {"stem_names": list(STEM_NAMES)}
+68
View File
@@ -0,0 +1,68 @@
from __future__ import annotations
import asyncio
import json
from collections.abc import AsyncIterator
from fastapi import APIRouter, HTTPException
from fastapi.responses import StreamingResponse
from app.core.config import JOB_ID_RE
from app.core.registry import get as registry_get
router = APIRouter(tags=["events"])
# Close SSE connections that outlive this threshold to prevent zombie
# connections from accumulating when clients disconnect without a TCP RST.
_MAX_SSE_SECONDS = 4 * 3600 # 4 hours
# Hard cap on concurrent SSE connections to prevent resource exhaustion
# from tab leaks or aggressive reconnect loops.
_MAX_SSE_CONNECTIONS = 200
# Counter is only mutated from the async event loop (no awaits between
# check+increment), so no lock is needed.
_sse_active = 0
@router.get("/jobs/{job_id}/events")
async def job_events(job_id: str) -> StreamingResponse:
"""Server-Sent Events stream of job state updates. Closes when the job
reaches a terminal status (done, error, cancelled) or after 4 hours."""
global _sse_active
if not JOB_ID_RE.match(job_id):
raise HTTPException(status_code=404, detail="job not found")
job = registry_get(job_id)
if job is None:
raise HTTPException(status_code=404, detail="job not found")
if _sse_active >= _MAX_SSE_CONNECTIONS:
raise HTTPException(status_code=503, detail="too many concurrent streams")
_sse_active += 1
async def stream() -> AsyncIterator[str]:
global _sse_active
try:
last = None
keepalive_at = 0
loop = asyncio.get_running_loop()
deadline = loop.time() + _MAX_SSE_SECONDS
while loop.time() < deadline:
snapshot = job.to_state()
serialized = json.dumps(snapshot)
if serialized != last:
yield f"data: {serialized}\n\n"
last = serialized
keepalive_at = 0
if snapshot["status"] in ("done", "error", "cancelled"):
return
keepalive_at += 1
if keepalive_at >= 75: # ~15s
yield ": keepalive\n\n"
keepalive_at = 0
await asyncio.sleep(0.2)
finally:
_sse_active -= 1
return StreamingResponse(
stream(),
media_type="text/event-stream",
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
)
+361
View File
@@ -0,0 +1,361 @@
from __future__ import annotations
import asyncio
import json
import logging
import re
import shutil
import subprocess
import uuid
from pathlib import Path
from fastapi import APIRouter, HTTPException, Request
from pydantic import BaseModel, field_validator
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
router = APIRouter(tags=["jobs"])
logger = logging.getLogger("stemdeck.api")
_ALLOWED_EXTS = frozenset((".mp3", ".wav", ".flac", ".mp4", ".m4a"))
_MAX_UPLOAD_BYTES = 400 * 1024 * 1024 # 400 MB
_WS_RE = re.compile(r"\s+")
def _sanitize_title(filename: str) -> str:
"""Strip extension, normalize whitespace, cap at 120 chars."""
stem = Path(filename).stem
return _WS_RE.sub(" ", stem).strip()[:120]
def _probe_duration(path: Path) -> float:
"""Run ffprobe to get file duration in seconds."""
result = subprocess.run(
[
ffprobe_executable(),
"-v",
"quiet",
"-show_entries",
"format=duration",
"-of",
"default=noprint_wrappers=1:nokey=1",
str(path),
],
capture_output=True,
text=True,
timeout=30,
)
if result.returncode != 0:
raise RuntimeError(f"ffprobe failed: {result.stderr.strip()}")
try:
return float(result.stdout.strip())
except ValueError as e:
raise RuntimeError(f"ffprobe returned non-numeric duration: {result.stdout!r}") from e
def _check_file_size(file_obj: object) -> int:
"""Seek to end, return size, rewind. Operates on the SpooledTemporaryFile
backing a starlette UploadFile — synchronous, suitable for to_thread."""
file_obj.seek(0, 2) # type: ignore[union-attr]
size = file_obj.tell() # type: ignore[union-attr]
file_obj.seek(0) # type: ignore[union-attr]
return size
def _copy_to_dest(src_file: object, dest: Path) -> None:
"""Copy SpooledTemporaryFile contents to dest. Synchronous, run in thread."""
with dest.open("wb") as out:
shutil.copyfileobj(src_file, out) # type: ignore[arg-type]
def _rmtree_job(job_id: str) -> None:
job_dir = JOBS_DIR / job_id
if not job_dir.is_dir():
return
try:
shutil.rmtree(job_dir)
except Exception:
logger.warning("failed to remove job dir %s", job_dir, exc_info=True)
def _task_error_cb(task: asyncio.Task) -> None:
if task.cancelled():
return
exc = task.exception()
if exc is not None:
logger.error("pipeline task raised unhandled exception", exc_info=exc)
class JobRequest(BaseModel):
url: str
# Subset of stems to include in the post-processing "selected mix"
# audio file. None = all 6 (no extra mix produced; would equal the
# original). Unknown stem names are dropped silently rather than
# rejected, so a future model with extra stems doesn't break older
# clients pinning the old set.
stems: list[str] | None = None
@router.post("")
async def create_job(request: Request) -> dict[str, str]:
"""Submit a YouTube URL (JSON body) or upload an audio file (multipart/form-data)
to start a stem-separation job. Returns the new job ID."""
ct = request.headers.get("content-type", "")
if "multipart/form-data" in ct:
return await _create_local_job(request)
return await _create_youtube_job(request)
async def _create_youtube_job(request: Request) -> dict[str, str]:
try:
body = await request.json()
except Exception as e:
raise HTTPException(status_code=422, detail=f"Invalid JSON: {e}") from e
try:
payload = JobRequest(**body)
except Exception as e:
raise HTTPException(status_code=422, detail=str(e)) from e
try:
url = validate_youtube_url(payload.url)
except InvalidYouTubeURL as e:
raise HTTPException(status_code=422, detail=str(e)) from e
selected = [s for s in payload.stems if s in STEM_NAMES] if payload.stems else list(STEM_NAMES)
if not selected:
selected = list(STEM_NAMES)
job = Job(id=uuid.uuid4().hex[:12], selected_stems=selected, source_url=url)
if not registry_register_if_capacity(job, MAX_PENDING_JOBS):
raise HTTPException(status_code=503, detail="Server busy, please try again later")
task = asyncio.create_task(run_pipeline(job, url, JOBS_DIR))
task.add_done_callback(_task_error_cb)
return {"job_id": job.id}
async def _create_local_job(request: Request) -> dict[str, str]:
# Fast pre-check: if already at capacity, reject before touching disk.
# The real atomic check happens in register_if_capacity after the upload.
if sum(1 for j in registry_all_jobs().values() if j.status == "queued") >= MAX_PENDING_JOBS:
raise HTTPException(status_code=503, detail="Server busy, please try again later")
# Quick pre-check on Content-Length to fail fast for obviously oversized
# uploads without buffering the whole body first.
cl_header = request.headers.get("content-length")
if cl_header:
try:
if int(cl_header) > _MAX_UPLOAD_BYTES + 4096:
raise HTTPException(status_code=422, detail="File exceeds 400 MB limit")
except ValueError:
pass
form = await request.form()
upload = form.get("file")
stems_raw = form.get("stems", "[]")
if upload is None or not hasattr(upload, "filename"):
raise HTTPException(status_code=422, detail="No file provided")
filename: str = getattr(upload, "filename", "") or ""
ext = Path(filename).suffix.lower()
if ext not in _ALLOWED_EXTS:
raise HTTPException(
status_code=422,
detail=f"Unsupported file type '{ext}': accepted formats are .mp3, .wav, .flac, .mp4, and .m4a",
)
# Validate stems list from form field
try:
stems_list = json.loads(stems_raw)
if not isinstance(stems_list, list):
raise ValueError
except (json.JSONDecodeError, ValueError):
stems_list = []
selected = [s for s in stems_list if s in STEM_NAMES] or list(STEM_NAMES)
# Check actual file size (SpooledTemporaryFile is already buffered at this
# point; seek/tell are fast and don't re-read the body).
file_obj = upload.file # type: ignore[union-attr]
file_size = await asyncio.to_thread(_check_file_size, file_obj)
if file_size == 0:
raise HTTPException(status_code=422, detail="Uploaded file is empty")
if file_size > _MAX_UPLOAD_BYTES:
raise HTTPException(status_code=422, detail="File exceeds 400 MB limit")
job_id = uuid.uuid4().hex[:12]
job_dir = JOBS_DIR / job_id
source_path = job_dir / f"source{ext}"
job_dir.mkdir(parents=True, exist_ok=True)
try:
await asyncio.to_thread(_copy_to_dest, file_obj, source_path)
# Duration check before registering the job so a violation leaves no
# registered job and no leftover directory.
try:
duration = await asyncio.to_thread(_probe_duration, source_path)
except Exception as e:
raise HTTPException(status_code=422, detail=f"Could not read file duration: {e}") from e
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 // 60} min"),
)
except HTTPException:
shutil.rmtree(job_dir, ignore_errors=True)
raise
title = _sanitize_title(filename)
local_source_url = f"local:{title}"
job = Job(
id=job_id,
selected_stems=selected,
title=title,
duration_sec=duration,
source_url=local_source_url,
)
if not registry_register_if_capacity(job, MAX_PENDING_JOBS):
shutil.rmtree(job_dir, ignore_errors=True)
raise HTTPException(status_code=503, detail="Server busy, please try again later")
task = asyncio.create_task(run_local_pipeline(job, source_path, JOBS_DIR))
task.add_done_callback(_task_error_cb)
return {"job_id": job.id}
@router.get("")
def list_jobs() -> list[dict]:
"""List all completed jobs in the library, sorted by creation time."""
return [
job.to_state()
for job in sorted(registry_all_jobs().values(), key=lambda j: j.created_at)
if job.status == "done"
]
@router.get("/{job_id}")
def get_job(job_id: str) -> dict:
"""Get the current state of a job by ID."""
job = registry_get(job_id)
if job is None:
raise HTTPException(status_code=404, detail="job not found")
return job.to_state()
@router.post("/{job_id}/cancel")
def cancel_job(job_id: str) -> dict:
"""Request cancellation of a running job. Idempotent for terminal jobs."""
job = registry_get(job_id)
if job is None:
raise HTTPException(status_code=404, detail="job not found")
if job.status in ("done", "error", "cancelled"):
return job.to_state()
job.cancel_requested = True
proc = registry_get_proc(job_id)
if proc is not None and proc.poll() is None:
proc.terminate()
return job.to_state()
_SECTION_ID_RE = re.compile(r"^[a-zA-Z0-9_\-]{1,64}$")
_COLOR_RE = re.compile(r"^#[0-9a-fA-F]{3,8}$")
class SectionItem(BaseModel):
id: str
name: str
start: float
end: float
color: str
@field_validator("id")
@classmethod
def _check_id(cls, v: str) -> str:
if not _SECTION_ID_RE.match(v):
raise ValueError("invalid section id")
return v
@field_validator("name")
@classmethod
def _check_name(cls, v: str) -> str:
return v.strip()[:64] or "Section"
@field_validator("color")
@classmethod
def _check_color(cls, v: str) -> str:
if not _COLOR_RE.match(v):
raise ValueError("invalid color")
return v
@field_validator("start", "end")
@classmethod
def _check_time(cls, v: float) -> float:
if not (0 <= v < 86400):
raise ValueError("time out of range")
return round(v, 3)
class SectionsBody(BaseModel):
sections: list[SectionItem]
@router.patch("/{job_id}/sections")
def update_sections(job_id: str, body: SectionsBody) -> dict:
"""Save named timeline sections (intro, verse, chorus, etc.) for a done job."""
if not JOB_ID_RE.match(job_id):
raise HTTPException(status_code=404, detail="job not found")
job = registry_get(job_id)
if job is None:
raise HTTPException(status_code=404, detail="job not found")
validated = [s.model_dump() for s in body.sections]
job.sections = validated
job_dir = (JOBS_DIR / job_id).resolve()
if not job_dir.is_relative_to(JOBS_DIR.resolve()):
raise HTTPException(status_code=404, detail="job not found")
meta_path = job_dir / "metadata.json"
meta: dict = {}
if meta_path.is_file():
try:
meta = json.loads(meta_path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
pass
meta["sections"] = validated
try:
meta_path.write_text(json.dumps(meta, indent=2) + "\n", encoding="utf-8")
except OSError as exc:
logger.exception("failed to write sections for %s: %s", job_id, exc)
raise HTTPException(status_code=500, detail="failed to save sections") from exc
registry_persist(JOBS_DIR)
return {"job_id": job_id, "sections": validated}
@router.delete("/{job_id}")
def delete_job(job_id: str) -> dict[str, str]:
"""Delete a completed or failed job and remove its stem files from disk."""
if not JOB_ID_RE.match(job_id):
raise HTTPException(status_code=404, detail="job not found")
job = registry_get(job_id)
if job is None:
raise HTTPException(status_code=404, detail="job not found")
if job.status not in ("done", "error", "cancelled"):
raise HTTPException(status_code=409, detail="job is still running")
_rmtree_job(job_id)
registry_remove(job_id)
registry_persist(JOBS_DIR)
return {"job_id": job_id, "status": "deleted"}
+25
View File
@@ -0,0 +1,25 @@
from __future__ import annotations
import io
import segno
from fastapi import APIRouter, HTTPException
from fastapi.responses import Response
router = APIRouter()
_MAX_LEN = 500
@router.get("/qr")
def get_qr(url: str) -> Response:
if not url or len(url) > _MAX_LEN:
raise HTTPException(status_code=422, detail="url required (max 500 chars)")
qr = segno.make_qr(url, error="m")
buf = io.BytesIO()
qr.save(buf, kind="svg", scale=5, border=2, dark="#1a1206", light="#ffffff")
return Response(
content=buf.getvalue(),
media_type="image/svg+xml",
headers={"Cache-Control": "public, max-age=3600"},
)
+16
View File
@@ -0,0 +1,16 @@
from __future__ import annotations
from fastapi import APIRouter
from app.api.config import router as config_router
from app.api.events import router as events_router
from app.api.jobs import router as jobs_router
from app.api.qr import router as qr_router
from app.api.stems import router as stems_router
router = APIRouter()
router.include_router(config_router, tags=["config"])
router.include_router(jobs_router, prefix="/jobs", tags=["jobs"])
router.include_router(events_router, tags=["events"])
router.include_router(stems_router, tags=["stems"])
router.include_router(qr_router, tags=["qr"])
+494
View File
@@ -0,0 +1,494 @@
from __future__ import annotations
import asyncio
import logging
import os
import re
import subprocess
import tempfile
import uuid
import zipfile
from pathlib import Path
from fastapi import APIRouter, HTTPException, Query
from fastapi.responses import FileResponse, Response, StreamingResponse
from starlette.background import BackgroundTask
from app.core.config import JOB_ID_RE, JOBS_DIR, STEM_NAMES, TIMEOUT_FFMPEG, ffmpeg_executable
from app.core.registry import get as registry_get
logger = logging.getLogger("stemdeck.api")
router = APIRouter(tags=["stems"])
# Stem files served by this endpoint: the 6 demucs stems + two
# pipeline-produced extras. "original" is the re-encoded source song
# (added when the user picked a strict subset), "mix" is the ffmpeg
# amix of the user's selected stems.
_ALLOWED_NAMES = frozenset(STEM_NAMES) | {"original", "mix"}
# Lanes the dynamic mixdown may sum: the 6 stems plus "original" (the complement
# track shown when the user picked a subset). "mix" is excluded -- it is the
# static pre-render this endpoint replaces. Gains are linear; the studio caps a
# lane at 2.0, so this generous bound just rejects abusive values.
_MIXDOWN_NAMES = frozenset(STEM_NAMES) | {"original"}
_MIXDOWN_MAX_GAIN = 4.0
# Output encoders by container/extension, shared by the dynamic mixdown and the
# stems zip. WAV is lossless PCM, FLAC is lossless compressed, MP3 is VBR ~190 kbps.
_ENCODE_ARGS = {
"wav": ["-c:a", "pcm_s16le"],
"mp3": ["-q:a", "2"],
"flac": ["-c:a", "flac"],
}
MIXDOWN_CODECS = {ext: [*args, "-f", ext] for ext, args in _ENCODE_ARGS.items()}
MIXDOWN_MEDIA_TYPES = {"wav": "audio/wav", "mp3": "audio/mpeg", "flac": "audio/flac"}
def _validate_stem_path(job_id: str, name: str):
"""Shared guard: validate job_id, name, job state, and path. Returns resolved Path."""
if not JOB_ID_RE.match(job_id):
raise HTTPException(status_code=404, detail="job not found")
if name not in _ALLOWED_NAMES:
raise HTTPException(status_code=404, detail="unknown stem")
job = registry_get(job_id)
if job is None or job.status != "done":
raise HTTPException(status_code=404, detail="job not ready")
path = (JOBS_DIR / job_id / "stems" / f"{name}.wav").resolve()
if not path.is_file() or not path.is_relative_to(JOBS_DIR.resolve()):
raise HTTPException(status_code=404, detail="stem not found")
return path
def _parse_lane_gains(stems: str, gains: str) -> tuple[list[str], list[float]]:
"""Parse and validate parallel comma-separated lane names and linear gains.
Shared by the audio mixdown and the MP4 video mux. Raises HTTPException
on malformed input, unknown lanes, or out-of-range gains."""
names = [s for s in stems.split(",") if s]
raw_gains = [g for g in gains.split(",") if g]
if not names or len(names) != len(raw_gains):
raise HTTPException(
status_code=422, detail="stems and gains must be non-empty and equal length"
)
try:
parsed_gains = [float(g) for g in raw_gains]
except ValueError:
raise HTTPException(status_code=422, detail="gains must be numbers") from None
if any(g < 0 or g > _MIXDOWN_MAX_GAIN for g in parsed_gains):
raise HTTPException(status_code=422, detail="gain out of range")
if not set(names) <= _MIXDOWN_NAMES:
raise HTTPException(status_code=422, detail="unknown stem requested")
return names, parsed_gains
async def _stream_ffmpeg(cmd: list[str]):
"""Yield ffmpeg stdout in 64 KB chunks; kill process on client disconnect."""
proc = await asyncio.create_subprocess_exec(
*cmd,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.DEVNULL,
)
try:
while True:
chunk = await proc.stdout.read(65536)
if not chunk:
break
yield chunk
finally:
if proc.returncode is None:
proc.kill()
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."""
if not JOB_ID_RE.match(job_id):
raise HTTPException(status_code=404, detail="job not found")
job = registry_get(job_id)
if job is None or job.status != "done":
raise HTTPException(status_code=404, detail="job not ready")
path = (JOBS_DIR / job_id / "stems" / "peaks.json").resolve()
if not path.is_file() or not path.is_relative_to(JOBS_DIR.resolve()):
raise HTTPException(status_code=404, detail="peaks not found")
return FileResponse(
path,
media_type="application/json",
headers={"Cache-Control": "public, max-age=31536000, immutable"},
)
@router.api_route("/jobs/{job_id}/stems/{name}.wav", methods=["GET", "HEAD"], response_model=None)
async def get_stem(
job_id: str,
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"),
) -> FileResponse | StreamingResponse:
"""Download a WAV stem. Optional ?start=&end= trims to a time region."""
path = _validate_stem_path(job_id, name)
if start is None and end is None:
return FileResponse(path, media_type="audio/wav", filename=f"{name}.wav")
if start is None or end is None or start >= end:
raise HTTPException(
status_code=422,
detail="start and end are both required and start must be less than end",
)
cmd = [
ffmpeg_executable(),
"-nostdin",
"-loglevel",
"error",
"-ss",
str(start),
"-i",
str(path),
"-t",
str(end - start),
"-c:a",
"pcm_s16le",
"-f",
"wav",
"pipe:1",
]
return StreamingResponse(
_stream_ffmpeg(cmd),
media_type="audio/wav",
headers={"Content-Disposition": f'attachment; filename="{name}_region.wav"'},
)
@router.get("/jobs/{job_id}/stems/{name}.mp3")
async def get_stem_mp3(
job_id: str,
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"),
) -> 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):
raise HTTPException(
status_code=422,
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 []
cmd = [
ffmpeg_executable(),
"-nostdin",
"-loglevel",
"error",
*pre_seek,
"-i",
str(path),
*post_seek,
"-q:a",
"2", # VBR ~190 kbps
"-f",
"mp3",
"pipe:1",
]
filename = f"{name}_region.mp3" if start is not None else f"{name}.mp3"
return StreamingResponse(
_stream_ffmpeg(cmd),
media_type="audio/mpeg",
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
)
@router.get("/jobs/{job_id}/mixdown.{ext}", response_model=None)
async def get_mixdown(
job_id: str,
ext: str,
stems: str = Query(..., description="Comma-separated lane names to sum"),
gains: str = Query(..., description="Comma-separated linear gains, parallel to stems"),
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:
"""Render a fresh mixdown of the given lanes at the given gains, streamed as
WAV or MP3. Mirrors the studio mixer (per-stem volume, mute, solo) so the
exported file matches what is heard. The master fader is intentionally not
applied -- it is a monitoring level, not part of the mix. Optional ?start=&end=
trims to a loop region."""
if ext not in ("wav", "mp3", "flac"):
raise HTTPException(status_code=404, detail="not found")
names, parsed_gains = _parse_lane_gains(stems, gains)
if (start is None) != (end is None) or (start is not None and start >= end):
raise HTTPException(
status_code=422,
detail="start and end are both required and start must be less than end",
)
# Validates job_id (404), job done (404), and path traversal (404) per stem.
paths = [_validate_stem_path(job_id, name) for name in names]
pre_seek = ["-ss", str(start)] if start is not None else []
post_seek = ["-t", str(end - start)] if start is not None else []
cmd: list[str] = [ffmpeg_executable(), "-nostdin", "-loglevel", "error"]
for p in paths:
cmd += [*pre_seek, "-i", str(p)]
# Apply each lane's gain, then sum with amix (normalize=0 keeps levels faithful,
# matching collect.py). A single audible lane skips amix (a 1-input amix is a no-op).
filters = [f"[{i}:a]volume={g:.6f}[a{i}]" for i, g in enumerate(parsed_gains)]
n = len(paths)
if n > 1:
labels = "".join(f"[a{i}]" for i in range(n))
filters.append(f"{labels}amix=inputs={n}:normalize=0[mix]")
out_label = "[mix]"
else:
out_label = "[a0]"
codec = MIXDOWN_CODECS[ext]
cmd += ["-filter_complex", ";".join(filters), "-map", out_label, *post_seek, *codec, "pipe:1"]
media_type = MIXDOWN_MEDIA_TYPES[ext]
return StreamingResponse(
_stream_ffmpeg(cmd),
media_type=media_type,
headers={"Content-Disposition": f'attachment; filename="mixdown.{ext}"'},
)
def _safe_title(title: str | None) -> str:
"""Sanitize a song title into a filename-safe slug (matches the frontend)."""
safe = re.sub(r"[^a-zA-Z0-9]+", "_", title or "")
safe = re.sub(r"_{2,}", "_", safe).strip("_")[:80].strip("_")
return safe or "stems"
@router.get("/jobs/{job_id}/video.mp4", response_model=None)
async def get_video_mixdown(
job_id: str,
stems: str = Query(..., description="Comma-separated lane names to sum"),
gains: str = Query(..., description="Comma-separated linear gains, parallel to stems"),
) -> StreamingResponse:
"""Mux a fresh audio mixdown of the current mixer state with the job's preserved
video into an MP4 (issue #219). Mirrors get_mixdown's audio graph (encoded
as AAC) and stream-copies video.mp4 -- the silent video kept from an .mp4 upload
or the real video stream downloaded for a YouTube job. 404 when the job has no
video (SoundCloud / plain audio uploads).
Streamed as fragmented MP4 (frag_keyframe+empty_moov) since the output pipe is
not seekable -- +faststart would require a seekable file. The full song is
exported; no region trim, to avoid A/V drift from stream-copy seeking."""
if not JOB_ID_RE.match(job_id):
raise HTTPException(status_code=404, detail="job not found")
job = registry_get(job_id)
if job is None or job.status != "done":
raise HTTPException(status_code=404, detail="job not ready")
video_path = (JOBS_DIR / job_id / "video.mp4").resolve()
if not video_path.is_file() or not video_path.is_relative_to(JOBS_DIR.resolve()):
raise HTTPException(status_code=404, detail="no video track for this job")
names, parsed_gains = _parse_lane_gains(stems, gains)
# Validates job_id (404), job done (404), and path traversal (404) per stem.
paths = [_validate_stem_path(job_id, name) for name in names]
cmd: list[str] = [ffmpeg_executable(), "-nostdin", "-loglevel", "error"]
for p in paths:
cmd += ["-i", str(p)]
cmd += ["-i", str(video_path)]
video_idx = len(paths)
# Per-lane gain then amix (normalize=0 keeps levels faithful). A single audible
# lane skips amix (a 1-input amix is a no-op), matching get_mixdown.
filters = [f"[{i}:a]volume={g:.6f}[a{i}]" for i, g in enumerate(parsed_gains)]
n = len(paths)
if n > 1:
labels = "".join(f"[a{i}]" for i in range(n))
filters.append(f"{labels}amix=inputs={n}:normalize=0[mix]")
out_label = "[mix]"
else:
out_label = "[a0]"
cmd += [
"-filter_complex",
";".join(filters),
"-map",
out_label,
"-map",
f"{video_idx}:v",
"-c:v",
"copy",
"-c:a",
"aac",
"-b:a",
"192k",
"-shortest",
"-movflags",
"frag_keyframe+empty_moov",
"-f",
"mp4",
"pipe:1",
]
filename = f"{_safe_title(job.title)}_video.mp4"
return StreamingResponse(
_stream_ffmpeg(cmd),
media_type="video/mp4",
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
)
def _build_stems_zip(sources: list[tuple[str, Path]], fmt: str, dest: Path) -> None:
"""Blocking: write the stems into a ZIP. WAV files are stored as-is; MP3 and
FLAC are transcoded per stem via ffmpeg. ZIP_STORED throughout - audio doesn't
meaningfully compress, and STORED keeps the build fast. Runs in a thread."""
if fmt == "wav":
with zipfile.ZipFile(dest, "w", zipfile.ZIP_STORED) as zf:
for name, p in sources:
zf.write(p, arcname=f"{name}.wav")
return
encode = _ENCODE_ARGS[fmt]
with tempfile.TemporaryDirectory() as td, zipfile.ZipFile(dest, "w", zipfile.ZIP_STORED) as zf:
for name, p in sources:
out = os.path.join(td, f"{name}.{fmt}")
cmd = [
ffmpeg_executable(),
"-nostdin",
"-loglevel",
"error",
"-i",
str(p),
*encode,
"-f",
fmt,
out,
]
proc = subprocess.run( # noqa: S603 — list args, no shell, trusted ffmpeg
cmd,
stdin=subprocess.DEVNULL,
stdout=subprocess.DEVNULL,
stderr=subprocess.PIPE,
timeout=TIMEOUT_FFMPEG,
)
if proc.returncode != 0:
tail = proc.stderr[-2000:].decode("utf-8", "replace")
raise RuntimeError(f"ffmpeg failed for {name}: {tail}")
zf.write(out, arcname=f"{name}.{fmt}")
@router.get("/jobs/{job_id}/stems/all.zip")
async def get_all_stems_zip(
job_id: str,
fmt: str = Query(default="wav", alias="format"),
stems: str | None = Query(default=None, description="Comma-separated stems; default all"),
) -> FileResponse:
"""Bundle the requested stems into a single ZIP, named after the song.
`stems` is the active subset selected in the DAW (whitelisted). When omitted,
every available stem is included."""
if not JOB_ID_RE.match(job_id):
raise HTTPException(status_code=404, detail="job not found")
if fmt not in ("wav", "mp3", "flac"):
raise HTTPException(status_code=422, detail="format must be 'wav', 'mp3', or 'flac'")
job = registry_get(job_id)
if job is None or job.status != "done":
raise HTTPException(status_code=404, detail="job not ready")
# Resolve the requested subset (whitelisted) or fall back to all stems.
if stems:
requested = {s for s in stems.split(",") if s}
if not requested <= set(STEM_NAMES):
raise HTTPException(status_code=422, detail="unknown stem requested")
wanted = [name for name in STEM_NAMES if name in requested]
else:
wanted = list(STEM_NAMES)
jobs_root = JOBS_DIR.resolve()
stems_dir = (JOBS_DIR / job_id / "stems").resolve()
if not stems_dir.is_dir() or not stems_dir.is_relative_to(jobs_root):
raise HTTPException(status_code=404, detail="stems not found")
sources: list[tuple[str, Path]] = []
for name in wanted:
p = (stems_dir / f"{name}.wav").resolve()
if p.is_file() and p.is_relative_to(jobs_root):
sources.append((name, p))
if not sources:
raise HTTPException(status_code=404, detail="no stems found")
fd, tmp = tempfile.mkstemp(prefix="stemdeck_zip_", suffix=".zip")
os.close(fd)
tmp_path = Path(tmp)
try:
await asyncio.to_thread(_build_stems_zip, sources, fmt, tmp_path)
except Exception:
tmp_path.unlink(missing_ok=True)
logger.exception("failed to build stems zip for job %s", job_id)
raise HTTPException(status_code=500, detail="failed to build archive") from None
filename = f"{_safe_title(job.title)}_stems.zip"
return FileResponse(
tmp_path,
media_type="application/zip",
filename=filename,
background=BackgroundTask(lambda: tmp_path.unlink(missing_ok=True)),
)