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
+21
View File
@@ -0,0 +1,21 @@
from __future__ import annotations
import pytest
from app.core import settings as _settings
@pytest.fixture(autouse=True)
def _isolate_network_settings(tmp_path, monkeypatch):
"""Isolate the runtime network gate for every test. Without this, a stray
settings.json in the repo (written by a local dev server) could flip the
gate off and 403 the whole suite, since TestClient's client host is not
loopback. Each test starts from the env default (on, outside desktop mode)."""
monkeypatch.setattr(_settings, "_SETTINGS_PATH", tmp_path / "settings.json")
# Network access defaults OFF in production, which would 403 TestClient
# (whose client host isn't loopback). Default the suite ON; gate tests set
# it explicitly. Tests checking the real default clear this env var.
monkeypatch.setenv("STEMDECK_ALLOW_NETWORK", "1")
_settings._state = None # force a fresh load from the isolated path
yield
_settings._state = None
+84
View File
@@ -0,0 +1,84 @@
from __future__ import annotations
import importlib
import os
from pathlib import Path
def test_data_dir_moves_default_runtime_dirs(monkeypatch, tmp_path: Path):
import app.core.config as config
original = config
data_dir = tmp_path / "portable-data"
monkeypatch.setenv("STEMDECK_DATA_DIR", str(data_dir))
monkeypatch.delenv("STEMDECK_JOBS_DIR", raising=False)
monkeypatch.delenv("STEMDECK_CACHE_DIR", raising=False)
monkeypatch.delenv("STEMDECK_DOWNLOADS_DIR", raising=False)
monkeypatch.delenv("STEMDECK_MODELS_DIR", raising=False)
monkeypatch.delenv("STEMDECK_LOGS_DIR", raising=False)
try:
reloaded = importlib.reload(config)
assert data_dir.resolve() == reloaded.DATA_DIR
assert data_dir.resolve() / "jobs" == reloaded.JOBS_DIR
assert data_dir.resolve() / "cache" == reloaded.CACHE_DIR
assert data_dir.resolve() / "downloads" == reloaded.DOWNLOADS_DIR
assert data_dir.resolve() / "models" == reloaded.MODELS_DIR
assert data_dir.resolve() / "logs" == reloaded.LOGS_DIR
finally:
monkeypatch.delenv("STEMDECK_DATA_DIR", raising=False)
importlib.reload(original)
def test_jobs_dir_override_wins_over_data_dir(monkeypatch, tmp_path: Path):
import app.core.config as config
original = config
data_dir = tmp_path / "portable-data"
jobs_dir = tmp_path / "custom-jobs"
monkeypatch.setenv("STEMDECK_DATA_DIR", str(data_dir))
monkeypatch.setenv("STEMDECK_JOBS_DIR", str(jobs_dir))
try:
reloaded = importlib.reload(config)
assert data_dir.resolve() == reloaded.DATA_DIR
assert jobs_dir.resolve() == reloaded.JOBS_DIR
finally:
monkeypatch.delenv("STEMDECK_DATA_DIR", raising=False)
monkeypatch.delenv("STEMDECK_JOBS_DIR", raising=False)
importlib.reload(original)
def test_ffmpeg_executable_prefers_portable_binary(monkeypatch, tmp_path: Path):
import app.core.config as config
original = config
ffmpeg = tmp_path / "ffmpeg" / "ffmpeg"
ffmpeg.parent.mkdir()
ffmpeg.write_text("#!/bin/sh\n", encoding="utf-8")
monkeypatch.setenv("STEMDECK_DATA_DIR", str(tmp_path))
monkeypatch.setenv("STEMDECK_FFMPEG", str(ffmpeg))
try:
reloaded = importlib.reload(config)
assert reloaded.ffmpeg_executable() == str(ffmpeg.resolve())
finally:
monkeypatch.delenv("STEMDECK_DATA_DIR", raising=False)
monkeypatch.delenv("STEMDECK_FFMPEG", raising=False)
importlib.reload(original)
def test_configure_portable_environment_leaves_dev_cache_env_alone(monkeypatch):
import app.core.config as config
original = config
monkeypatch.delenv("STEMDECK_DATA_DIR", raising=False)
monkeypatch.delenv("XDG_CACHE_HOME", raising=False)
monkeypatch.delenv("TORCH_HOME", raising=False)
try:
reloaded = importlib.reload(config)
reloaded.configure_portable_environment()
assert "XDG_CACHE_HOME" not in os.environ
assert "TORCH_HOME" not in os.environ
finally:
monkeypatch.delenv("STEMDECK_DATA_DIR", raising=False)
monkeypatch.delenv("XDG_CACHE_HOME", raising=False)
monkeypatch.delenv("TORCH_HOME", raising=False)
importlib.reload(original)
+30
View File
@@ -0,0 +1,30 @@
from __future__ import annotations
from fastapi.testclient import TestClient
from app.main import app
def _csp_directive(name: str) -> str:
"""Return the named directive from the served Content-Security-Policy header."""
with TestClient(app) as c:
resp = c.get("/")
csp = resp.headers["content-security-policy"]
return next(d.strip() for d in csp.split(";") if d.strip().startswith(name))
def test_connect_src_permits_data_and_blob():
# Regression for #186: multitrack.js fetches a data: URI while initializing
# each track's audio. Without data:/blob: in connect-src the browser blocks
# it, Multitrack.create throws, and no audio/waveform/playback loads.
connect = _csp_directive("connect-src")
assert "data:" in connect
assert "blob:" in connect
def test_script_src_stays_locked():
# Lock #171's intent: loosening connect-src must not weaken the XSS defense.
script = _csp_directive("script-src")
assert "'self'" in script
assert "unsafe-inline" not in script
assert "unsafe-eval" not in script
+19
View File
@@ -0,0 +1,19 @@
from __future__ import annotations
from fastapi.testclient import TestClient
def test_health_endpoints_report_ok():
from app.main import app
with TestClient(app) as client:
for path in ("/health", "/api/health"):
r = client.get(path)
assert r.status_code == 200
body = r.json()
assert body["name"] == "StemDeck"
assert body["status"] == "ok"
assert body["version"]
assert "ffmpeg_configured" in body
assert "jobs_dir" not in body
assert "data_dir" not in body
+271
View File
@@ -0,0 +1,271 @@
from __future__ import annotations
import io
import json
from unittest.mock import patch
import pytest
from fastapi.testclient import TestClient
from app.core.config import MAX_PENDING_JOBS
from app.core.models import Job
from app.core.registry import _jobs
@pytest.fixture(autouse=True)
def _isolate_registry():
"""Each test gets a fresh in-memory registry."""
_jobs.clear()
yield
_jobs.clear()
@pytest.fixture
def client():
async def _noop_pipeline(job, url, jobs_dir):
return None
with patch("app.api.jobs.run_pipeline", _noop_pipeline):
from app.main import app
with TestClient(app) as c:
yield c
@pytest.fixture
def upload_client(tmp_path, monkeypatch):
import app.core.config as cfg
monkeypatch.setattr(cfg, "JOBS_DIR", tmp_path)
async def _noop_local(job, source_path, jobs_dir):
return None
async def _noop_youtube(job, url, jobs_dir):
return None
with (
patch("app.api.jobs.run_local_pipeline", _noop_local),
patch("app.api.jobs.run_pipeline", _noop_youtube),
patch("app.api.jobs._probe_duration", return_value=60.0),
):
from app.main import app
with TestClient(app) as c:
yield c
def test_post_rejects_invalid_url(client):
r = client.post("/api/jobs", json={"url": "https://example.com/foo"})
assert r.status_code == 422
assert "unsupported host" in r.json()["detail"]
def test_post_rejects_empty_url(client):
r = client.post("/api/jobs", json={"url": ""})
assert r.status_code == 422
def test_post_accepts_youtube_url(client):
r = client.post("/api/jobs", json={"url": "https://youtu.be/dQw4w9WgXcQ"})
assert r.status_code == 200
assert "job_id" in r.json()
assert len(r.json()["job_id"]) == 12
def test_get_unknown_job_returns_404(client):
r = client.get("/api/jobs/000000000000")
assert r.status_code == 404
def test_cancel_unknown_job_returns_404(client):
r = client.post("/api/jobs/000000000000/cancel")
assert r.status_code == 404
def test_delete_running_job_rejected(client):
r = client.post("/api/jobs", json={"url": "https://youtu.be/dQw4w9WgXcQ"})
job_id = r.json()["job_id"]
r = client.delete(f"/api/jobs/{job_id}")
assert r.status_code == 409
def test_cancel_sets_flag_and_returns_state(client):
r = client.post("/api/jobs", json={"url": "https://youtu.be/dQw4w9WgXcQ"})
job_id = r.json()["job_id"]
r = client.post(f"/api/jobs/{job_id}/cancel")
assert r.status_code == 200
assert _jobs[job_id].cancel_requested is True
def test_cancel_after_done_is_idempotent(client):
r = client.post("/api/jobs", json={"url": "https://youtu.be/dQw4w9WgXcQ"})
job_id = r.json()["job_id"]
_jobs[job_id].status = "done"
r = client.post(f"/api/jobs/{job_id}/cancel")
assert r.status_code == 200
assert _jobs[job_id].cancel_requested is False
# ─── Capacity (503) ───────────────────────────────────────────────────────────
def test_youtube_503_when_queue_full(client):
for _ in range(MAX_PENDING_JOBS):
r = client.post("/api/jobs", json={"url": "https://youtu.be/dQw4w9WgXcQ"})
assert r.status_code == 200
r = client.post("/api/jobs", json={"url": "https://youtu.be/dQw4w9WgXcQ"})
assert r.status_code == 503
def test_upload_503_when_queue_full(upload_client):
for _ in range(MAX_PENDING_JOBS):
r = upload_client.post("/api/jobs", json={"url": "https://youtu.be/dQw4w9WgXcQ"})
assert r.status_code == 200
data = io.BytesIO(b"ID3" + b"\x00" * 128)
r = upload_client.post(
"/api/jobs",
files={"file": ("track.mp3", data, "audio/mpeg")},
)
assert r.status_code == 503
# ─── File upload ─────────────────────────────────────────────────────────────
def test_upload_rejects_unsupported_extension(upload_client):
data = io.BytesIO(b"OGG data")
r = upload_client.post(
"/api/jobs",
files={"file": ("track.ogg", data, "audio/ogg")},
)
assert r.status_code == 422
assert "Unsupported file type" in r.json()["detail"]
def test_upload_rejects_empty_file(upload_client):
r = upload_client.post(
"/api/jobs",
files={"file": ("track.wav", io.BytesIO(b""), "audio/wav")},
)
assert r.status_code == 422
assert "empty" in r.json()["detail"].lower()
def test_upload_mp3_returns_job_id(upload_client):
data = io.BytesIO(b"ID3" + b"\x00" * 128)
r = upload_client.post(
"/api/jobs",
files={"file": ("my_track.mp3", data, "audio/mpeg")},
)
assert r.status_code == 200
assert "job_id" in r.json()
assert len(r.json()["job_id"]) == 12
def test_upload_wav_returns_job_id(upload_client):
data = io.BytesIO(b"RIFF" + b"\x00" * 128)
r = upload_client.post(
"/api/jobs",
files={"file": ("my_track.wav", data, "audio/wav")},
)
assert r.status_code == 200
assert "job_id" in r.json()
def test_upload_flac_returns_job_id(upload_client):
data = io.BytesIO(b"fLaC" + b"\x00" * 128)
r = upload_client.post(
"/api/jobs",
files={"file": ("my_track.flac", data, "audio/flac")},
)
assert r.status_code == 200
assert "job_id" in r.json()
# ─── Sections endpoint ────────────────────────────────────────────────────────
@pytest.fixture
def done_job(client, tmp_path, monkeypatch):
import app.api.jobs as jobs_mod
monkeypatch.setattr(jobs_mod, "JOBS_DIR", tmp_path)
job = Job(id="abcdefabcdef")
job.status = "done"
_jobs[job.id] = job
job_dir = tmp_path / job.id
job_dir.mkdir(parents=True, exist_ok=True)
return job
def test_sections_happy_path(client, done_job, tmp_path):
payload = {
"sections": [{"id": "sec1", "name": "Verse", "start": 0.0, "end": 30.0, "color": "#ff0000"}]
}
r = client.patch(f"/api/jobs/{done_job.id}/sections", json=payload)
assert r.status_code == 200
body = r.json()
assert body["job_id"] == done_job.id
assert len(body["sections"]) == 1
assert body["sections"][0]["name"] == "Verse"
# Verify written to disk
meta_path = tmp_path / done_job.id / "metadata.json"
assert meta_path.is_file()
meta = json.loads(meta_path.read_text())
assert meta["sections"][0]["id"] == "sec1"
def test_sections_unknown_job_returns_404(client):
payload = {"sections": []}
r = client.patch("/api/jobs/000000000000/sections", json=payload)
assert r.status_code == 404
def test_sections_malformed_job_id_returns_404(client):
# Job IDs must be 12 lowercase hex chars; anything else is rejected.
r = client.patch("/api/jobs/BADID/sections", json={"sections": []})
assert r.status_code == 404
def test_sections_invalid_color_returns_422(client, done_job):
payload = {
"sections": [
{"id": "sec1", "name": "Intro", "start": 0.0, "end": 10.0, "color": "not-a-color"}
]
}
r = client.patch(f"/api/jobs/{done_job.id}/sections", json=payload)
assert r.status_code == 422
def test_sections_invalid_id_returns_422(client, done_job):
payload = {
"sections": [{"id": "has space", "name": "x", "start": 0.0, "end": 5.0, "color": "#fff"}]
}
r = client.patch(f"/api/jobs/{done_job.id}/sections", json=payload)
assert r.status_code == 422
# ─── SSE job_id validation ────────────────────────────────────────────────────
def test_sse_rejects_malformed_job_id(client):
for bad_id in ("../etc", "ABC", "abcdefabcdef0"):
r = client.get(f"/api/jobs/{bad_id}/events")
assert r.status_code == 404, f"SSE should 404 for id {bad_id!r}"
def test_sse_503_when_connection_cap_reached(client):
"""#86/#88: SSE endpoint rejects with 503 when _MAX_SSE_CONNECTIONS is reached."""
import app.api.events as events_mod
original = events_mod._sse_active
try:
events_mod._sse_active = events_mod._MAX_SSE_CONNECTIONS
job = Job(id="abcdefabcdef")
job.status = "done"
_jobs[job.id] = job
r = client.get(f"/api/jobs/{job.id}/events")
assert r.status_code == 503
finally:
events_mod._sse_active = original
+66
View File
@@ -0,0 +1,66 @@
from __future__ import annotations
import pytest
from fastapi.testclient import TestClient
from app.main import _is_mobile_ua, app
IPHONE_UA = (
"Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) "
"AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1"
)
ANDROID_UA = (
"Mozilla/5.0 (Linux; Android 14; Pixel 8) AppleWebKit/537.36 "
"(KHTML, like Gecko) Chrome/120.0.0.0 Mobile Safari/537.36"
)
DESKTOP_UA = (
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
"(KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
)
# Modern iPadOS Safari reports a Mac desktop UA — tablets fall through to the DAW.
IPAD_UA = (
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 "
"(KHTML, like Gecko) Version/17.0 Safari/605.1.15"
)
@pytest.mark.parametrize("ua", [IPHONE_UA, ANDROID_UA])
def test_is_mobile_ua_true_for_phones(ua: str):
assert _is_mobile_ua(ua) is True
@pytest.mark.parametrize("ua", [DESKTOP_UA, IPAD_UA, "", "curl/8.0"])
def test_is_mobile_ua_false_for_non_phones(ua: str):
assert _is_mobile_ua(ua) is False
def test_root_serves_mobile_shell_to_phones():
with TestClient(app) as c:
resp = c.get("/", headers={"user-agent": IPHONE_UA})
assert resp.status_code == 200
assert "/mobile/app.js" in resp.text
def test_root_serves_daw_to_desktop():
with TestClient(app) as c:
resp = c.get("/", headers={"user-agent": DESKTOP_UA})
assert resp.status_code == 200
assert "/css/daw.css" in resp.text
def test_ui_query_override_forces_mobile_on_desktop():
with TestClient(app) as c:
resp = c.get("/", params={"ui": "mobile"}, headers={"user-agent": DESKTOP_UA})
assert "/mobile/app.js" in resp.text
def test_ui_query_override_forces_desktop_on_phone():
with TestClient(app) as c:
resp = c.get("/", params={"ui": "desktop"}, headers={"user-agent": IPHONE_UA})
assert "/css/daw.css" in resp.text
def test_mobile_assets_are_served():
with TestClient(app) as c:
assert c.get("/mobile/app.js").status_code == 200
assert c.get("/mobile/styles.css").status_code == 200
+116
View File
@@ -0,0 +1,116 @@
from __future__ import annotations
import pytest
from fastapi.testclient import TestClient
from app.core import settings as settings_mod
from app.main import _is_host_request, _is_loopback, app
MOBILE_UA = "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) Mobile/15E148"
@pytest.mark.parametrize(
"host,expected",
[
("127.0.0.1", True),
("::1", True),
("localhost", True),
("::ffff:127.0.0.1", True),
("127.0.1.1", True),
("192.168.1.14", False),
("10.0.0.5", False),
("", False),
(None, False),
],
)
def test_is_loopback(host, expected):
assert _is_loopback(host) is expected
def test_host_request_recognizes_own_lan_ip(monkeypatch):
# The host reaching itself via its LAN address must count as local, so
# turning network access off never cuts the host off from its own server.
monkeypatch.setattr("app.main._local_ips", lambda: frozenset({"192.168.1.14"}))
assert _is_host_request("192.168.1.14") is True # the host's own IP
assert _is_host_request("127.0.0.1") is True # loopback
assert _is_host_request("192.168.1.99") is False # a different device
def test_default_is_off_in_desktop_mode(monkeypatch):
# Desktop keeps network off; the user opts in via the UI toggle.
monkeypatch.delenv("STEMDECK_ALLOW_NETWORK", raising=False)
monkeypatch.setenv("STEMDECK_DESKTOP", "1")
assert settings_mod._default_allow_network() is False
def test_default_is_on_in_server_mode(monkeypatch):
# Server/Docker deployments open the gate by default.
monkeypatch.delenv("STEMDECK_ALLOW_NETWORK", raising=False)
monkeypatch.delenv("STEMDECK_DESKTOP", raising=False)
assert settings_mod._default_allow_network() is True
def test_env_var_pre_enables(monkeypatch):
monkeypatch.setenv("STEMDECK_ALLOW_NETWORK", "1")
assert settings_mod._default_allow_network() is True
def test_runtime_settings_round_trip_and_clamp():
with TestClient(app) as c:
r = c.post("/api/settings", json={"max_duration_sec": 600, "video_max_height": 1080})
assert r.status_code == 200
body = r.json()
assert body["max_duration_sec"] == 600
assert body["video_max_height"] == 1080
# GET reflects the new values.
assert c.get("/api/settings").json()["max_duration_sec"] == 600
# Out-of-range values are clamped, not rejected.
assert settings_mod.set_max_duration_sec(5) == 60 # floor
assert settings_mod.set_max_duration_sec(99999) == 1200 # ceiling = 20 min
assert settings_mod.set_video_max_height(99999) == 2160 # ceil
def test_port_default_and_clamp():
assert settings_mod.get_port() == 8000 # default
with TestClient(app) as c:
assert c.post("/api/settings", json={"port": 9000}).json()["port"] == 9000
assert settings_mod.set_port(80) == 1024 # floor (privileged ports rejected)
assert settings_mod.set_port(70000) == 65535 # ceil
def test_settings_reject_non_integer():
with TestClient(app) as c:
assert c.post("/api/settings", json={"max_duration_sec": "abc"}).status_code == 422
def test_gate_blocks_non_loopback_when_off():
settings_mod.set_allow_network(False)
# TestClient's client host ("testclient") is treated as non-loopback.
with TestClient(app) as c:
r = c.get("/", headers={"user-agent": MOBILE_UA})
assert r.status_code == 403
def test_gate_allows_everyone_when_on():
settings_mod.set_allow_network(True)
with TestClient(app) as c:
assert c.get("/api/health").status_code == 200
def test_loopback_always_allowed_even_when_off(monkeypatch):
settings_mod.set_allow_network(False)
monkeypatch.setattr("app.main._is_loopback", lambda _host: True)
with TestClient(app) as c:
assert c.get("/api/health").status_code == 200
def test_post_toggles_off_then_blocks():
settings_mod.set_allow_network(True) # so the non-loopback client can reach POST
with TestClient(app) as c:
r = c.post("/api/settings", json={"allow_network": False})
assert r.status_code == 200
assert r.json()["allow_network"] is False
# Now off → a non-loopback client is blocked from everything.
with TestClient(app) as c:
assert c.get("/api/settings").status_code == 403
+106
View File
@@ -0,0 +1,106 @@
from __future__ import annotations
import json
import struct
import wave
from pathlib import Path
import numpy as np
from app.pipeline.collect import _PEAK_POINTS, compute_stem_peaks
def _write_wav(path: Path, samples: list[float], sample_rate: int = 44100) -> None:
"""Write a mono 16-bit PCM WAV file."""
with wave.open(str(path), "w") as wf:
wf.setnchannels(1)
wf.setsampwidth(2)
wf.setframerate(sample_rate)
data = struct.pack(f"<{len(samples)}h", *[int(s * 32767) for s in samples])
wf.writeframes(data)
def test_produces_peaks_json(tmp_path):
stems_dir = tmp_path / "stems"
stems_dir.mkdir()
# 1-second sine wave at 440 Hz
sr = 44100
t = np.linspace(0, 1, sr, endpoint=False)
samples = (np.sin(2 * np.pi * 440 * t) * 0.5).tolist()
_write_wav(stems_dir / "vocals.wav", samples, sr)
compute_stem_peaks(stems_dir, ["vocals"])
peaks_path = stems_dir / "peaks.json"
assert peaks_path.is_file()
data = json.loads(peaks_path.read_text())
assert "vocals" in data
pts = data["vocals"]
assert len(pts) <= _PEAK_POINTS
assert len(pts) > 0
# each point is [min, max] with min <= 0 <= max (sine wave)
for mn, mx in pts:
assert mn <= mx
assert -1.0 <= mn <= 1.0
assert -1.0 <= mx <= 1.0
def test_multiple_stems(tmp_path):
stems_dir = tmp_path / "stems"
stems_dir.mkdir()
for name in ("vocals", "drums", "bass"):
_write_wav(stems_dir / f"{name}.wav", [0.1, -0.1, 0.2, -0.2])
compute_stem_peaks(stems_dir, ["vocals", "drums", "bass"])
data = json.loads((stems_dir / "peaks.json").read_text())
assert set(data.keys()) == {"vocals", "drums", "bass"}
def test_skips_missing_wav(tmp_path):
stems_dir = tmp_path / "stems"
stems_dir.mkdir()
_write_wav(stems_dir / "drums.wav", [0.1, -0.1])
# "vocals.wav" intentionally absent
compute_stem_peaks(stems_dir, ["vocals", "drums"])
data = json.loads((stems_dir / "peaks.json").read_text())
assert "drums" in data
assert "vocals" not in data
def test_no_output_when_all_stems_missing(tmp_path):
stems_dir = tmp_path / "stems"
stems_dir.mkdir()
compute_stem_peaks(stems_dir, ["vocals", "drums"])
assert not (stems_dir / "peaks.json").exists()
def test_writes_atomically(tmp_path):
"""No partial peaks.json.tmp should survive a successful run."""
stems_dir = tmp_path / "stems"
stems_dir.mkdir()
_write_wav(stems_dir / "vocals.wav", [0.1, -0.1, 0.3])
compute_stem_peaks(stems_dir, ["vocals"])
assert (stems_dir / "peaks.json").is_file()
assert not (stems_dir / "peaks.json.tmp").exists()
def test_non_fatal_on_corrupt_wav(tmp_path):
stems_dir = tmp_path / "stems"
stems_dir.mkdir()
(stems_dir / "vocals.wav").write_bytes(b"not a wav file at all")
_write_wav(stems_dir / "drums.wav", [0.1, -0.1])
# Should not raise; drums should still be computed
compute_stem_peaks(stems_dir, ["vocals", "drums"])
data = json.loads((stems_dir / "peaks.json").read_text())
assert "drums" in data
assert "vocals" not in data
+222
View File
@@ -0,0 +1,222 @@
from __future__ import annotations
from pathlib import Path
from unittest.mock import patch
import pytest
from app.core.models import Job, JobCancelled
from app.core.registry import _jobs
from app.pipeline.runner import _extract_video_track, run_local_pipeline, run_pipeline
def _ffmpeg_available() -> bool:
import shutil
return shutil.which("ffmpeg") is not None
@pytest.mark.asyncio
async def test_pipeline_transitions_to_error_on_stage_failure(tmp_path: Path):
job = Job(id="abcdefabcdef")
def boom(*args, **kwargs):
raise RuntimeError("download blew up")
with patch("app.pipeline.runner._run_blocking", side_effect=boom):
await run_pipeline(job, "https://www.youtube.com/watch?v=dQw4w9WgXcQ", tmp_path)
assert job.status == "error"
assert job.error # generic message returned to client; detail is in server logs
@pytest.mark.asyncio
async def test_pipeline_marks_done_on_success(tmp_path: Path):
job = Job(id="abcdefabcdee")
with patch("app.pipeline.runner._run_blocking", return_value=None):
await run_pipeline(job, "https://www.youtube.com/watch?v=dQw4w9WgXcQ", tmp_path)
assert job.status == "done"
assert job.progress == 1.0
@pytest.mark.asyncio
async def test_pipeline_handles_jobcancelled(tmp_path: Path):
job = Job(id="abcdefabcdec")
job.cancel_requested = True
def cancel(*args, **kwargs):
raise JobCancelled()
with patch("app.pipeline.runner._run_blocking", side_effect=cancel):
await run_pipeline(job, "https://www.youtube.com/watch?v=dQw4w9WgXcQ", tmp_path)
assert job.status == "cancelled"
# Partial job dir is removed.
assert not (tmp_path / job.id).exists()
@pytest.mark.asyncio
async def test_pipeline_handles_wrapped_cancel(tmp_path: Path):
"""yt-dlp wraps hook exceptions in DownloadError; the runner must still
treat it as a cancel when the flag is set."""
job = Job(id="abcdefabcdeb")
job.cancel_requested = True
def wrapped(*args, **kwargs):
raise RuntimeError("yt-dlp DownloadError wrapping JobCancelled")
with patch("app.pipeline.runner._run_blocking", side_effect=wrapped):
await run_pipeline(job, "https://www.youtube.com/watch?v=dQw4w9WgXcQ", tmp_path)
assert job.status == "cancelled"
@pytest.mark.asyncio
async def test_pipeline_recovers_from_mkdir_failure(tmp_path: Path):
"""If something pre-lock raises, the job must transition to error
instead of staying stuck on `queued`."""
job = Job(id="abcdefabcdea")
bad_jobs_dir = tmp_path / "blocked"
# Make jobs_dir a regular file so mkdir(parents=True) under it raises.
bad_jobs_dir.write_bytes(b"not a directory")
await run_pipeline(job, "https://www.youtube.com/watch?v=dQw4w9WgXcQ", bad_jobs_dir)
assert job.status == "error"
@pytest.mark.asyncio
async def test_pipeline_error_cleans_up_job_dir(tmp_path: Path):
"""#82: failed pipeline must remove the job directory so no orphan is left."""
job = Job(id="abcdefabcde9")
def boom(*args, **kwargs):
raise RuntimeError("ffmpeg died")
with patch("app.pipeline.runner._run_blocking", side_effect=boom):
await run_pipeline(job, "https://www.youtube.com/watch?v=dQw4w9WgXcQ", tmp_path)
assert job.status == "error"
assert not (tmp_path / job.id).exists(), "job dir should be removed on error"
@pytest.mark.asyncio
async def test_pipeline_error_calls_persist(tmp_path: Path):
"""#83: persist is called after an error so the registry stays consistent."""
job = Job(id="abcdefabcde8")
_jobs[job.id] = job
persist_calls = []
def boom(*args, **kwargs):
raise RuntimeError("separated badly")
def fake_persist(jobs_dir):
persist_calls.append(jobs_dir)
with (
patch("app.pipeline.runner._run_blocking", side_effect=boom),
patch("app.pipeline.runner.persist_registry", side_effect=fake_persist),
):
await run_pipeline(job, "https://www.youtube.com/watch?v=dQw4w9WgXcQ", tmp_path)
assert job.status == "error"
assert len(persist_calls) == 1
@pytest.mark.asyncio
async def test_local_pipeline_error_cleans_up_job_dir(tmp_path: Path):
"""#82: local upload error path also removes the job directory."""
job = Job(id="abcdefabcde7")
job_dir = tmp_path / job.id
job_dir.mkdir(parents=True)
source = job_dir / "source.mp3"
source.write_bytes(b"ID3")
def boom(*args, **kwargs):
raise RuntimeError("demucs blew up")
with patch("app.pipeline.runner._run_local_blocking", side_effect=boom):
await run_local_pipeline(job, source, tmp_path)
assert job.status == "error"
assert not (tmp_path / job.id).exists(), "job dir should be removed on local error"
def test_extract_video_track_from_mp4(tmp_path: Path):
"""#219: an mp4 with a video stream yields video.mp4 and sets has_video."""
if not _ffmpeg_available():
pytest.skip("ffmpeg not available")
import subprocess
job = Job(id="vid000000001")
job_dir = tmp_path / job.id
job_dir.mkdir(parents=True)
source = job_dir / "source.mp4"
subprocess.run(
[
"ffmpeg",
"-nostdin",
"-loglevel",
"error",
"-y",
"-f",
"lavfi",
"-i",
"color=c=black:s=64x64:d=0.3:r=10",
"-f",
"lavfi",
"-i",
"anullsrc=r=44100:cl=stereo",
"-shortest",
"-c:v",
"mpeg4",
"-c:a",
"aac",
str(source),
],
check=True,
timeout=30,
)
_extract_video_track(job, source, job_dir)
assert job.has_video is True
assert (job_dir / "video.mp4").is_file()
assert (job_dir / "video.mp4").stat().st_size > 0
def test_extract_video_track_audio_only_mp4(tmp_path: Path):
"""An mp4 with no video stream leaves has_video false and no video.mp4."""
if not _ffmpeg_available():
pytest.skip("ffmpeg not available")
import subprocess
job = Job(id="vid000000002")
job_dir = tmp_path / job.id
job_dir.mkdir(parents=True)
source = job_dir / "source.mp4"
subprocess.run(
[
"ffmpeg",
"-nostdin",
"-loglevel",
"error",
"-y",
"-f",
"lavfi",
"-i",
"anullsrc=r=44100:cl=stereo:d=0.3",
"-c:a",
"aac",
str(source),
],
check=True,
timeout=30,
)
_extract_video_track(job, source, job_dir)
assert job.has_video is False
assert not (job_dir / "video.mp4").exists()
+121
View File
@@ -0,0 +1,121 @@
from __future__ import annotations
import json
from pathlib import Path
import pytest
from fastapi.testclient import TestClient
from app.core.models import Job
from app.core.registry import _jobs
from app.core.registry import persist as persist_registry
from app.core.registry import restore as restore_registry
@pytest.fixture(autouse=True)
def _isolate_registry():
_jobs.clear()
yield
_jobs.clear()
def test_persist_and_restore_terminal_job(tmp_path: Path):
job = Job(
id="abcdefabcdef",
status="done",
progress=1.0,
stage_message="Done",
title="Saved song",
stems=[{"name": "vocals", "url": "/api/jobs/abcdefabcdef/stems/vocals.wav"}],
selected_stems=["vocals"],
)
_jobs[job.id] = job
persist_registry(tmp_path)
_jobs.clear()
restore_registry(tmp_path)
restored = _jobs[job.id]
assert restored.status == "done"
assert restored.title == "Saved song"
assert restored.stems == job.stems
assert restored.cancel_requested is False
def test_restore_recovers_orphan_done_job_from_stems(tmp_path: Path):
job_dir = tmp_path / "abcdefabcdee"
stems_dir = job_dir / "stems"
stems_dir.mkdir(parents=True)
(stems_dir / "vocals.wav").write_bytes(b"RIFF")
(stems_dir / "drums.wav").write_bytes(b"RIFF")
(job_dir / "metadata.json").write_text(json.dumps({"title": "Test Song"}), encoding="utf-8")
restore_registry(tmp_path)
restored = _jobs["abcdefabcdee"]
assert restored.status == "done"
assert restored.progress == 1.0
assert restored.title == "Test Song"
assert {stem["name"] for stem in restored.stems} == {"vocals", "drums"}
def test_restore_skips_orphan_without_metadata(tmp_path: Path):
stems_dir = tmp_path / "abcdefabcde0" / "stems"
stems_dir.mkdir(parents=True)
(stems_dir / "vocals.wav").write_bytes(b"RIFF")
restore_registry(tmp_path)
assert "abcdefabcde0" not in _jobs
def test_restored_job_serves_stems(tmp_path: Path, monkeypatch):
stems_dir = tmp_path / "abcdefabcded" / "stems"
stems_dir.mkdir(parents=True)
(stems_dir / "vocals.wav").write_bytes(b"RIFF1234")
data = {
"version": 1,
"jobs": [
Job(
id="abcdefabcded",
status="done",
title="Test Song",
stems=[{"name": "vocals", "url": "/api/jobs/abcdefabcded/stems/vocals.wav"}],
selected_stems=["vocals"],
).to_record()
],
}
(tmp_path / "registry.json").write_text(json.dumps(data), encoding="utf-8")
monkeypatch.setattr("app.api.stems.JOBS_DIR", tmp_path)
restore_registry(tmp_path)
from app.main import app
with TestClient(app) as client:
state = client.get("/api/jobs/abcdefabcded")
assert state.status_code == 200
assert state.json()["status"] == "done"
stem = client.get("/api/jobs/abcdefabcded/stems/vocals.wav")
assert stem.status_code == 200
assert stem.content == b"RIFF1234"
def test_delete_updates_persisted_registry(tmp_path: Path, monkeypatch):
job = Job(id="abcdefabcdec", status="done")
_jobs[job.id] = job
job_dir = tmp_path / job.id
job_dir.mkdir(parents=True)
persist_registry(tmp_path)
monkeypatch.setattr("app.api.jobs.JOBS_DIR", tmp_path)
from app.main import app
with TestClient(app) as client:
response = client.delete(f"/api/jobs/{job.id}")
assert response.status_code == 200
assert not job_dir.exists()
data = json.loads((tmp_path / "registry.json").read_text(encoding="utf-8"))
assert data["jobs"] == []
+457
View File
@@ -0,0 +1,457 @@
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"
+121
View File
@@ -0,0 +1,121 @@
from __future__ import annotations
import time
from pathlib import Path
from unittest.mock import patch
import pytest
from app.core.models import Job
from app.core.registry import _jobs
from app.pipeline.collect import sweep_old_jobs
@pytest.fixture(autouse=True)
def _isolate_registry():
_jobs.clear()
yield
_jobs.clear()
def _mkdir(jobs_dir: Path, name: str) -> Path:
d = jobs_dir / name
d.mkdir(parents=True)
(d / "marker").write_bytes(b"x")
return d
def test_skip_active_job_even_if_old(tmp_path: Path):
"""An active (non-terminal) job's directory must never be swept,
even if its created_at predates the TTL cutoff."""
d = _mkdir(tmp_path, "abcdefabcdef")
job = Job(id="abcdefabcdef")
job.status = "separating"
job.created_at = time.time() - 999_999 # ancient
_jobs[job.id] = job
with patch("app.pipeline.collect.JOB_TTL_SECONDS", 60):
sweep_old_jobs(tmp_path)
assert d.is_dir()
assert job.id in _jobs
def test_sweeps_terminal_old_job(tmp_path: Path):
d = _mkdir(tmp_path, "abcdefabcdee")
job = Job(id="abcdefabcdee")
job.status = "done"
job.created_at = time.time() - 999_999
_jobs[job.id] = job
with patch("app.pipeline.collect.JOB_TTL_SECONDS", 60):
sweep_old_jobs(tmp_path)
assert not d.exists()
assert job.id not in _jobs
def test_sweep_disabled_under_desktop(monkeypatch):
"""The desktop shell (STEMDECK_DESKTOP=1) opts out of the TTL sweep so a
user's curated library isn't purged; the server/Docker default keeps it."""
from app.main import _sweep_disabled
monkeypatch.delenv("STEMDECK_PERSIST_LIBRARY", raising=False)
monkeypatch.setenv("STEMDECK_DESKTOP", "1")
assert _sweep_disabled() is True
monkeypatch.delenv("STEMDECK_DESKTOP", raising=False)
assert _sweep_disabled() is False
def test_sweep_disabled_under_persistent_library(monkeypatch):
"""A self-hosted server (run.sh) opts into a persistent library
(STEMDECK_PERSIST_LIBRARY=1) so its processed tracks aren't purged."""
from app.main import _sweep_disabled
monkeypatch.delenv("STEMDECK_DESKTOP", raising=False)
monkeypatch.setenv("STEMDECK_PERSIST_LIBRARY", "1")
assert _sweep_disabled() is True
monkeypatch.delenv("STEMDECK_PERSIST_LIBRARY", raising=False)
assert _sweep_disabled() is False
@pytest.mark.asyncio
async def test_sweep_loop_returns_immediately_under_desktop(monkeypatch):
"""In desktop mode the loop returns at once instead of entering the hourly
cycle (wait_for would time out if it looped)."""
import asyncio
from app.main import _sweep_loop
monkeypatch.setenv("STEMDECK_DESKTOP", "1")
await asyncio.wait_for(_sweep_loop(), timeout=2)
def test_keeps_recent_terminal_job(tmp_path: Path):
d = _mkdir(tmp_path, "abcdefabcded")
job = Job(id="abcdefabcded")
job.status = "done"
job.created_at = time.time() # fresh
_jobs[job.id] = job
with patch("app.pipeline.collect.JOB_TTL_SECONDS", 60):
sweep_old_jobs(tmp_path)
assert d.is_dir()
assert job.id in _jobs
def test_orphan_dir_falls_back_to_mtime(tmp_path: Path):
"""Directories with no registry entry (e.g. left over from a prior
server run) still get swept by mtime."""
d = _mkdir(tmp_path, "abcdefabcdec")
# Backdate the directory.
old = time.time() - 999_999
import os
os.utime(d, (old, old))
with patch("app.pipeline.collect.JOB_TTL_SECONDS", 60):
sweep_old_jobs(tmp_path)
assert not d.exists()
+76
View File
@@ -0,0 +1,76 @@
from __future__ import annotations
import pytest
from app.pipeline.download import InvalidYouTubeURL, validate_youtube_url
@pytest.mark.parametrize(
"url,expected",
[
(
"https://www.youtube.com/watch?v=dQw4w9WgXcQ",
"https://www.youtube.com/watch?v=dQw4w9WgXcQ",
),
(
"https://youtu.be/dQw4w9WgXcQ",
"https://www.youtube.com/watch?v=dQw4w9WgXcQ",
),
(
"https://m.youtube.com/watch?v=dQw4w9WgXcQ&list=PLfoo",
"https://www.youtube.com/watch?v=dQw4w9WgXcQ",
),
(
"https://music.youtube.com/watch?v=dQw4w9WgXcQ",
"https://www.youtube.com/watch?v=dQw4w9WgXcQ",
),
(
" https://www.youtube.com/watch?v=dQw4w9WgXcQ ",
"https://www.youtube.com/watch?v=dQw4w9WgXcQ",
),
(
"https://www.youtube.com/shorts/dQw4w9WgXcQ",
"https://www.youtube.com/watch?v=dQw4w9WgXcQ",
),
(
"https://m.youtube.com/shorts/dQw4w9WgXcQ",
"https://www.youtube.com/watch?v=dQw4w9WgXcQ",
),
],
)
def test_accepts_youtube_urls(url: str, expected: str) -> None:
assert validate_youtube_url(url) == expected
@pytest.mark.parametrize(
"url,reason_substring",
[
("", "required"),
(" ", "required"),
("not a url", "http"),
("ftp://youtube.com/watch?v=dQw4w9WgXcQ", "http"),
("https://example.com/foo", "unsupported host"),
("https://www.youtube.com/playlist?list=PLfoo", "video ID"),
("https://evil.com/watch?v=dQw4w9WgXcQ", "unsupported host"),
# The on.soundcloud.com share shortener is rejected — it redirects to
# arbitrary targets, an SSRF vector once handed to yt-dlp (#173).
("https://on.soundcloud.com/abc123", "unsupported host"),
],
)
def test_rejects_bad_urls(url: str, reason_substring: str) -> None:
with pytest.raises(InvalidYouTubeURL) as exc:
validate_youtube_url(url)
assert reason_substring in str(exc.value)
@pytest.mark.parametrize(
"url",
[
"https://soundcloud.com/artist/track",
"https://www.soundcloud.com/artist/track",
" https://soundcloud.com/artist/track ",
],
)
def test_accepts_soundcloud_urls(url: str) -> None:
result = validate_youtube_url(url)
assert result == url.strip()