web: add GET /api/jobs/{id}/review for per-line note data

This commit is contained in:
2026-07-27 23:18:26 -07:00
parent 781f7a326e
commit bed7a135e9
2 changed files with 111 additions and 0 deletions
+28
View File
@@ -1,3 +1,4 @@
import json
import math
import queue
import re
@@ -15,6 +16,7 @@ from fastapi.staticfiles import StaticFiles
from . import rebuild as rebuild_policy
from .config import Config
from .lyrics import normalize_lyrics
from .pipeline import (STAGE_ORDER, PipelineError, SongInput, run_pipeline,
song_slug)
@@ -303,6 +305,32 @@ def create_app(cfg: Config, runner=run_pipeline) -> FastAPI:
"video_filename": inp.video_path.name if inp.video_path else None,
"cover_filename": inp.cover_path.name if inp.cover_path else None}
@app.get("/api/jobs/{job_id}/review")
def get_review(job_id: str):
job = _finished_job(job_id)
if job.status != "done":
raise HTTPException(404)
work = cfg.work_dir / song_slug(job.inp)
notes_file = work / "notes_sec.json"
if not notes_file.exists():
raise HTTPException(404)
notes_data = json.loads(notes_file.read_text())
meta_file = work / "align_meta.json"
meta = json.loads(meta_file.read_text()) if meta_file.exists() else {}
dropped = set(meta.get("dropped_lines", []))
missing_spans = [{"start": s, "end": e}
for s, e in meta.get("missing_spans", [])]
ref = normalize_lyrics(job.inp.lyrics_text)
by_line: dict[int, list] = {}
for n in notes_data["notes"]:
by_line.setdefault(n["line_index"], []).append({
"start": n["start"], "end": n["start"] + n["duration"],
"pitch": n["pitch"], "low_confidence": n["low_confidence"]})
lines = [{"index": i, "text": " ".join(words), "dropped": i in dropped,
"notes": by_line.get(i, [])}
for i, words in enumerate(ref.lines)]
return {"lines": lines, "missing_spans": missing_spans}
return app
+83
View File
@@ -1,4 +1,5 @@
import io
import json
import re
import threading
import time
@@ -984,3 +985,85 @@ def test_a_plain_job_never_removes_anything(tmp_path):
stray.mkdir(parents=True)
make_done_job(client, title="T2")
assert stray.exists()
def review_runner_factory(notes, dropped_lines=None, missing_spans=None, warnings=None):
def runner(inp, cfg, log=print, force_from=None, stages=None):
work = cfg.work_dir / song_slug(inp)
work.mkdir(parents=True, exist_ok=True)
(work / "notes_sec.json").write_text(json.dumps({
"notes": notes, "breaks": [], "warnings": warnings or []}))
if dropped_lines is not None or missing_spans is not None:
(work / "align_meta.json").write_text(json.dumps({
"dropped_lines": dropped_lines or [],
"missing_spans": missing_spans or []}))
out = cfg.output_dir / song_slug(inp)
out.mkdir(parents=True, exist_ok=True)
return PipelineResult(output_dir=out, warnings=warnings or [])
return runner
def test_review_endpoint_groups_notes_by_line(tmp_path):
notes = [
{"start": 1.0, "duration": 0.4, "pitch": 0, "text": "hi",
"low_confidence": False, "line_index": 0},
{"start": 5.0, "duration": 0.3, "pitch": 2, "text": "there",
"low_confidence": True, "line_index": 1},
]
client, _ = make_client(tmp_path, review_runner_factory(
notes, dropped_lines=[2], missing_spans=[[8.0, 9.5]]))
resp = client.post("/api/jobs", data={
"title": "T", "artist": "A", "lyrics": "hi\nthere\ngone",
"youtube_url": "http://y"})
job_id = resp.json()["id"]
wait_status(client, job_id, "done")
body = client.get(f"/api/jobs/{job_id}/review").json()
assert body["lines"] == [
{"index": 0, "text": "hi", "dropped": False,
"notes": [{"start": 1.0, "end": 1.4, "pitch": 0, "low_confidence": False}]},
{"index": 1, "text": "there", "dropped": False,
"notes": [{"start": 5.0, "end": 5.3, "pitch": 2, "low_confidence": True}]},
{"index": 2, "text": "gone", "dropped": True, "notes": []},
]
assert body["missing_spans"] == [{"start": 8.0, "end": 9.5}]
def test_review_of_a_failed_job_is_404(tmp_path):
def bad_runner(*a, **kw):
raise PipelineError("boom")
client, _ = make_client(tmp_path, bad_runner)
resp = client.post("/api/jobs", data={
"title": "T", "artist": "A", "lyrics": "x", "youtube_url": "http://y"})
job_id = resp.json()["id"]
wait_status(client, job_id, "failed")
assert client.get(f"/api/jobs/{job_id}/review").status_code == 404
def test_review_of_a_running_job_is_409(tmp_path):
release = threading.Event()
def blocking(inp, cfg, log=print, force_from=None, stages=None):
release.wait(5)
return PipelineResult(output_dir=cfg.output_dir / "X")
client, _ = make_client(tmp_path, blocking)
resp = client.post("/api/jobs", data={
"title": "T", "artist": "A", "lyrics": "x", "youtube_url": "http://y"})
job_id = resp.json()["id"]
try:
wait_status(client, job_id, "running")
assert client.get(f"/api/jobs/{job_id}/review").status_code == 409
finally:
release.set()
wait_status(client, job_id, "done")
def test_review_of_an_unknown_job_is_404(tmp_path):
client, _ = make_client(tmp_path, ok_runner)
assert client.get("/api/jobs/nope/review").status_code == 404
def test_review_missing_when_pipeline_never_wrote_notes(tmp_path):
client, _ = make_client(tmp_path, ok_runner)
job_id = make_done_job(client)
assert client.get(f"/api/jobs/{job_id}/review").status_code == 404