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
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:
@@ -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)}
|
||||
@@ -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
@@ -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"}
|
||||
@@ -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"},
|
||||
)
|
||||
@@ -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"])
|
||||
@@ -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)),
|
||||
)
|
||||
Reference in New Issue
Block a user