Merge pull request 'Rerun failed tracks and edit-and-rebuild existing ones' (#2) from rerun-rebuild into main
Reviewed-on: branderson/karaoke-maker#2
This commit is contained in:
@@ -0,0 +1,73 @@
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
|
||||
from .config import Config
|
||||
from .pipeline import STAGE_ORDER, PipelineError, SongInput, song_slug
|
||||
|
||||
# The earliest stage that reads each SongInput field. Editing a field means every
|
||||
# stage from here onward is stale. Adding a field to SongInput without adding it
|
||||
# here would let an edit invalidate nothing at all, so tests pin this to the
|
||||
# dataclass.
|
||||
FIELD_STAGE = {
|
||||
"youtube_url": "acquire",
|
||||
"video_path": "acquire",
|
||||
"language": "transcribe",
|
||||
"lyrics_text": "align",
|
||||
"title": "grid",
|
||||
"artist": "grid",
|
||||
"genre": "grid",
|
||||
"year": "grid",
|
||||
"bpm_override": "grid",
|
||||
"cover_path": "package",
|
||||
}
|
||||
|
||||
|
||||
def changed_fields(old: SongInput, new: SongInput) -> list[str]:
|
||||
return [f for f in FIELD_STAGE if getattr(old, f) != getattr(new, f)]
|
||||
|
||||
|
||||
def plan(old: SongInput, new: SongInput) -> str | None:
|
||||
"""The stage to restart from, or None when nothing changed.
|
||||
|
||||
None is not "do nothing": run_pipeline with no force_from resumes at the
|
||||
first stage whose outputs are missing, which is exactly what a rerun wants.
|
||||
"""
|
||||
changed = changed_fields(old, new)
|
||||
if not changed:
|
||||
return None
|
||||
return min((FIELD_STAGE[f] for f in changed), key=STAGE_ORDER.index)
|
||||
|
||||
|
||||
def relocate_work(old: SongInput, new: SongInput, cfg: Config) -> bool:
|
||||
"""Move the cached work dir when title/artist changed its slug.
|
||||
|
||||
Nothing inside a work dir depends on the slug except song.txt, which `grid`
|
||||
rewrites — and FIELD_STAGE sends title/artist edits to `grid`. So a retitle
|
||||
costs a rename plus grid and package, not the whole GPU pipeline.
|
||||
"""
|
||||
old_dir = cfg.work_dir / song_slug(old)
|
||||
new_dir = cfg.work_dir / song_slug(new)
|
||||
if old_dir == new_dir or not old_dir.exists():
|
||||
return False
|
||||
if new_dir.exists():
|
||||
raise PipelineError(
|
||||
f"a work folder already exists for {song_slug(new)!r}; "
|
||||
"rename or remove it before rebuilding")
|
||||
new_dir.parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.move(str(old_dir), str(new_dir))
|
||||
return True
|
||||
|
||||
|
||||
def superseded_output(old_dir: str | None, new_dir: str,
|
||||
output_root: Path) -> Path | None:
|
||||
"""The previous build's folder, when the rebuild replaced it under a new slug.
|
||||
|
||||
Returns None unless the path is strictly inside output_root, so a bad job
|
||||
record cannot turn the caller's removal into an arbitrary delete.
|
||||
"""
|
||||
if not old_dir:
|
||||
return None
|
||||
old, new = Path(old_dir).resolve(), Path(new_dir).resolve()
|
||||
if old == new or output_root.resolve() not in old.parents:
|
||||
return None
|
||||
return old
|
||||
@@ -343,7 +343,7 @@ button { cursor: pointer; }
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
.output-path code { flex: 1; overflow-wrap: anywhere; font-family: var(--font-mono); font-size: 0.82rem; }
|
||||
#copy-path {
|
||||
#copy-path, .action, #cancel-rebuild {
|
||||
flex: none;
|
||||
padding: 5px 12px;
|
||||
font-family: var(--font-display);
|
||||
@@ -356,7 +356,23 @@ button { cursor: pointer; }
|
||||
border: 1px solid var(--control);
|
||||
border-radius: 2px;
|
||||
}
|
||||
#copy-path:hover { border-color: var(--text-muted); }
|
||||
#copy-path:hover, .action:hover, #cancel-rebuild:hover { border-color: var(--text-muted); }
|
||||
|
||||
.form-actions { display: flex; align-items: center; gap: 14px; }
|
||||
.keep-note { margin: -8px 0 0; font-size: 0.78rem; color: var(--aqua); }
|
||||
#review-actions { display: flex; gap: 10px; margin-top: 14px; }
|
||||
#review-lineage { margin: -10px 0 14px; }
|
||||
.lineage {
|
||||
padding: 0;
|
||||
font: inherit;
|
||||
font-size: 0.8rem;
|
||||
color: var(--aqua);
|
||||
background: none;
|
||||
border: 0;
|
||||
border-bottom: 1px solid transparent;
|
||||
}
|
||||
.lineage:hover { border-bottom-color: var(--aqua); }
|
||||
.job-mark { grid-area: 1 / 2; justify-self: end; font-size: 0.8rem; color: var(--text-muted); }
|
||||
|
||||
#detail {
|
||||
margin: 13px 0 0;
|
||||
@@ -480,6 +496,7 @@ button { cursor: pointer; }
|
||||
<div class="stack">
|
||||
<label class="field"><span>YouTube URL</span><input name="youtube_url"></label>
|
||||
<label class="field"><span>Video file</span><input type="file" name="video"></label>
|
||||
<p class="keep-note" data-keep="video" hidden></p>
|
||||
<div class="pair">
|
||||
<label class="field"><span>Genre</span><input name="genre"></label>
|
||||
<label class="field"><span>Year</span><input name="year" type="number"></label>
|
||||
@@ -489,9 +506,13 @@ button { cursor: pointer; }
|
||||
<label class="field"><span>BPM override</span><input name="bpm" type="number" step="any"></label>
|
||||
</div>
|
||||
<label class="field"><span>Cover image</span><input type="file" name="cover"></label>
|
||||
<p class="keep-note" data-keep="cover" hidden></p>
|
||||
</div>
|
||||
</details>
|
||||
<button type="submit" class="primary">CREATE SONG</button>
|
||||
<div class="form-actions">
|
||||
<button type="submit" class="primary">CREATE SONG</button>
|
||||
<button type="button" id="cancel-rebuild" hidden>CANCEL</button>
|
||||
</div>
|
||||
</form>
|
||||
<p id="form-error" role="alert"></p>
|
||||
</section>
|
||||
@@ -499,9 +520,11 @@ button { cursor: pointer; }
|
||||
<section id="review">
|
||||
<h2>REVIEW</h2>
|
||||
<h3 id="review-title"></h3>
|
||||
<p id="review-lineage" hidden></p>
|
||||
<p id="review-error" class="review-error" hidden></p>
|
||||
<div id="warnings"><p class="empty-note">no songs queued yet — nothing to review</p></div>
|
||||
<div id="output-path"></div>
|
||||
<div id="review-actions"></div>
|
||||
<details>
|
||||
<summary>Log</summary>
|
||||
<pre id="detail">no songs queued yet — nothing to log</pre>
|
||||
@@ -527,6 +550,13 @@ const nowBar = document.getElementById("now-bar");
|
||||
const nowLabel = document.getElementById("now-label");
|
||||
const nowTitle = document.getElementById("now-title");
|
||||
const stageTrack = document.getElementById("stage-track");
|
||||
const reviewActions = document.getElementById("review-actions");
|
||||
const reviewLineage = document.getElementById("review-lineage");
|
||||
const cancelRebuild = document.getElementById("cancel-rebuild");
|
||||
const formHeading = document.querySelector("#new-song h2");
|
||||
const submitButton = form.querySelector("button[type=submit]");
|
||||
let rebuildOf = null;
|
||||
let JOBS = [];
|
||||
let selectedId = null;
|
||||
let STAGES = [];
|
||||
let pollTimer = null;
|
||||
@@ -557,13 +587,15 @@ form.addEventListener("submit", async (e) => {
|
||||
if (isFile && value.size === 0) data.delete(name);
|
||||
else if (!isFile && value === "") data.delete(name);
|
||||
}
|
||||
const resp = await fetch("/api/jobs", { method: "POST", body: data });
|
||||
const url = rebuildOf ? `/api/jobs/${rebuildOf}/rebuild` : "/api/jobs";
|
||||
const resp = await fetch(url, { method: "POST", body: data });
|
||||
if (!resp.ok) {
|
||||
const body = await resp.json().catch(() => ({}));
|
||||
formError.textContent = body.detail || `error: ${resp.status}`;
|
||||
return; // before the reset below, so a rejected submission is never retyped
|
||||
}
|
||||
form.reset();
|
||||
selectedId = (await resp.json()).id;
|
||||
exitRebuild();
|
||||
refreshJobs();
|
||||
});
|
||||
|
||||
@@ -588,6 +620,8 @@ for (const input of form.querySelectorAll('input[type="file"]')) {
|
||||
});
|
||||
}
|
||||
|
||||
cancelRebuild.addEventListener("click", exitRebuild);
|
||||
|
||||
function jobCard(job) {
|
||||
const card = document.createElement("button");
|
||||
card.type = "button";
|
||||
@@ -610,7 +644,15 @@ function jobCard(job) {
|
||||
const time = document.createElement("span");
|
||||
time.className = "job-elapsed";
|
||||
|
||||
card.append(title, artist, stateLine, time);
|
||||
const mark = document.createElement("span");
|
||||
mark.className = "job-mark";
|
||||
const markGlyph = document.createElement("span");
|
||||
markGlyph.setAttribute("aria-hidden", "true");
|
||||
const markLabel = document.createElement("span");
|
||||
markLabel.className = "sr-only";
|
||||
mark.append(markGlyph, markLabel);
|
||||
|
||||
card.append(title, artist, stateLine, time, mark);
|
||||
card.addEventListener("click", () => selectJob(job.id));
|
||||
paintCard(card, job);
|
||||
return card;
|
||||
@@ -618,7 +660,7 @@ function jobCard(job) {
|
||||
|
||||
function paintCard(card, job) {
|
||||
const state = STATE[job.status] ?? { glyph: "·", cls: "st-queued" };
|
||||
const [title, artist, stateLine, time] = card.children;
|
||||
const [title, artist, stateLine, time, mark] = card.children;
|
||||
card.className = `job ${state.cls}`;
|
||||
if (job.id === selectedId) card.setAttribute("aria-current", "true");
|
||||
else card.removeAttribute("aria-current");
|
||||
@@ -627,6 +669,8 @@ function paintCard(card, job) {
|
||||
stateLine.children[0].textContent = state.glyph;
|
||||
stateLine.children[1].textContent = job.stage ? `${job.status} · ${job.stage}` : job.status;
|
||||
time.textContent = elapsed(job);
|
||||
mark.children[0].textContent = job.rebuilt_from ? "↻" : "";
|
||||
mark.children[1].textContent = job.rebuilt_from ? " rebuilt" : "";
|
||||
}
|
||||
|
||||
// patched in place rather than replaced wholesale: detaching a card would blur
|
||||
@@ -709,6 +753,7 @@ function schedulePoll(jobs) {
|
||||
async function refreshJobs() {
|
||||
const resp = await fetch("/api/jobs");
|
||||
const jobs = await resp.json();
|
||||
JOBS = jobs;
|
||||
if (selectedId === null && jobs.length) selectedId = jobs[0].id;
|
||||
renderJobs(jobs);
|
||||
renderNowBar(jobs);
|
||||
@@ -829,6 +874,90 @@ function renderOutputPath(job) {
|
||||
outputPathDiv.replaceChildren(wrap);
|
||||
}
|
||||
|
||||
function setFormMode(inp) {
|
||||
formHeading.textContent = rebuildOf ? "REBUILD" : "NEW SONG";
|
||||
submitButton.textContent = rebuildOf ? "REBUILD SONG" : "CREATE SONG";
|
||||
cancelRebuild.hidden = !rebuildOf;
|
||||
for (const [field, key] of [["video", "video_filename"], ["cover", "cover_filename"]]) {
|
||||
const note = form.querySelector(`[data-keep="${field}"]`);
|
||||
const kept = rebuildOf && inp ? inp[key] : null;
|
||||
note.textContent = kept ? `keeping ${kept} — attach a file to replace it` : "";
|
||||
note.hidden = !kept;
|
||||
}
|
||||
}
|
||||
|
||||
function exitRebuild() {
|
||||
rebuildOf = null;
|
||||
form.reset();
|
||||
setFormMode(null);
|
||||
}
|
||||
|
||||
async function startRebuild(id) {
|
||||
const resp = await fetch(`/api/jobs/${id}/input`);
|
||||
if (!resp.ok) return;
|
||||
const inp = await resp.json();
|
||||
form.reset();
|
||||
for (const [name, value] of Object.entries(inp)) {
|
||||
// the payload is keyed by form field name; *_filename match nothing, by design
|
||||
const el = form.elements[name];
|
||||
if (el && value !== null) el.value = value;
|
||||
}
|
||||
rebuildOf = id;
|
||||
setFormMode(inp);
|
||||
formError.textContent = "";
|
||||
form.elements.title.focus();
|
||||
}
|
||||
|
||||
async function rerunJob(id) {
|
||||
formError.textContent = "";
|
||||
const resp = await fetch(`/api/jobs/${id}/rerun`, { method: "POST" });
|
||||
if (!resp.ok) {
|
||||
const body = await resp.json().catch(() => ({}));
|
||||
formError.textContent = body.detail || `error: ${resp.status}`;
|
||||
return;
|
||||
}
|
||||
selectedId = (await resp.json()).id;
|
||||
refreshJobs();
|
||||
}
|
||||
|
||||
function actionButton(action, jobId) {
|
||||
const button = document.createElement("button");
|
||||
button.type = "button";
|
||||
button.className = "action";
|
||||
button.dataset.action = action;
|
||||
button.textContent = action === "rerun" ? "RERUN" : "EDIT & REBUILD";
|
||||
button.addEventListener("click",
|
||||
() => action === "rerun" ? rerunJob(jobId) : startRebuild(jobId));
|
||||
return button;
|
||||
}
|
||||
|
||||
function renderActions(job) {
|
||||
// no controls mid-run: both endpoints answer 409 until the job settles
|
||||
const wanted = job.status === "failed" ? ["rerun", "edit"]
|
||||
: job.status === "done" ? ["edit"] : [];
|
||||
const shown = [...reviewActions.children].map(b => b.dataset.action);
|
||||
// rebuilding an unchanged row would detach a focused action on every poll
|
||||
if (reviewActions.dataset.jobId === job.id && shown.join() === wanted.join()) return;
|
||||
reviewActions.dataset.jobId = job.id;
|
||||
reviewActions.replaceChildren(...wanted.map(a => actionButton(a, job.id)));
|
||||
}
|
||||
|
||||
function renderLineage(job) {
|
||||
if (reviewLineage.dataset.jobId === job.id) return;
|
||||
reviewLineage.dataset.jobId = job.id;
|
||||
reviewLineage.replaceChildren();
|
||||
reviewLineage.hidden = !job.rebuilt_from;
|
||||
if (!job.rebuilt_from) return;
|
||||
const parent = JOBS.find(j => j.id === job.rebuilt_from);
|
||||
const link = document.createElement("button");
|
||||
link.type = "button";
|
||||
link.className = "lineage";
|
||||
link.textContent = parent ? `rebuilt from ${parent.title}`
|
||||
: "rebuilt from an earlier run";
|
||||
link.addEventListener("click", () => selectJob(job.rebuilt_from));
|
||||
reviewLineage.append(link);
|
||||
}
|
||||
|
||||
async function refreshDetail() {
|
||||
const id = selectedId;
|
||||
const resp = await fetch(`/api/jobs/${id}`);
|
||||
@@ -842,6 +971,8 @@ async function refreshDetail() {
|
||||
reviewError.textContent = job.error ?? "";
|
||||
renderWarnings(job);
|
||||
renderOutputPath(job);
|
||||
renderLineage(job);
|
||||
renderActions(job);
|
||||
detail.textContent = job.log.join("\n");
|
||||
}
|
||||
|
||||
|
||||
+173
-25
@@ -13,8 +13,10 @@ from fastapi.concurrency import run_in_threadpool
|
||||
from fastapi.responses import FileResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
|
||||
from . import rebuild as rebuild_policy
|
||||
from .config import Config
|
||||
from .pipeline import STAGE_ORDER, PipelineError, SongInput, run_pipeline
|
||||
from .pipeline import (STAGE_ORDER, PipelineError, SongInput, run_pipeline,
|
||||
song_slug)
|
||||
|
||||
STATIC = Path(__file__).parent / "static"
|
||||
STAGE_LINE = re.compile(r"^\[(\w+)\] (?:running|cached, skipping)")
|
||||
@@ -32,6 +34,7 @@ class Job:
|
||||
warnings: list[str] = field(default_factory=list)
|
||||
error: str | None = None
|
||||
inp: SongInput | None = None
|
||||
rebuilt_from: str | None = None
|
||||
created_at: float = field(default_factory=time.time)
|
||||
started_at: float | None = None
|
||||
finished_at: float | None = None
|
||||
@@ -47,6 +50,47 @@ async def _save_upload(upload: UploadFile, dest: Path) -> None:
|
||||
await run_in_threadpool(shutil.copyfileobj, upload.file, f)
|
||||
|
||||
|
||||
async def _store_uploads(cfg: Config, job_id: str, video: UploadFile | None,
|
||||
cover: UploadFile | None) -> tuple[Path | None, Path | None]:
|
||||
updir = cfg.work_dir / "uploads" / job_id
|
||||
paths = []
|
||||
for upload, default in ((video, "video"), (cover, "cover")):
|
||||
if not (upload and upload.filename):
|
||||
paths.append(None)
|
||||
continue
|
||||
updir.mkdir(parents=True, exist_ok=True)
|
||||
dest = updir / _safe_filename(upload.filename, default)
|
||||
await _save_upload(upload, dest)
|
||||
paths.append(dest)
|
||||
return paths[0], paths[1]
|
||||
|
||||
|
||||
def _cleanup_uploads(cfg: Config, job_id: str, video_path: Path | None) -> None:
|
||||
"""Drop the video upload a finished job no longer needs.
|
||||
|
||||
Covers are left behind: they are small, and a rebuild inherits cover_path, so
|
||||
removing one would silently strip #COVER from the rebuilt song. A rebuild can
|
||||
also inherit its parent's upload, so the directory is not always uploads/<job_id>.
|
||||
Never raises — the caller has already marked the job done.
|
||||
"""
|
||||
uploads = (cfg.work_dir / "uploads").resolve()
|
||||
dirs = {uploads / job_id}
|
||||
if video_path is not None:
|
||||
target = Path(video_path)
|
||||
parent = target.resolve().parent
|
||||
if parent.parent == uploads:
|
||||
try:
|
||||
target.unlink(missing_ok=True)
|
||||
except OSError:
|
||||
pass
|
||||
dirs.add(parent)
|
||||
for d in dirs:
|
||||
try:
|
||||
d.rmdir()
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def create_app(cfg: Config, runner=run_pipeline) -> FastAPI:
|
||||
app = FastAPI()
|
||||
app.mount("/static", StaticFiles(directory=STATIC), name="static")
|
||||
@@ -55,6 +99,35 @@ def create_app(cfg: Config, runner=run_pipeline) -> FastAPI:
|
||||
lock = threading.Lock()
|
||||
todo: queue.Queue[str] = queue.Queue()
|
||||
|
||||
def _prepare_rebuild(job: Job, log) -> str | None:
|
||||
"""Relocate cached work and report the stage this rebuild restarts from."""
|
||||
if not job.rebuilt_from:
|
||||
return None
|
||||
with lock:
|
||||
parent = jobs.get(job.rebuilt_from)
|
||||
if parent is None:
|
||||
return None
|
||||
force_from = rebuild_policy.plan(parent.inp, job.inp)
|
||||
changed = rebuild_policy.changed_fields(parent.inp, job.inp)
|
||||
rebuild_policy.relocate_work(parent.inp, job.inp, cfg)
|
||||
log(f"[rebuild] restarting from {force_from or 'the first incomplete stage'}"
|
||||
f" ({', '.join(changed) if changed else 'nothing'} changed)")
|
||||
return force_from
|
||||
|
||||
def _drop_superseded_output(job: Job, log) -> None:
|
||||
if not job.rebuilt_from:
|
||||
return
|
||||
with lock:
|
||||
parent = jobs.get(job.rebuilt_from)
|
||||
if parent is None or parent.status != "done":
|
||||
return
|
||||
old = rebuild_policy.superseded_output(parent.output_dir, job.output_dir,
|
||||
cfg.output_dir)
|
||||
if old is None:
|
||||
return
|
||||
shutil.rmtree(old, ignore_errors=True)
|
||||
log(f"[rebuild] removed superseded {old.name}")
|
||||
|
||||
def worker():
|
||||
while True:
|
||||
job_id = todo.get()
|
||||
@@ -70,11 +143,17 @@ def create_app(cfg: Config, runner=run_pipeline) -> FastAPI:
|
||||
job.stage = m[1]
|
||||
|
||||
try:
|
||||
result = runner(job.inp, cfg, log=log)
|
||||
force_from = _prepare_rebuild(job, log)
|
||||
result = runner(job.inp, cfg, log=log, force_from=force_from)
|
||||
with lock:
|
||||
job.status = "done"
|
||||
job.output_dir = str(result.output_dir)
|
||||
job.warnings = list(result.warnings)
|
||||
_drop_superseded_output(job, log)
|
||||
with lock:
|
||||
job.status = "done"
|
||||
# uploads survive a failure so the job stays rerunnable; acquire
|
||||
# has already copied the video into the work dir by now.
|
||||
_cleanup_uploads(cfg, job_id, job.inp.video_path)
|
||||
except BaseException as e:
|
||||
with lock:
|
||||
job.status = "failed"
|
||||
@@ -82,13 +161,28 @@ def create_app(cfg: Config, runner=run_pipeline) -> FastAPI:
|
||||
finally:
|
||||
with lock:
|
||||
job.finished_at = time.time()
|
||||
# safe: acquire copies the upload into the per-song work dir
|
||||
# and package reads cover_path directly, both inside runner()
|
||||
# above, before this cleanup runs.
|
||||
shutil.rmtree(cfg.work_dir / "uploads" / job_id, ignore_errors=True)
|
||||
|
||||
threading.Thread(target=worker, daemon=True).start()
|
||||
|
||||
def _enqueue(job_id: str, inp: SongInput,
|
||||
rebuilt_from: str | None = None) -> dict:
|
||||
job = Job(id=job_id, title=inp.title, artist=inp.artist, inp=inp,
|
||||
rebuilt_from=rebuilt_from)
|
||||
with lock:
|
||||
jobs[job_id] = job
|
||||
order.append(job_id)
|
||||
todo.put(job_id)
|
||||
return {"id": job_id}
|
||||
|
||||
def _finished_job(job_id: str) -> Job:
|
||||
with lock:
|
||||
job = jobs.get(job_id)
|
||||
if job is None:
|
||||
raise HTTPException(404)
|
||||
if job.status in ("queued", "running"):
|
||||
raise HTTPException(409, "job is still in progress")
|
||||
return job
|
||||
|
||||
@app.get("/")
|
||||
def index():
|
||||
return FileResponse(STATIC / "index.html")
|
||||
@@ -100,6 +194,8 @@ def create_app(cfg: Config, runner=run_pipeline) -> FastAPI:
|
||||
language: str | None = Form(None), bpm: float | None = Form(None),
|
||||
video: UploadFile | None = File(None),
|
||||
cover: UploadFile | None = File(None)):
|
||||
youtube_url, genre, language = (youtube_url or None, genre or None,
|
||||
language or None)
|
||||
has_video = bool(video and video.filename)
|
||||
if bool(youtube_url) == has_video:
|
||||
raise HTTPException(400, "provide exactly one of youtube_url / video")
|
||||
@@ -107,27 +203,64 @@ def create_app(cfg: Config, runner=run_pipeline) -> FastAPI:
|
||||
raise HTTPException(400, f"bpm must be a finite value > 0, got {bpm!r}")
|
||||
|
||||
job_id = uuid.uuid4().hex
|
||||
updir = cfg.work_dir / "uploads" / job_id
|
||||
video_path = cover_path = None
|
||||
if has_video:
|
||||
updir.mkdir(parents=True, exist_ok=True)
|
||||
video_path = updir / _safe_filename(video.filename, "video")
|
||||
await _save_upload(video, video_path)
|
||||
if cover and cover.filename:
|
||||
updir.mkdir(parents=True, exist_ok=True)
|
||||
cover_path = updir / _safe_filename(cover.filename, "cover")
|
||||
await _save_upload(cover, cover_path)
|
||||
video_path, cover_path = await _store_uploads(cfg, job_id, video, cover)
|
||||
|
||||
inp = SongInput(title=title, artist=artist, lyrics_text=lyrics,
|
||||
youtube_url=youtube_url, video_path=video_path,
|
||||
genre=genre, year=year, language=language,
|
||||
cover_path=cover_path, bpm_override=bpm)
|
||||
job = Job(id=job_id, title=title, artist=artist, inp=inp)
|
||||
with lock:
|
||||
jobs[job_id] = job
|
||||
order.append(job_id)
|
||||
todo.put(job_id)
|
||||
return {"id": job_id}
|
||||
return _enqueue(job_id, inp)
|
||||
|
||||
@app.post("/api/jobs/{job_id}/rerun", status_code=201)
|
||||
def rerun(job_id: str):
|
||||
parent = _finished_job(job_id)
|
||||
return _enqueue(uuid.uuid4().hex, parent.inp, rebuilt_from=job_id)
|
||||
|
||||
def _require_source(parent_inp: SongInput, inp: SongInput,
|
||||
force_from: str | None) -> None:
|
||||
"""Refuse a rebuild that will rerun acquire against an upload we no longer have."""
|
||||
if inp.video_path is None or inp.video_path.exists():
|
||||
return
|
||||
if force_from != "acquire":
|
||||
# acquire is cached under the old slug until the worker renames it
|
||||
for slug in {song_slug(parent_inp), song_slug(inp)}:
|
||||
work = cfg.work_dir / slug
|
||||
if (work / "meta.json").exists() and (work / "audio.wav").exists():
|
||||
return
|
||||
raise HTTPException(400, "the original video upload is no longer available; "
|
||||
"attach the video file again")
|
||||
|
||||
@app.post("/api/jobs/{job_id}/rebuild", status_code=201)
|
||||
async def rebuild(job_id: str, title: str = Form(...), artist: str = Form(...),
|
||||
lyrics: str = Form(...), youtube_url: str | None = Form(None),
|
||||
genre: str | None = Form(None), year: int | None = Form(None),
|
||||
language: str | None = Form(None), bpm: float | None = Form(None),
|
||||
video: UploadFile | None = File(None),
|
||||
cover: UploadFile | None = File(None)):
|
||||
parent = _finished_job(job_id)
|
||||
# a cleared field arrives as "" from some clients; "" and None must not
|
||||
# look like different inputs to plan()
|
||||
youtube_url, genre, language = (youtube_url or None, genre or None,
|
||||
language or None)
|
||||
has_video = bool(video and video.filename)
|
||||
if youtube_url and has_video:
|
||||
raise HTTPException(400, "provide exactly one of youtube_url / video")
|
||||
if bpm is not None and (not math.isfinite(bpm) or bpm <= 0):
|
||||
raise HTTPException(400, f"bpm must be a finite value > 0, got {bpm!r}")
|
||||
|
||||
new_id = uuid.uuid4().hex
|
||||
video_path, cover_path = await _store_uploads(cfg, new_id, video, cover)
|
||||
if not youtube_url and not has_video:
|
||||
youtube_url, video_path = parent.inp.youtube_url, parent.inp.video_path
|
||||
if cover_path is None:
|
||||
cover_path = parent.inp.cover_path
|
||||
|
||||
inp = SongInput(title=title, artist=artist, lyrics_text=lyrics,
|
||||
youtube_url=youtube_url, video_path=video_path,
|
||||
genre=genre, year=year, language=language,
|
||||
cover_path=cover_path, bpm_override=bpm)
|
||||
_require_source(parent.inp, inp, rebuild_policy.plan(parent.inp, inp))
|
||||
return _enqueue(new_id, inp, rebuilt_from=job_id)
|
||||
|
||||
@app.get("/api/stages")
|
||||
def list_stages():
|
||||
@@ -139,7 +272,7 @@ def create_app(cfg: Config, runner=run_pipeline) -> FastAPI:
|
||||
return [{"id": j.id, "title": j.title, "artist": j.artist,
|
||||
"status": j.status, "stage": j.stage,
|
||||
"created_at": j.created_at, "started_at": j.started_at,
|
||||
"finished_at": j.finished_at}
|
||||
"finished_at": j.finished_at, "rebuilt_from": j.rebuilt_from}
|
||||
for j in (jobs[i] for i in reversed(order))]
|
||||
|
||||
@app.get("/api/jobs/{job_id}")
|
||||
@@ -153,7 +286,22 @@ def create_app(cfg: Config, runner=run_pipeline) -> FastAPI:
|
||||
"log": job.log[-100:], "output_dir": job.output_dir,
|
||||
"warnings": list(job.warnings), "error": job.error,
|
||||
"created_at": job.created_at, "started_at": job.started_at,
|
||||
"finished_at": job.finished_at}
|
||||
"finished_at": job.finished_at, "rebuilt_from": job.rebuilt_from}
|
||||
|
||||
@app.get("/api/jobs/{job_id}/input")
|
||||
def get_input(job_id: str):
|
||||
with lock:
|
||||
job = jobs.get(job_id)
|
||||
if job is None:
|
||||
raise HTTPException(404)
|
||||
inp = job.inp
|
||||
# keys are the form's field names so the page can prefill by name;
|
||||
# paths are reduced to filenames, which is all the browser can use.
|
||||
return {"title": inp.title, "artist": inp.artist, "lyrics": inp.lyrics_text,
|
||||
"youtube_url": inp.youtube_url, "genre": inp.genre, "year": inp.year,
|
||||
"language": inp.language, "bpm": inp.bpm_override,
|
||||
"video_filename": inp.video_path.name if inp.video_path else None,
|
||||
"cover_filename": inp.cover_path.name if inp.cover_path else None}
|
||||
|
||||
return app
|
||||
|
||||
|
||||
@@ -38,6 +38,12 @@ def test_youtube(tmp_path, monkeypatch):
|
||||
assert meta["video_file"] == "source_video.webm"
|
||||
assert meta["thumbnail"] == "source_video.jpg"
|
||||
|
||||
def test_outputs_are_meta_and_audio(tmp_path):
|
||||
"""_require_source in web.py hardcodes these filenames; pin them here."""
|
||||
ctx = make_ctx(tmp_path, youtube_url="https://yt/x")
|
||||
outputs = acquire.acquire_stage().outputs(ctx)
|
||||
assert [p.name for p in outputs] == ["meta.json", "audio.wav"]
|
||||
|
||||
def test_requires_exactly_one_source(tmp_path):
|
||||
import pytest
|
||||
from karaoke_maker.pipeline import PipelineError
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
import dataclasses
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from karaoke_maker.config import Config
|
||||
from karaoke_maker.pipeline import STAGE_ORDER, PipelineError, SongInput
|
||||
from karaoke_maker.rebuild import (FIELD_STAGE, changed_fields, plan,
|
||||
relocate_work, superseded_output)
|
||||
|
||||
|
||||
def song(**kw):
|
||||
base = dict(title="T", artist="A", lyrics_text="la", youtube_url="http://y")
|
||||
return SongInput(**{**base, **kw})
|
||||
|
||||
|
||||
def cfg_for(tmp_path):
|
||||
return Config(whisper_url="u", work_dir=tmp_path / "work",
|
||||
output_dir=tmp_path / "out")
|
||||
|
||||
|
||||
def test_every_song_input_field_is_mapped():
|
||||
"""A field added to SongInput without a FIELD_STAGE entry would make edits to
|
||||
it invalidate nothing, silently building from stale intermediates."""
|
||||
actual = {f.name for f in dataclasses.fields(SongInput)}
|
||||
assert actual - set(FIELD_STAGE) == set()
|
||||
assert set(FIELD_STAGE) - actual == set()
|
||||
|
||||
|
||||
def test_every_mapped_stage_is_a_real_stage():
|
||||
assert set(FIELD_STAGE.values()) <= set(STAGE_ORDER)
|
||||
|
||||
|
||||
def test_no_change_means_no_force_from():
|
||||
assert plan(song(), song()) is None
|
||||
|
||||
|
||||
@pytest.mark.parametrize("field,value,stage", [
|
||||
("youtube_url", "http://other", "acquire"),
|
||||
("video_path", Path("/tmp/v.mp4"), "acquire"),
|
||||
("language", "de", "transcribe"),
|
||||
("lyrics_text", "different words", "align"),
|
||||
("title", "Other", "grid"),
|
||||
("artist", "Other", "grid"),
|
||||
("genre", "rock", "grid"),
|
||||
("year", 1999, "grid"),
|
||||
("bpm_override", 123.0, "grid"),
|
||||
("cover_path", Path("/tmp/c.jpg"), "package"),
|
||||
])
|
||||
def test_single_field_maps_to_its_stage(field, value, stage):
|
||||
assert plan(song(), song(**{field: value})) == stage
|
||||
|
||||
|
||||
def test_earliest_stage_wins_when_several_change():
|
||||
new = song(lyrics_text="different", year=1999, language="de")
|
||||
assert plan(song(), new) == "transcribe"
|
||||
|
||||
|
||||
def test_changed_fields_lists_what_moved():
|
||||
assert sorted(changed_fields(song(), song(year=1999, genre="rock"))) == \
|
||||
["genre", "year"]
|
||||
|
||||
|
||||
def test_slug_fields_invalidate_no_later_than_the_stage_that_writes_the_slug():
|
||||
"""song.txt embeds the slug in #MP3/#COVER/#VIDEO and is written by `grid`,
|
||||
so a title/artist edit must rerun grid or earlier for relocate_work to be safe."""
|
||||
for field in ("title", "artist"):
|
||||
assert STAGE_ORDER.index(FIELD_STAGE[field]) <= STAGE_ORDER.index("grid")
|
||||
|
||||
|
||||
def test_relocate_moves_the_work_dir_when_the_slug_changes(tmp_path):
|
||||
cfg = cfg_for(tmp_path)
|
||||
src = cfg.work_dir / "A - T"
|
||||
src.mkdir(parents=True)
|
||||
(src / "audio.wav").write_bytes(b"x")
|
||||
assert relocate_work(song(), song(title="Fixed"), cfg) is True
|
||||
assert not src.exists()
|
||||
assert (cfg.work_dir / "A - Fixed" / "audio.wav").read_bytes() == b"x"
|
||||
|
||||
|
||||
def test_relocate_is_a_noop_when_the_slug_is_unchanged(tmp_path):
|
||||
cfg = cfg_for(tmp_path)
|
||||
src = cfg.work_dir / "A - T"
|
||||
src.mkdir(parents=True)
|
||||
assert relocate_work(song(), song(year=1999), cfg) is False
|
||||
assert src.exists()
|
||||
|
||||
|
||||
def test_relocate_is_a_noop_when_there_is_nothing_to_move(tmp_path):
|
||||
cfg = cfg_for(tmp_path)
|
||||
assert relocate_work(song(), song(title="Fixed"), cfg) is False
|
||||
|
||||
|
||||
def test_relocate_refuses_to_clobber_an_existing_work_dir(tmp_path):
|
||||
cfg = cfg_for(tmp_path)
|
||||
(cfg.work_dir / "A - T").mkdir(parents=True)
|
||||
(cfg.work_dir / "A - Fixed").mkdir(parents=True)
|
||||
with pytest.raises(PipelineError, match="already exists"):
|
||||
relocate_work(song(), song(title="Fixed"), cfg)
|
||||
assert (cfg.work_dir / "A - T").exists()
|
||||
|
||||
|
||||
def test_superseded_output_names_the_replaced_folder(tmp_path):
|
||||
root = tmp_path / "out"
|
||||
assert superseded_output(str(root / "A - Old"), str(root / "A - New"), root) == \
|
||||
(root / "A - Old").resolve()
|
||||
|
||||
|
||||
def test_superseded_output_is_none_when_the_slug_did_not_change(tmp_path):
|
||||
root = tmp_path / "out"
|
||||
assert superseded_output(str(root / "A - T"), str(root / "A - T"), root) is None
|
||||
|
||||
|
||||
def test_superseded_output_is_none_without_a_previous_build(tmp_path):
|
||||
root = tmp_path / "out"
|
||||
assert superseded_output(None, str(root / "A - T"), root) is None
|
||||
|
||||
|
||||
def test_superseded_output_refuses_a_path_outside_the_output_root(tmp_path):
|
||||
root = tmp_path / "out"
|
||||
assert superseded_output(str(tmp_path / "elsewhere"), str(root / "A - T"), root) is None
|
||||
|
||||
|
||||
def test_superseded_output_refuses_the_output_root_itself(tmp_path):
|
||||
root = tmp_path / "out"
|
||||
assert superseded_output(str(root), str(root / "A - T"), root) is None
|
||||
+462
-4
@@ -7,8 +7,8 @@ import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from karaoke_maker.config import Config
|
||||
from karaoke_maker.pipeline import STAGE_ORDER, PipelineError, PipelineResult
|
||||
from karaoke_maker.web import create_app
|
||||
from karaoke_maker.pipeline import STAGE_ORDER, PipelineError, PipelineResult, song_slug
|
||||
from karaoke_maker.web import STAGE_LINE, create_app
|
||||
|
||||
|
||||
def make_client(tmp_path, runner):
|
||||
@@ -52,6 +52,16 @@ def test_failure_reported(tmp_path):
|
||||
assert job["error"] == "boom"
|
||||
|
||||
|
||||
def test_submit_normalises_a_blank_genre_to_none(tmp_path):
|
||||
seen = []
|
||||
client, _ = make_client(tmp_path, capture_runner_factory(seen))
|
||||
resp = client.post("/api/jobs", data={
|
||||
"title": "T", "artist": "A", "lyrics": "x",
|
||||
"youtube_url": "http://y", "genre": ""})
|
||||
wait_status(client, resp.json()["id"], "done")
|
||||
assert seen[0]["inp"].genre is None
|
||||
|
||||
|
||||
def test_requires_one_source(tmp_path):
|
||||
client, _ = make_client(tmp_path, ok_runner)
|
||||
resp = client.post("/api/jobs", data={"title": "T", "artist": "A", "lyrics": "x"})
|
||||
@@ -153,7 +163,8 @@ def test_upload_dir_cleaned_up_after_success(tmp_path):
|
||||
assert not (cfg.work_dir / "uploads" / job_id).exists()
|
||||
|
||||
|
||||
def test_upload_dir_cleaned_up_after_failure(tmp_path):
|
||||
def test_upload_retained_after_failure(tmp_path):
|
||||
"""A job that died inside acquire must keep its source, or it can never rerun."""
|
||||
def bad_runner(*a, **kw):
|
||||
raise PipelineError("boom")
|
||||
client, cfg = make_client(tmp_path, bad_runner)
|
||||
@@ -161,7 +172,51 @@ def test_upload_dir_cleaned_up_after_failure(tmp_path):
|
||||
files={"video": ("v.mp4", io.BytesIO(b"data"), "video/mp4")})
|
||||
job_id = resp.json()["id"]
|
||||
wait_status(client, job_id, "failed")
|
||||
assert not (cfg.work_dir / "uploads" / job_id).exists()
|
||||
assert (cfg.work_dir / "uploads" / job_id / "v.mp4").exists()
|
||||
|
||||
|
||||
def test_cover_upload_kept_after_success(tmp_path):
|
||||
"""A rebuild inherits cover_path; deleting it would silently drop #COVER."""
|
||||
client, cfg = make_client(tmp_path, ok_runner)
|
||||
resp = client.post("/api/jobs", data={"title": "T", "artist": "A", "lyrics": "x"},
|
||||
files={"video": ("v.mp4", io.BytesIO(b"data"), "video/mp4"),
|
||||
"cover": ("c.jpg", io.BytesIO(b"img"), "image/jpeg")})
|
||||
job_id = resp.json()["id"]
|
||||
wait_status(client, job_id, "done")
|
||||
updir = cfg.work_dir / "uploads" / job_id
|
||||
assert (updir / "c.jpg").read_bytes() == b"img"
|
||||
assert not (updir / "v.mp4").exists()
|
||||
|
||||
|
||||
def test_cleanup_uploads_removes_an_inherited_upload_dir(tmp_path):
|
||||
"""A rebuild's video may live under its parent's id, not its own."""
|
||||
from karaoke_maker.web import _cleanup_uploads
|
||||
cfg = Config(whisper_url="u", work_dir=tmp_path / "work",
|
||||
output_dir=tmp_path / "out")
|
||||
parent = cfg.work_dir / "uploads" / "parent"
|
||||
parent.mkdir(parents=True)
|
||||
(parent / "v.mp4").write_bytes(b"data")
|
||||
_cleanup_uploads(cfg, "child", parent / "v.mp4")
|
||||
assert not parent.exists()
|
||||
|
||||
|
||||
def test_cleanup_uploads_ignores_paths_outside_the_upload_root(tmp_path):
|
||||
from karaoke_maker.web import _cleanup_uploads
|
||||
cfg = Config(whisper_url="u", work_dir=tmp_path / "work",
|
||||
output_dir=tmp_path / "out")
|
||||
outside = tmp_path / "somewhere" / "keep.mp4"
|
||||
outside.parent.mkdir(parents=True)
|
||||
outside.write_bytes(b"data")
|
||||
_cleanup_uploads(cfg, "child", outside)
|
||||
assert outside.exists()
|
||||
|
||||
|
||||
def test_cleanup_uploads_tolerates_a_missing_file(tmp_path):
|
||||
from karaoke_maker.web import _cleanup_uploads
|
||||
cfg = Config(whisper_url="u", work_dir=tmp_path / "work",
|
||||
output_dir=tmp_path / "out")
|
||||
_cleanup_uploads(cfg, "child", cfg.work_dir / "uploads" / "child" / "gone.mp4")
|
||||
_cleanup_uploads(cfg, "child", None)
|
||||
|
||||
|
||||
def test_jobs_run_one_at_a_time(tmp_path):
|
||||
@@ -453,6 +508,29 @@ def test_running_stage_pulses_unless_motion_is_reduced():
|
||||
assert _pulse_flaws(_index_html()) == []
|
||||
|
||||
|
||||
def _submit_body(html):
|
||||
start = html.index('form.addEventListener("submit"')
|
||||
return html[start:html.index("\n});\n", start)]
|
||||
|
||||
|
||||
def _rebuild_flaws(html):
|
||||
"""Ways rebuild mode could quietly stop working."""
|
||||
flaws, submit = [], _submit_body(html)
|
||||
if "rebuildOf ?" not in submit or "/rebuild" not in submit:
|
||||
flaws.append("the form always posts to /api/jobs, so edits create a new song")
|
||||
|
||||
actions = _fn_body(html, "renderActions")
|
||||
if not re.search(r"if \(.*dataset\.jobId === job\.id.*\) return;", actions):
|
||||
flaws.append("the action row is rebuilt on every poll, blurring a focused button")
|
||||
if '"failed"' not in actions or '"done"' not in actions:
|
||||
flaws.append("the action row does not key off the job status")
|
||||
|
||||
start = _fn_body(html, "startRebuild")
|
||||
if "/input" not in start or "form.elements[" not in start:
|
||||
flaws.append("rebuild mode does not prefill the form from the job's input")
|
||||
return flaws
|
||||
|
||||
|
||||
# each mutation is a plausible regression; the checker above must reject all of them
|
||||
MUTATIONS = [
|
||||
(_focus_flaws, "queue: card moved unconditionally",
|
||||
@@ -475,6 +553,12 @@ MUTATIONS = [
|
||||
".stage-pending .stage-dot { color: var(--aqua); animation: stage-pulse"),
|
||||
(_pulse_flaws, "pulse: reduced motion no longer wins",
|
||||
"animation: none !important", "animation: none"),
|
||||
(_rebuild_flaws, "rebuild: form posts to /api/jobs even in rebuild mode",
|
||||
'const url = rebuildOf ? `/api/jobs/${rebuildOf}/rebuild` : "/api/jobs";',
|
||||
'const url = "/api/jobs";'),
|
||||
(_rebuild_flaws, "rebuild: action row rebuilt on every poll",
|
||||
"if (reviewActions.dataset.jobId === job.id && shown.join() === wanted.join()) return;",
|
||||
""),
|
||||
]
|
||||
|
||||
|
||||
@@ -500,3 +584,377 @@ def test_narrow_stage_labels_stay_in_the_accessibility_tree():
|
||||
_page_css(_index_html()))
|
||||
assert hidden, "the compact stage track rule moved or was renamed"
|
||||
assert "display: none" not in hidden[1] and "visibility: hidden" not in hidden[1]
|
||||
|
||||
|
||||
def test_rebuild_mode_reuses_the_one_form():
|
||||
html = _index_html()
|
||||
assert html.count("<form") == 1
|
||||
assert _rebuild_flaws(html) == []
|
||||
|
||||
|
||||
def test_index_has_rebuild_hooks():
|
||||
html = _index_html()
|
||||
for hook in ("review-actions", "review-lineage", "cancel-rebuild",
|
||||
'data-keep="video"', 'data-keep="cover"'):
|
||||
assert hook in html, hook
|
||||
|
||||
|
||||
def test_rebuild_controls_are_not_offered_mid_run():
|
||||
"""Both endpoints answer 409 while a job is queued or running."""
|
||||
actions = _fn_body(_index_html(), "renderActions")
|
||||
assert '"queued"' not in actions and '"running"' not in actions
|
||||
|
||||
|
||||
def test_lineage_marker_survives_in_the_accessibility_tree():
|
||||
html = _index_html()
|
||||
body = _fn_body(html, "paintCard")
|
||||
assert "rebuilt_from" in body
|
||||
assert '" rebuilt"' in body
|
||||
assert '"sr-only"' in _fn_body(html, "jobCard")
|
||||
|
||||
|
||||
def make_done_job(client, **overrides):
|
||||
data = {"title": "T", "artist": "A", "lyrics": "x", "youtube_url": "http://y"}
|
||||
data.update(overrides)
|
||||
job_id = client.post("/api/jobs", data=data).json()["id"]
|
||||
wait_status(client, job_id, "done")
|
||||
return job_id
|
||||
|
||||
|
||||
def test_rerun_creates_a_linked_job(tmp_path):
|
||||
client, _ = make_client(tmp_path, ok_runner)
|
||||
parent = make_done_job(client)
|
||||
resp = client.post(f"/api/jobs/{parent}/rerun")
|
||||
assert resp.status_code == 201
|
||||
child = resp.json()["id"]
|
||||
assert child != parent
|
||||
job = wait_status(client, child, "done")
|
||||
assert job["rebuilt_from"] == parent
|
||||
|
||||
|
||||
def test_rerun_copies_the_parents_input(tmp_path):
|
||||
seen = []
|
||||
|
||||
def capture(inp, cfg, log=print, force_from=None, stages=None):
|
||||
seen.append(inp)
|
||||
return PipelineResult(output_dir=cfg.output_dir / "X")
|
||||
|
||||
client, _ = make_client(tmp_path, capture)
|
||||
parent = client.post("/api/jobs", data={
|
||||
"title": "T", "artist": "A", "lyrics": "x", "youtube_url": "http://y",
|
||||
"genre": "pop", "year": "1998", "language": "en", "bpm": "120"}).json()["id"]
|
||||
wait_status(client, parent, "done")
|
||||
child = client.post(f"/api/jobs/{parent}/rerun").json()["id"]
|
||||
wait_status(client, child, "done")
|
||||
assert seen[0] == seen[1]
|
||||
|
||||
|
||||
def test_rerun_of_a_failed_job_is_allowed(tmp_path):
|
||||
calls = []
|
||||
|
||||
def flaky(inp, cfg, log=print, force_from=None, stages=None):
|
||||
calls.append(1)
|
||||
if len(calls) == 1:
|
||||
raise PipelineError("transient")
|
||||
return PipelineResult(output_dir=cfg.output_dir / "X")
|
||||
|
||||
client, _ = make_client(tmp_path, flaky)
|
||||
parent = client.post("/api/jobs", data={
|
||||
"title": "T", "artist": "A", "lyrics": "x",
|
||||
"youtube_url": "http://y"}).json()["id"]
|
||||
wait_status(client, parent, "failed")
|
||||
child = client.post(f"/api/jobs/{parent}/rerun").json()["id"]
|
||||
wait_status(client, child, "done")
|
||||
|
||||
|
||||
def test_rerun_of_an_unknown_job_is_404(tmp_path):
|
||||
client, _ = make_client(tmp_path, ok_runner)
|
||||
assert client.post("/api/jobs/nope/rerun").status_code == 404
|
||||
|
||||
|
||||
def test_rerun_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)
|
||||
parent = client.post("/api/jobs", data={
|
||||
"title": "T", "artist": "A", "lyrics": "x",
|
||||
"youtube_url": "http://y"}).json()["id"]
|
||||
try:
|
||||
wait_status(client, parent, "running")
|
||||
assert client.post(f"/api/jobs/{parent}/rerun").status_code == 409
|
||||
finally:
|
||||
release.set()
|
||||
wait_status(client, parent, "done")
|
||||
|
||||
|
||||
def test_rebuilt_from_is_null_for_an_ordinary_job(tmp_path):
|
||||
client, _ = make_client(tmp_path, ok_runner)
|
||||
job_id = make_done_job(client)
|
||||
assert client.get(f"/api/jobs/{job_id}").json()["rebuilt_from"] is None
|
||||
listed = {j["id"]: j for j in client.get("/api/jobs").json()}
|
||||
assert listed[job_id]["rebuilt_from"] is None
|
||||
|
||||
|
||||
def test_input_echoes_the_submitted_form(tmp_path):
|
||||
client, _ = make_client(tmp_path, ok_runner)
|
||||
job_id = make_done_job(client, genre="pop", year="1998", language="en", bpm="120")
|
||||
body = client.get(f"/api/jobs/{job_id}/input").json()
|
||||
assert body == {"title": "T", "artist": "A", "lyrics": "x",
|
||||
"youtube_url": "http://y", "genre": "pop", "year": 1998,
|
||||
"language": "en", "bpm": 120.0,
|
||||
"video_filename": None, "cover_filename": None}
|
||||
|
||||
|
||||
def test_input_keys_match_the_form_field_names(tmp_path):
|
||||
"""The page assigns straight into form.elements[name]; a rename breaks prefill."""
|
||||
html = _index_html()
|
||||
names = set(re.findall(r'name="([a-z_]+)"', html))
|
||||
client, _ = make_client(tmp_path, ok_runner)
|
||||
body = client.get(f"/api/jobs/{make_done_job(client)}/input").json()
|
||||
assert set(body) - names == {"video_filename", "cover_filename"}
|
||||
|
||||
|
||||
def test_input_reports_bare_filenames_only(tmp_path):
|
||||
client, cfg = make_client(tmp_path, ok_runner)
|
||||
resp = client.post("/api/jobs", data={"title": "T", "artist": "A", "lyrics": "x"},
|
||||
files={"video": ("v.mp4", io.BytesIO(b"data"), "video/mp4"),
|
||||
"cover": ("c.jpg", io.BytesIO(b"img"), "image/jpeg")})
|
||||
job_id = resp.json()["id"]
|
||||
wait_status(client, job_id, "done")
|
||||
body = client.get(f"/api/jobs/{job_id}/input").json()
|
||||
assert body["video_filename"] == "v.mp4"
|
||||
assert body["cover_filename"] == "c.jpg"
|
||||
assert "/" not in body["video_filename"]
|
||||
assert str(tmp_path) not in resp.text
|
||||
|
||||
|
||||
def test_input_of_an_unknown_job_is_404(tmp_path):
|
||||
client, _ = make_client(tmp_path, ok_runner)
|
||||
assert client.get("/api/jobs/nope/input").status_code == 404
|
||||
|
||||
|
||||
def capture_runner_factory(seen):
|
||||
def capture(inp, cfg, log=print, force_from=None, stages=None):
|
||||
seen.append({"inp": inp, "force_from": force_from})
|
||||
return PipelineResult(output_dir=cfg.output_dir / song_slug(inp))
|
||||
return capture
|
||||
|
||||
|
||||
def rebuild_form(**overrides):
|
||||
"""The form as the page posts it — empty fields are stripped, not sent blank."""
|
||||
data = {"title": "T", "artist": "A", "lyrics": "x", "youtube_url": "http://y"}
|
||||
data.update(overrides)
|
||||
return {k: v for k, v in data.items() if v != ""}
|
||||
|
||||
|
||||
def test_rebuild_forces_the_stage_the_edit_invalidated(tmp_path):
|
||||
seen = []
|
||||
client, _ = make_client(tmp_path, capture_runner_factory(seen))
|
||||
parent = make_done_job(client)
|
||||
child = client.post(f"/api/jobs/{parent}/rebuild",
|
||||
data=rebuild_form(lyrics="new words")).json()["id"]
|
||||
wait_status(client, child, "done")
|
||||
assert seen[1]["force_from"] == "align"
|
||||
assert seen[1]["inp"].lyrics_text == "new words"
|
||||
|
||||
|
||||
def test_rebuild_with_no_edits_forces_nothing(tmp_path):
|
||||
seen = []
|
||||
client, _ = make_client(tmp_path, capture_runner_factory(seen))
|
||||
parent = make_done_job(client)
|
||||
child = client.post(f"/api/jobs/{parent}/rebuild",
|
||||
data=rebuild_form()).json()["id"]
|
||||
wait_status(client, child, "done")
|
||||
assert seen[1]["force_from"] is None
|
||||
|
||||
|
||||
def test_rebuild_renames_the_work_dir_on_a_retitle(tmp_path):
|
||||
seen = []
|
||||
client, cfg = make_client(tmp_path, capture_runner_factory(seen))
|
||||
parent = make_done_job(client)
|
||||
(cfg.work_dir / "A - T").mkdir(parents=True)
|
||||
(cfg.work_dir / "A - T" / "audio.wav").write_bytes(b"x")
|
||||
child = client.post(f"/api/jobs/{parent}/rebuild",
|
||||
data=rebuild_form(title="T2")).json()["id"]
|
||||
wait_status(client, child, "done")
|
||||
assert not (cfg.work_dir / "A - T").exists()
|
||||
assert (cfg.work_dir / "A - T2" / "audio.wav").exists()
|
||||
assert seen[1]["force_from"] == "grid"
|
||||
|
||||
|
||||
def test_rebuild_fails_the_job_on_a_work_dir_collision(tmp_path):
|
||||
client, cfg = make_client(tmp_path, ok_runner)
|
||||
parent = make_done_job(client)
|
||||
(cfg.work_dir / "A - T").mkdir(parents=True)
|
||||
(cfg.work_dir / "A - T2").mkdir(parents=True)
|
||||
child = client.post(f"/api/jobs/{parent}/rebuild",
|
||||
data=rebuild_form(title="T2")).json()["id"]
|
||||
job = wait_status(client, child, "failed")
|
||||
assert "already exists" in job["error"]
|
||||
|
||||
|
||||
def test_rebuild_logs_what_changed_without_faking_a_stage(tmp_path):
|
||||
client, _ = make_client(tmp_path, ok_runner)
|
||||
parent = make_done_job(client)
|
||||
child = client.post(f"/api/jobs/{parent}/rebuild",
|
||||
data=rebuild_form(year="1998")).json()["id"]
|
||||
job = wait_status(client, child, "done")
|
||||
rebuild_lines = [l for l in job["log"] if l.startswith("[rebuild]")]
|
||||
assert len(rebuild_lines) == 1
|
||||
line = rebuild_lines[0]
|
||||
assert "grid" in line and "year" in line
|
||||
assert not STAGE_LINE.match(line)
|
||||
|
||||
|
||||
def test_rebuild_inherits_the_parents_source(tmp_path):
|
||||
seen = []
|
||||
client, cfg = make_client(tmp_path, capture_runner_factory(seen))
|
||||
resp = client.post("/api/jobs", data={"title": "T", "artist": "A", "lyrics": "x"},
|
||||
files={"video": ("v.mp4", io.BytesIO(b"data"), "video/mp4")})
|
||||
parent = resp.json()["id"]
|
||||
wait_status(client, parent, "done")
|
||||
(cfg.work_dir / "A - T").mkdir(parents=True)
|
||||
for name in ("meta.json", "audio.wav"):
|
||||
(cfg.work_dir / "A - T" / name).write_bytes(b"x")
|
||||
child = client.post(f"/api/jobs/{parent}/rebuild",
|
||||
data=rebuild_form(youtube_url="", year="1998")).json()["id"]
|
||||
wait_status(client, child, "done")
|
||||
assert seen[1]["inp"].video_path == seen[0]["inp"].video_path
|
||||
|
||||
|
||||
def test_rebuild_inherits_the_parents_cover(tmp_path):
|
||||
seen = []
|
||||
client, _ = make_client(tmp_path, capture_runner_factory(seen))
|
||||
resp = client.post("/api/jobs", data={"title": "T", "artist": "A", "lyrics": "x",
|
||||
"youtube_url": "http://y"},
|
||||
files={"cover": ("c.jpg", io.BytesIO(b"img"), "image/jpeg")})
|
||||
parent = resp.json()["id"]
|
||||
wait_status(client, parent, "done")
|
||||
child = client.post(f"/api/jobs/{parent}/rebuild",
|
||||
data=rebuild_form(year="1998")).json()["id"]
|
||||
wait_status(client, child, "done")
|
||||
assert seen[1]["inp"].cover_path == seen[0]["inp"].cover_path
|
||||
|
||||
|
||||
def test_rebuild_rejects_a_missing_upload_that_would_be_needed(tmp_path):
|
||||
client, _ = make_client(tmp_path, ok_runner)
|
||||
resp = client.post("/api/jobs", data={"title": "T", "artist": "A", "lyrics": "x"},
|
||||
files={"video": ("v.mp4", io.BytesIO(b"data"), "video/mp4")})
|
||||
parent = resp.json()["id"]
|
||||
wait_status(client, parent, "done")
|
||||
resp = client.post(f"/api/jobs/{parent}/rebuild",
|
||||
data=rebuild_form(youtube_url="", language="de"))
|
||||
assert resp.status_code == 400
|
||||
assert "attach the video file again" in resp.json()["detail"]
|
||||
|
||||
|
||||
def test_rebuild_accepts_a_replacement_source(tmp_path):
|
||||
seen = []
|
||||
client, _ = make_client(tmp_path, capture_runner_factory(seen))
|
||||
parent = make_done_job(client)
|
||||
child = client.post(f"/api/jobs/{parent}/rebuild", data=rebuild_form(youtube_url=""),
|
||||
files={"video": ("new.mp4", io.BytesIO(b"d"), "video/mp4")}
|
||||
).json()["id"]
|
||||
wait_status(client, child, "done")
|
||||
assert seen[1]["inp"].youtube_url is None
|
||||
assert seen[1]["inp"].video_path.name == "new.mp4"
|
||||
assert seen[1]["force_from"] == "acquire"
|
||||
|
||||
|
||||
def test_rebuild_rejects_two_sources(tmp_path):
|
||||
client, _ = make_client(tmp_path, ok_runner)
|
||||
parent = make_done_job(client)
|
||||
resp = client.post(f"/api/jobs/{parent}/rebuild", data=rebuild_form(),
|
||||
files={"video": ("v.mp4", io.BytesIO(b"d"), "video/mp4")})
|
||||
assert resp.status_code == 400
|
||||
|
||||
|
||||
def test_rebuild_rejects_a_bad_bpm(tmp_path):
|
||||
client, _ = make_client(tmp_path, ok_runner)
|
||||
parent = make_done_job(client)
|
||||
resp = client.post(f"/api/jobs/{parent}/rebuild", data=rebuild_form(bpm="0"))
|
||||
assert resp.status_code == 400
|
||||
|
||||
|
||||
def test_rebuild_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)
|
||||
parent = client.post("/api/jobs", data=rebuild_form()).json()["id"]
|
||||
try:
|
||||
wait_status(client, parent, "running")
|
||||
resp = client.post(f"/api/jobs/{parent}/rebuild", data=rebuild_form(year="1998"))
|
||||
assert resp.status_code == 409
|
||||
finally:
|
||||
release.set()
|
||||
wait_status(client, parent, "done")
|
||||
|
||||
|
||||
def test_rebuild_of_an_unknown_job_is_404(tmp_path):
|
||||
client, _ = make_client(tmp_path, ok_runner)
|
||||
assert client.post("/api/jobs/nope/rebuild", data=rebuild_form()).status_code == 404
|
||||
|
||||
|
||||
def slug_runner(inp, cfg, log=print, force_from=None, stages=None):
|
||||
out = cfg.output_dir / song_slug(inp)
|
||||
out.mkdir(parents=True, exist_ok=True)
|
||||
(out / "song.txt").write_text(f"#TITLE:{inp.title}\n")
|
||||
return PipelineResult(output_dir=out)
|
||||
|
||||
|
||||
def test_rebuild_removes_the_output_it_replaced(tmp_path):
|
||||
client, cfg = make_client(tmp_path, slug_runner)
|
||||
parent = make_done_job(client)
|
||||
assert (cfg.output_dir / "A - T" / "song.txt").exists()
|
||||
child = client.post(f"/api/jobs/{parent}/rebuild",
|
||||
data=rebuild_form(title="T2")).json()["id"]
|
||||
job = wait_status(client, child, "done")
|
||||
assert (cfg.output_dir / "A - T2" / "song.txt").exists()
|
||||
assert not (cfg.output_dir / "A - T").exists()
|
||||
assert any("superseded" in line for line in job["log"])
|
||||
|
||||
|
||||
def test_rebuild_keeps_the_output_when_the_slug_is_unchanged(tmp_path):
|
||||
client, cfg = make_client(tmp_path, slug_runner)
|
||||
parent = make_done_job(client)
|
||||
child = client.post(f"/api/jobs/{parent}/rebuild",
|
||||
data=rebuild_form(year="1998")).json()["id"]
|
||||
wait_status(client, child, "done")
|
||||
assert (cfg.output_dir / "A - T" / "song.txt").exists()
|
||||
|
||||
|
||||
def test_rebuild_of_a_failed_parent_removes_nothing(tmp_path):
|
||||
calls = []
|
||||
|
||||
def flaky(inp, cfg, log=print, force_from=None, stages=None):
|
||||
calls.append(1)
|
||||
if len(calls) == 1:
|
||||
raise PipelineError("boom")
|
||||
return slug_runner(inp, cfg, log=log)
|
||||
|
||||
client, cfg = make_client(tmp_path, flaky)
|
||||
parent = client.post("/api/jobs", data=rebuild_form()).json()["id"]
|
||||
wait_status(client, parent, "failed")
|
||||
(cfg.output_dir / "A - T").mkdir(parents=True)
|
||||
(cfg.output_dir / "A - T" / "keep.txt").write_text("mine")
|
||||
child = client.post(f"/api/jobs/{parent}/rebuild",
|
||||
data=rebuild_form(title="T2")).json()["id"]
|
||||
wait_status(client, child, "done")
|
||||
assert (cfg.output_dir / "A - T" / "keep.txt").exists()
|
||||
|
||||
|
||||
def test_a_plain_job_never_removes_anything(tmp_path):
|
||||
client, cfg = make_client(tmp_path, slug_runner)
|
||||
make_done_job(client)
|
||||
stray = cfg.output_dir / "A - Other"
|
||||
stray.mkdir(parents=True)
|
||||
make_done_job(client, title="T2")
|
||||
assert stray.exists()
|
||||
|
||||
Reference in New Issue
Block a user