Rerun failed tracks and edit-and-rebuild existing ones #2

Merged
branderson merged 10 commits from rerun-rebuild into main 2026-07-27 09:19:57 -07:00
6 changed files with 978 additions and 36 deletions
+73
View File
@@ -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
+138 -7
View File
@@ -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
View File
@@ -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
+6
View File
@@ -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
+126
View File
@@ -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
View File
@@ -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()