8f10353f0c
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
458 lines
14 KiB
Python
458 lines
14 KiB
Python
from __future__ import annotations
|
|
|
|
import json
|
|
|
|
import pytest
|
|
from fastapi.testclient import TestClient
|
|
|
|
from app.core.models import Job
|
|
from app.core.registry import _jobs
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _isolate_registry():
|
|
_jobs.clear()
|
|
yield
|
|
_jobs.clear()
|
|
|
|
|
|
@pytest.fixture
|
|
def client(tmp_path, monkeypatch):
|
|
import app.api.stems as stems_mod
|
|
|
|
monkeypatch.setattr(stems_mod, "JOBS_DIR", tmp_path)
|
|
from app.main import app
|
|
|
|
return TestClient(app)
|
|
|
|
|
|
def _make_stem_file(tmp_path, job_id: str, name: str, contents: bytes = b"RIFF"):
|
|
stems_dir = tmp_path / job_id / "stems"
|
|
stems_dir.mkdir(parents=True, exist_ok=True)
|
|
path = stems_dir / f"{name}.wav"
|
|
path.write_bytes(contents)
|
|
return path
|
|
|
|
|
|
def test_rejects_malformed_job_id(client):
|
|
for bad_id in ("../etc", "ABC", "abcdefabcdef0", "abcdefabcde", "abcd-efabcdef"):
|
|
r = client.get(f"/api/jobs/{bad_id}/stems/vocals.wav")
|
|
assert r.status_code == 404, f"id {bad_id!r} should 404"
|
|
|
|
|
|
def test_rejects_unknown_stem_name(client):
|
|
job = Job(id="abcdefabcdef")
|
|
job.status = "done"
|
|
_jobs[job.id] = job
|
|
r = client.get(f"/api/jobs/{job.id}/stems/banjo.wav")
|
|
assert r.status_code == 404
|
|
|
|
|
|
def test_requires_done_status(client, tmp_path):
|
|
job = Job(id="abcdefabcdef")
|
|
job.status = "separating"
|
|
_jobs[job.id] = job
|
|
_make_stem_file(tmp_path, job.id, "vocals")
|
|
r = client.get(f"/api/jobs/{job.id}/stems/vocals.wav")
|
|
assert r.status_code == 404
|
|
|
|
|
|
def test_serves_done_job_stem(client, tmp_path):
|
|
job = Job(id="abcdefabcdee")
|
|
job.status = "done"
|
|
_jobs[job.id] = job
|
|
_make_stem_file(tmp_path, job.id, "vocals", b"RIFF1234")
|
|
r = client.get(f"/api/jobs/{job.id}/stems/vocals.wav")
|
|
assert r.status_code == 200
|
|
assert r.content == b"RIFF1234"
|
|
assert r.headers["content-type"] == "audio/wav"
|
|
|
|
|
|
# --- peaks endpoint ---
|
|
|
|
|
|
def _make_peaks_file(tmp_path, job_id: str, data: dict) -> None:
|
|
stems_dir = tmp_path / job_id / "stems"
|
|
stems_dir.mkdir(parents=True, exist_ok=True)
|
|
(stems_dir / "peaks.json").write_text(json.dumps(data), encoding="utf-8")
|
|
|
|
|
|
def test_peaks_returns_json_for_done_job(client, tmp_path):
|
|
job = Job(id="abcdefabcdea")
|
|
job.status = "done"
|
|
_jobs[job.id] = job
|
|
payload = {"vocals": [[-0.1, 0.2], [-0.3, 0.4]], "drums": [[-0.5, 0.6]]}
|
|
_make_peaks_file(tmp_path, job.id, payload)
|
|
|
|
r = client.get(f"/api/jobs/{job.id}/stems/peaks.json")
|
|
assert r.status_code == 200
|
|
assert r.headers["content-type"] == "application/json"
|
|
assert "immutable" in r.headers.get("cache-control", "")
|
|
assert r.json() == payload
|
|
|
|
|
|
def test_peaks_404_when_file_missing(client, tmp_path):
|
|
job = Job(id="abcdefabcdeb")
|
|
job.status = "done"
|
|
_jobs[job.id] = job
|
|
# stems dir exists but no peaks.json
|
|
(tmp_path / job.id / "stems").mkdir(parents=True, exist_ok=True)
|
|
|
|
r = client.get(f"/api/jobs/{job.id}/stems/peaks.json")
|
|
assert r.status_code == 404
|
|
|
|
|
|
def test_peaks_404_for_non_done_job(client):
|
|
job = Job(id="abcdefabcdec")
|
|
job.status = "separating"
|
|
_jobs[job.id] = job
|
|
|
|
r = client.get(f"/api/jobs/{job.id}/stems/peaks.json")
|
|
assert r.status_code == 404
|
|
|
|
|
|
def test_peaks_rejects_malformed_job_id(client):
|
|
for bad_id in ("../etc", "ABC", "abcdefabcdef0", "abcdefabcde"):
|
|
r = client.get(f"/api/jobs/{bad_id}/stems/peaks.json")
|
|
assert r.status_code == 404, f"id {bad_id!r} should 404"
|
|
|
|
|
|
# ── Export All Stems (.zip) ──
|
|
|
|
|
|
def test_all_stems_zip_all_when_no_subset(client, tmp_path):
|
|
import io
|
|
import zipfile
|
|
|
|
job = Job(id="abcdefabcdab")
|
|
job.status = "done"
|
|
job.title = "My Song! (Live)"
|
|
_jobs[job.id] = job
|
|
_make_stem_file(tmp_path, job.id, "vocals", b"RIFFvocals")
|
|
_make_stem_file(tmp_path, job.id, "drums", b"RIFFdrums")
|
|
_make_stem_file(tmp_path, job.id, "bass", b"RIFFbass")
|
|
|
|
r = client.get(f"/api/jobs/{job.id}/stems/all.zip")
|
|
assert r.status_code == 200
|
|
assert r.headers["content-type"] == "application/zip"
|
|
assert "My_Song_Live_stems.zip" in r.headers["content-disposition"]
|
|
|
|
zf = zipfile.ZipFile(io.BytesIO(r.content))
|
|
assert sorted(zf.namelist()) == ["bass.wav", "drums.wav", "vocals.wav"]
|
|
assert zf.read("vocals.wav") == b"RIFFvocals"
|
|
|
|
|
|
def test_all_stems_zip_only_active_subset(client, tmp_path):
|
|
"""Only the requested (active) stems are bundled — not every stem on disk."""
|
|
import io
|
|
import zipfile
|
|
|
|
job = Job(id="abcdefabcdba")
|
|
job.status = "done"
|
|
_jobs[job.id] = job
|
|
for name in ("vocals", "drums", "bass", "guitar", "piano", "other"):
|
|
_make_stem_file(tmp_path, job.id, name, f"RIFF{name}".encode())
|
|
|
|
r = client.get(f"/api/jobs/{job.id}/stems/all.zip?stems=vocals,bass")
|
|
assert r.status_code == 200
|
|
zf = zipfile.ZipFile(io.BytesIO(r.content))
|
|
assert sorted(zf.namelist()) == ["bass.wav", "vocals.wav"]
|
|
|
|
|
|
def test_all_stems_zip_rejects_unknown_stem(client, tmp_path):
|
|
job = Job(id="abcdefabcdbb")
|
|
job.status = "done"
|
|
_jobs[job.id] = job
|
|
_make_stem_file(tmp_path, job.id, "vocals")
|
|
r = client.get(f"/api/jobs/{job.id}/stems/all.zip?stems=vocals,banjo")
|
|
assert r.status_code == 422
|
|
|
|
|
|
def test_all_stems_zip_rejects_bad_format(client, tmp_path):
|
|
job = Job(id="abcdefabcdac")
|
|
job.status = "done"
|
|
_jobs[job.id] = job
|
|
_make_stem_file(tmp_path, job.id, "vocals")
|
|
r = client.get(f"/api/jobs/{job.id}/stems/all.zip?format=ogg")
|
|
assert r.status_code == 422
|
|
|
|
|
|
def test_all_stems_zip_404_for_unknown_job(client):
|
|
r = client.get("/api/jobs/abcdefabcdad/stems/all.zip")
|
|
assert r.status_code == 404
|
|
|
|
|
|
def test_all_stems_zip_rejects_malformed_job_id(client):
|
|
for bad_id in ("../etc", "ABC", "abcdefabcdef0", "abcdefabcde"):
|
|
r = client.get(f"/api/jobs/{bad_id}/stems/all.zip")
|
|
assert r.status_code == 404, f"id {bad_id!r} should 404"
|
|
|
|
|
|
def test_all_stems_zip_404_when_no_stem_files(client, tmp_path):
|
|
job = Job(id="abcdefabcdae")
|
|
job.status = "done"
|
|
_jobs[job.id] = job
|
|
(tmp_path / job.id / "stems").mkdir(parents=True, exist_ok=True)
|
|
r = client.get(f"/api/jobs/{job.id}/stems/all.zip")
|
|
assert r.status_code == 404
|
|
|
|
|
|
def test_all_stems_zip_mp3(client, tmp_path):
|
|
"""MP3 zip transcodes via ffmpeg; skip if ffmpeg isn't available."""
|
|
import io
|
|
import shutil
|
|
import zipfile
|
|
|
|
if shutil.which("ffmpeg") is None:
|
|
import pytest
|
|
|
|
pytest.skip("ffmpeg not available")
|
|
|
|
# A real (tiny) WAV so ffmpeg can transcode it.
|
|
import struct
|
|
|
|
sr = 8000
|
|
nframes = sr // 10
|
|
data = b"\x00\x00" * nframes
|
|
hdr = b"RIFF" + struct.pack("<I", 36 + len(data)) + b"WAVE"
|
|
hdr += b"fmt " + struct.pack("<IHHIIHH", 16, 1, 1, sr, sr * 2, 2, 16)
|
|
hdr += b"data" + struct.pack("<I", len(data))
|
|
wav = hdr + data
|
|
|
|
job = Job(id="abcdefabcdaf")
|
|
job.status = "done"
|
|
job.title = "Track"
|
|
_jobs[job.id] = job
|
|
_make_stem_file(tmp_path, job.id, "vocals", wav)
|
|
|
|
r = client.get(f"/api/jobs/{job.id}/stems/all.zip?format=mp3")
|
|
assert r.status_code == 200
|
|
zf = zipfile.ZipFile(io.BytesIO(r.content))
|
|
assert zf.namelist() == ["vocals.mp3"]
|
|
assert len(zf.read("vocals.mp3")) > 0
|
|
|
|
|
|
# --- dynamic mixdown endpoint (#183) ---
|
|
|
|
|
|
def _tiny_wav(seconds: float = 0.2, sr: int = 8000) -> bytes:
|
|
"""A minimal silent PCM16 mono WAV so ffmpeg can decode/mix it."""
|
|
import struct
|
|
|
|
nframes = int(sr * seconds)
|
|
data = b"\x00\x00" * nframes
|
|
hdr = b"RIFF" + struct.pack("<I", 36 + len(data)) + b"WAVE"
|
|
hdr += b"fmt " + struct.pack("<IHHIIHH", 16, 1, 1, sr, sr * 2, 2, 16)
|
|
hdr += b"data" + struct.pack("<I", len(data))
|
|
return hdr + data
|
|
|
|
|
|
def _done_job_with_stems(tmp_path, job_id: str, names) -> Job:
|
|
job = Job(id=job_id)
|
|
job.status = "done"
|
|
job.title = "Track"
|
|
_jobs[job.id] = job
|
|
for name in names:
|
|
_make_stem_file(tmp_path, job_id, name, _tiny_wav())
|
|
return job
|
|
|
|
|
|
def test_mixdown_rejects_bad_ext(client):
|
|
r = client.get("/api/jobs/abcdef000001/mixdown.ogg?stems=vocals&gains=1")
|
|
assert r.status_code == 404
|
|
|
|
|
|
def test_mixdown_rejects_length_mismatch(client):
|
|
r = client.get("/api/jobs/abcdef000001/mixdown.wav?stems=vocals,drums&gains=1")
|
|
assert r.status_code == 422
|
|
|
|
|
|
def test_mixdown_rejects_empty(client):
|
|
r = client.get("/api/jobs/abcdef000001/mixdown.wav?stems=&gains=")
|
|
assert r.status_code == 422
|
|
|
|
|
|
def test_mixdown_rejects_bad_gain(client):
|
|
for gains in ("abc", "-1", "99"):
|
|
r = client.get(f"/api/jobs/abcdef000001/mixdown.wav?stems=vocals&gains={gains}")
|
|
assert r.status_code == 422, f"gains={gains!r} should 422"
|
|
|
|
|
|
def test_mixdown_rejects_unknown_stem(client):
|
|
# "mix" is intentionally excluded (it is the static pre-render we replace).
|
|
for stem in ("banjo", "mix"):
|
|
r = client.get(f"/api/jobs/abcdef000001/mixdown.wav?stems={stem}&gains=1")
|
|
assert r.status_code == 422, f"stem={stem!r} should 422"
|
|
|
|
|
|
def test_mixdown_rejects_bad_region(client):
|
|
r = client.get("/api/jobs/abcdef000001/mixdown.wav?stems=vocals&gains=1&start=5&end=2")
|
|
assert r.status_code == 422
|
|
|
|
|
|
def test_mixdown_rejects_malformed_job_id(client):
|
|
r = client.get("/api/jobs/ZZZ/mixdown.wav?stems=vocals&gains=1")
|
|
assert r.status_code == 404
|
|
|
|
|
|
def test_mixdown_requires_done(client, tmp_path):
|
|
job = Job(id="abcdef000002")
|
|
job.status = "separating"
|
|
_jobs[job.id] = job
|
|
_make_stem_file(tmp_path, job.id, "vocals", _tiny_wav())
|
|
r = client.get(f"/api/jobs/{job.id}/mixdown.wav?stems=vocals&gains=1")
|
|
assert r.status_code == 404
|
|
|
|
|
|
def test_mixdown_404_for_missing_stem_file(client, tmp_path):
|
|
job = Job(id="abcdef000003")
|
|
job.status = "done"
|
|
_jobs[job.id] = job # no stem files on disk
|
|
r = client.get(f"/api/jobs/{job.id}/mixdown.wav?stems=vocals&gains=1")
|
|
assert r.status_code == 404
|
|
|
|
|
|
def _skip_without_ffmpeg():
|
|
import shutil
|
|
|
|
if shutil.which("ffmpeg") is None:
|
|
import pytest
|
|
|
|
pytest.skip("ffmpeg not available")
|
|
|
|
|
|
def test_mixdown_wav_happy(client, tmp_path):
|
|
_skip_without_ffmpeg()
|
|
job = _done_job_with_stems(tmp_path, "abcdef000010", ["vocals", "drums"])
|
|
r = client.get(f"/api/jobs/{job.id}/mixdown.wav?stems=vocals,drums&gains=1.000,0.500")
|
|
assert r.status_code == 200
|
|
assert r.headers["content-type"] == "audio/wav"
|
|
assert r.content[:4] == b"RIFF"
|
|
|
|
|
|
def test_mixdown_single_lane_skips_amix(client, tmp_path):
|
|
_skip_without_ffmpeg()
|
|
job = _done_job_with_stems(tmp_path, "abcdef000011", ["bass"])
|
|
r = client.get(f"/api/jobs/{job.id}/mixdown.wav?stems=bass&gains=1.500")
|
|
assert r.status_code == 200
|
|
assert r.content[:4] == b"RIFF"
|
|
|
|
|
|
def test_mixdown_mp3_happy(client, tmp_path):
|
|
_skip_without_ffmpeg()
|
|
job = _done_job_with_stems(tmp_path, "abcdef000012", ["vocals", "bass"])
|
|
r = client.get(f"/api/jobs/{job.id}/mixdown.mp3?stems=vocals,bass&gains=1,1")
|
|
assert r.status_code == 200
|
|
assert r.headers["content-type"] == "audio/mpeg"
|
|
assert len(r.content) > 0
|
|
|
|
|
|
def test_mixdown_region_trim(client, tmp_path):
|
|
_skip_without_ffmpeg()
|
|
job = _done_job_with_stems(tmp_path, "abcdef000013", ["vocals", "drums"])
|
|
r = client.get(
|
|
f"/api/jobs/{job.id}/mixdown.wav?stems=vocals,drums&gains=1,1&start=0.05&end=0.15"
|
|
)
|
|
assert r.status_code == 200
|
|
assert r.content[:4] == b"RIFF"
|
|
|
|
|
|
def test_mixdown_flac_happy(client, tmp_path):
|
|
_skip_without_ffmpeg()
|
|
job = _done_job_with_stems(tmp_path, "abcdef000014", ["vocals", "bass"])
|
|
r = client.get(f"/api/jobs/{job.id}/mixdown.flac?stems=vocals,bass&gains=1,1")
|
|
assert r.status_code == 200
|
|
assert r.headers["content-type"] == "audio/flac"
|
|
assert r.content[:4] == b"fLaC" # FLAC stream marker
|
|
|
|
|
|
def test_mixdown_rejects_unknown_ext_still(client):
|
|
# ogg remains unsupported even after adding flac.
|
|
r = client.get("/api/jobs/abcdef000001/mixdown.ogg?stems=vocals&gains=1")
|
|
assert r.status_code == 404
|
|
|
|
|
|
def test_all_stems_zip_flac(client, tmp_path):
|
|
_skip_without_ffmpeg()
|
|
job = _done_job_with_stems(tmp_path, "abcdef000015", ["vocals"])
|
|
job.title = "Track"
|
|
import io
|
|
import zipfile
|
|
|
|
r = client.get(f"/api/jobs/{job.id}/stems/all.zip?format=flac")
|
|
assert r.status_code == 200
|
|
zf = zipfile.ZipFile(io.BytesIO(r.content))
|
|
assert zf.namelist() == ["vocals.flac"]
|
|
assert zf.read("vocals.flac")[:4] == b"fLaC"
|
|
|
|
|
|
# --- MP4 video mux endpoint (#219) ---
|
|
|
|
|
|
def _make_video_file(tmp_path, job_id: str) -> None:
|
|
"""Generate a tiny real MP4 with a video stream at <job>/video.mp4 so the
|
|
mux endpoint has something to stream-copy. Requires ffmpeg."""
|
|
import subprocess
|
|
|
|
job_dir = tmp_path / job_id
|
|
job_dir.mkdir(parents=True, exist_ok=True)
|
|
subprocess.run(
|
|
[
|
|
"ffmpeg",
|
|
"-nostdin",
|
|
"-loglevel",
|
|
"error",
|
|
"-y",
|
|
"-f",
|
|
"lavfi",
|
|
"-i",
|
|
"color=c=black:s=64x64:d=0.3:r=10",
|
|
"-c:v",
|
|
"mpeg4",
|
|
"-an",
|
|
str(job_dir / "video.mp4"),
|
|
],
|
|
check=True,
|
|
timeout=30,
|
|
)
|
|
|
|
|
|
def test_video_404_when_no_video_track(client, tmp_path):
|
|
job = _done_job_with_stems(tmp_path, "abcdef000020", ["vocals"])
|
|
r = client.get(f"/api/jobs/{job.id}/video.mp4?stems=vocals&gains=1")
|
|
assert r.status_code == 404
|
|
|
|
|
|
def test_video_requires_done(client, tmp_path):
|
|
job = Job(id="abcdef000021")
|
|
job.status = "separating"
|
|
_jobs[job.id] = job
|
|
r = client.get(f"/api/jobs/{job.id}/video.mp4?stems=vocals&gains=1")
|
|
assert r.status_code == 404
|
|
|
|
|
|
def test_video_rejects_malformed_job_id(client):
|
|
r = client.get("/api/jobs/ZZZ/video.mp4?stems=vocals&gains=1")
|
|
assert r.status_code == 404
|
|
|
|
|
|
def test_video_rejects_bad_params(client, tmp_path):
|
|
_skip_without_ffmpeg()
|
|
job = _done_job_with_stems(tmp_path, "abcdef000022", ["vocals", "drums"])
|
|
_make_video_file(tmp_path, job.id)
|
|
# length mismatch, bad gain, unknown stem all 422 once the video track exists.
|
|
assert client.get(f"/api/jobs/{job.id}/video.mp4?stems=vocals,drums&gains=1").status_code == 422
|
|
assert client.get(f"/api/jobs/{job.id}/video.mp4?stems=vocals&gains=99").status_code == 422
|
|
assert client.get(f"/api/jobs/{job.id}/video.mp4?stems=mix&gains=1").status_code == 422
|
|
|
|
|
|
def test_video_mux_happy(client, tmp_path):
|
|
_skip_without_ffmpeg()
|
|
job = _done_job_with_stems(tmp_path, "abcdef000023", ["vocals", "drums"])
|
|
_make_video_file(tmp_path, job.id)
|
|
r = client.get(f"/api/jobs/{job.id}/video.mp4?stems=vocals,drums&gains=1.0,0.5")
|
|
assert r.status_code == 200
|
|
assert r.headers["content-type"] == "video/mp4"
|
|
# ISO-BMFF: bytes 4-8 of the first box are the "ftyp" type.
|
|
assert r.content[4:8] == b"ftyp"
|