web: add GET /api/jobs/{id}/review for per-line note data
This commit is contained in:
@@ -1,3 +1,4 @@
|
|||||||
|
import json
|
||||||
import math
|
import math
|
||||||
import queue
|
import queue
|
||||||
import re
|
import re
|
||||||
@@ -15,6 +16,7 @@ from fastapi.staticfiles import StaticFiles
|
|||||||
|
|
||||||
from . import rebuild as rebuild_policy
|
from . import rebuild as rebuild_policy
|
||||||
from .config import Config
|
from .config import Config
|
||||||
|
from .lyrics import normalize_lyrics
|
||||||
from .pipeline import (STAGE_ORDER, PipelineError, SongInput, run_pipeline,
|
from .pipeline import (STAGE_ORDER, PipelineError, SongInput, run_pipeline,
|
||||||
song_slug)
|
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,
|
"video_filename": inp.video_path.name if inp.video_path else None,
|
||||||
"cover_filename": inp.cover_path.name if inp.cover_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
|
return app
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import io
|
import io
|
||||||
|
import json
|
||||||
import re
|
import re
|
||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
@@ -984,3 +985,85 @@ def test_a_plain_job_never_removes_anything(tmp_path):
|
|||||||
stray.mkdir(parents=True)
|
stray.mkdir(parents=True)
|
||||||
make_done_job(client, title="T2")
|
make_done_job(client, title="T2")
|
||||||
assert stray.exists()
|
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
|
||||||
|
|||||||
Reference in New Issue
Block a user