Files
Tha.Les afe871ce81 feat: background import queue, playlist import, and queue management (#350)
Imports run through an explicit serial queue: queue several tracks, a playlist, or a folder of files and keep using StemDeck while they extract. Adds a Queue view with per-job cancel and drag-to-reorder, and a restored queue waits for the user to start it.

Closes #344, #345, #346, #347, #348, #349, #351, #352, #353.
2026-08-11 21:31:29 +01:00

313 lines
10 KiB
Python

from __future__ import annotations
import asyncio
import json
import pytest
from fastapi.testclient import TestClient
from app.core.config import MAX_PENDING_UPLOAD_JOBS, MAX_PENDING_URL_JOBS
from app.core.models import Job, _set
from app.core.registry import _jobs
from app.pipeline import jobqueue
@pytest.fixture(autouse=True)
def _isolate_registry():
_jobs.clear()
yield
_jobs.clear()
@pytest.fixture
def client(monkeypatch):
# No worker: these tests assert what the endpoint reports for a given queue
# state, and a live worker would drain it mid-assertion.
monkeypatch.setattr(jobqueue, "start_worker", _NoopTask)
from app.main import app
with TestClient(app) as c:
yield c
class _NoopTask:
"""Stand-in for the worker task; the lifespan only stores and discards it."""
def add_done_callback(self, _cb):
pass
def cancel(self):
pass
def _queued(job_id: str, title: str | None = None) -> Job:
job = Job(id=job_id, title=title, source_url="https://www.youtube.com/watch?v=x")
_jobs[job_id] = job
jobqueue._queue.append(job_id)
return job
# ── GET /api/queue ───────────────────────────────────────────────────────────
def test_empty_queue(client):
body = client.get("/api/queue").json()
assert body["running"] is None
assert body["queued"] == []
assert body["max_pending_urls"] == MAX_PENDING_URL_JOBS
assert body["max_pending_uploads"] == MAX_PENDING_UPLOAD_JOBS
assert body["capacity_left_urls"] == MAX_PENDING_URL_JOBS
assert body["capacity_left_uploads"] == MAX_PENDING_UPLOAD_JOBS
def test_reports_waiting_jobs_in_order_with_positions(client):
for i, jid in enumerate(("aaaaaaaaaaa1", "aaaaaaaaaaa2", "aaaaaaaaaaa3")):
_queued(jid, title=f"Track {i}")
queued = client.get("/api/queue").json()["queued"]
assert [j["job_id"] for j in queued] == ["aaaaaaaaaaa1", "aaaaaaaaaaa2", "aaaaaaaaaaa3"]
assert [j["position"] for j in queued] == [0, 1, 2]
def test_reports_the_running_job_separately(client):
job = _queued("aaaaaaaaaaa1")
jobqueue._queue.remove(job.id)
jobqueue._set_running(job.id)
_set(job, status="processing", stage="Starting...")
body = client.get("/api/queue").json()
assert body["running"]["job_id"] == job.id
assert body["running"]["status"] == "processing"
assert body["queued"] == []
def test_capacity_reflects_waiting_jobs_only(client):
_queued("aaaaaaaaaaa1")
running = Job(id="aaaaaaaaaaa9")
running.status = "processing"
_jobs[running.id] = running
body = client.get("/api/queue").json()
assert body["capacity_left_urls"] == MAX_PENDING_URL_JOBS - 1, (
"the running job must not take a slot"
)
def test_skips_a_queued_id_with_no_registry_entry(client):
"""A reset can empty the registry while ids are still in the deque."""
jobqueue._queue.append("aaaaaaaaaaa1")
assert client.get("/api/queue").json()["queued"] == []
def test_payload_is_compact(client):
"""The queue frame ships for every waiting job several times a second, so it
must not carry stems/sections/analysis."""
job = _queued("aaaaaaaaaaa1", title="T")
job.stems = [{"name": "vocals", "url": "/x"}]
job.sections = [{"id": "a"}]
rec = client.get("/api/queue").json()["queued"][0]
assert set(rec) == {
"job_id",
"status",
"progress",
"stage",
"title",
"thumbnail",
"source_url",
"error",
"position",
}
# ── GET /api/queue/events ────────────────────────────────────────────────────
# Driven directly rather than through TestClient: this stream never self-closes,
# so a TestClient.stream() context would block on exit waiting for it to end.
# Same approach as tests/test_events_stream.py.
class _Stream:
def __init__(self, it):
self._it = it
async def next_data(self, timeout: float = 2.0) -> dict:
while True:
chunk = await asyncio.wait_for(self._it.__anext__(), timeout=timeout)
if chunk.startswith("data: "):
return json.loads(chunk[len("data: ") : -2])
async def expect_no_frame(self, timeout: float = 0.7) -> None:
with pytest.raises(asyncio.TimeoutError):
await asyncio.wait_for(self._it.__anext__(), timeout=timeout)
async def aclose(self) -> None:
await self._it.aclose()
async def _open() -> _Stream:
from app.api.queue import queue_events
response = await queue_events()
return _Stream(response.body_iterator)
async def test_events_emits_an_initial_frame():
_queued("aaaaaaaaaaa1", title="T")
stream = await _open()
try:
frame = await stream.next_data()
assert [j["job_id"] for j in frame["queued"]] == ["aaaaaaaaaaa1"]
finally:
await stream.aclose()
async def test_events_emits_again_when_a_job_changes():
job = _queued("aaaaaaaaaaa1", title="T")
stream = await _open()
try:
await stream.next_data()
_set(job, progress=0.5, stage="Separating")
frame = await stream.next_data()
assert frame["queued"][0]["progress"] == 0.5
finally:
await stream.aclose()
async def test_events_does_not_repeat_an_unchanged_queue():
_queued("aaaaaaaaaaa1", title="T")
stream = await _open()
try:
await stream.next_data()
await stream.expect_no_frame()
finally:
await stream.aclose()
async def test_events_stays_open_when_the_queue_empties():
"""Unlike the per-job stream, this one outlives any single job: it is the
session-long view, so an empty queue must not end it."""
job = _queued("aaaaaaaaaaa1", title="T")
stream = await _open()
try:
await stream.next_data()
jobqueue._queue.remove(job.id)
frame = await stream.next_data()
assert frame["queued"] == []
await stream.expect_no_frame() # still open, just idle
finally:
await stream.aclose()
async def test_events_503_at_the_shared_connection_cap(monkeypatch):
"""The budget is shared with the per-job stream rather than each having its
own, so twenty queue streams cannot starve job streams."""
from fastapi import HTTPException
import app.api.events as events_mod
from app.api.queue import queue_events
monkeypatch.setattr(events_mod, "_sse_active", events_mod._MAX_SSE_CONNECTIONS)
with pytest.raises(HTTPException) as exc:
await queue_events()
assert exc.value.status_code == 503
async def test_events_releases_its_slot_on_close():
"""The slot is released in the generator's finally, so the generator has to
have started -- pull a frame first, as Starlette's response iteration does."""
import app.api.events as events_mod
before = events_mod._sse_active
stream = await _open()
assert events_mod._sse_active == before + 1
await stream.next_data()
await stream.aclose()
assert events_mod._sse_active == before
# ── pausing ──────────────────────────────────────────────────────────────────
def test_snapshot_reports_the_paused_flag(client):
_queued("aaaaaaaaaaa1")
assert client.get("/api/queue").json()["paused"] is False
jobqueue.pause()
try:
assert client.get("/api/queue").json()["paused"] is True
finally:
jobqueue.resume()
def test_start_endpoint_resumes_the_queue(client):
_queued("aaaaaaaaaaa1")
jobqueue.pause()
try:
body = client.post("/api/queue/start").json()
assert body["paused"] is False
assert jobqueue.is_paused() is False
finally:
jobqueue.resume()
def test_start_is_harmless_when_nothing_is_paused(client):
assert client.post("/api/queue/start").status_code == 200
assert jobqueue.is_paused() is False
# ── reordering ───────────────────────────────────────────────────────────────
def test_reorder_moves_a_job_after_another(client):
for jid in ("aaaaaaaaaaa1", "aaaaaaaaaaa2", "aaaaaaaaaaa3"):
_queued(jid)
body = client.post(
"/api/queue/reorder", json={"job_id": "aaaaaaaaaaa3", "after": "aaaaaaaaaaa1"}
).json()
assert [j["job_id"] for j in body["queued"]] == [
"aaaaaaaaaaa1",
"aaaaaaaaaaa3",
"aaaaaaaaaaa2",
]
def test_reorder_to_the_front(client):
for jid in ("aaaaaaaaaaa1", "aaaaaaaaaaa2", "aaaaaaaaaaa3"):
_queued(jid)
body = client.post("/api/queue/reorder", json={"job_id": "aaaaaaaaaaa3"}).json()
assert [j["job_id"] for j in body["queued"]][0] == "aaaaaaaaaaa3"
def test_reorder_renumbers_positions(client):
for jid in ("aaaaaaaaaaa1", "aaaaaaaaaaa2", "aaaaaaaaaaa3"):
_queued(jid)
body = client.post("/api/queue/reorder", json={"job_id": "aaaaaaaaaaa3"}).json()
assert [j["position"] for j in body["queued"]] == [0, 1, 2]
def test_reorder_409s_for_a_job_that_is_no_longer_waiting(client):
"""The user dragged a row that finished or started mid-drag."""
_queued("aaaaaaaaaaa1")
r = client.post("/api/queue/reorder", json={"job_id": "aaaaaaaaaaa9"})
assert r.status_code == 409
def test_reorder_rejects_a_job_following_itself(client):
_queued("aaaaaaaaaaa1")
r = client.post("/api/queue/reorder", json={"job_id": "aaaaaaaaaaa1", "after": "aaaaaaaaaaa1"})
assert r.status_code == 422
def test_reorder_404s_on_a_malformed_id(client):
r = client.post("/api/queue/reorder", json={"job_id": "../../etc/passwd"})
assert r.status_code == 404
def test_a_lost_anchor_puts_the_job_last_rather_than_failing(client):
"""The anchor finished while the row was being dragged onto it."""
for jid in ("aaaaaaaaaaa1", "aaaaaaaaaaa2"):
_queued(jid)
body = client.post(
"/api/queue/reorder", json={"job_id": "aaaaaaaaaaa1", "after": "aaaaaaaaaaa8"}
).json()
assert [j["job_id"] for j in body["queued"]] == ["aaaaaaaaaaa2", "aaaaaaaaaaa1"]