From 6808f248fb4f88043e9d3b600981eea0ba5de8ab Mon Sep 17 00:00:00 2001 From: Brad Anderson Date: Sat, 10 Oct 2026 18:06:13 -0700 Subject: [PATCH 1/2] =?UTF-8?q?webui:=20add=20REBUILD=20ALL=20FROM?= =?UTF-8?q?=E2=80=A6=20to=20rebuild=20every=20song=20from=20a=20stage?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When an update improves a stage, existing songs only pick it up when each is rebuilt from that stage. POST /api/rebuild-all queues an ordinary rebuild with unchanged inputs for the current job of every song: jobs that were themselves rebuilt are skipped, as are songs still in progress and songs whose upload is gone when the stage is acquire. The sidebar button opens a dialog to pick the stage and confirm the count. --- README.md | 5 ++ src/songtrace/static/index.html | 103 ++++++++++++++++++++++++++++++++ src/songtrace/web.py | 26 ++++++++ tests/test_web.py | 75 ++++++++++++++++++++++- 4 files changed, 208 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index b58ba91..e3f455c 100644 --- a/README.md +++ b/README.md @@ -177,6 +177,11 @@ to redo a stage anyway — pick `package` to repackage without re-downloading. It can only widen what is rerun, so an edit that invalidates an earlier stage still wins. +**REBUILD ALL FROM…**, under CREATE SONG, does the same for every song at once: +pick the stage an update improved and each song is rebuilt from there with the +inputs it has now. Songs still in progress are skipped, as are songs whose +uploaded video is gone when the stage is `acquire`. + ## YouTube download failures `HTTP Error 403: Forbidden` from the acquire stage means YouTube served the diff --git a/src/songtrace/static/index.html b/src/songtrace/static/index.html index 8e4e417..b7c234b 100644 --- a/src/songtrace/static/index.html +++ b/src/songtrace/static/index.html @@ -113,6 +113,31 @@ body { #sidebar .eyebrow, #jobs .empty-note { padding: 0 18px; } #create-song { display: block; width: calc(100% - 36px); margin: 0 18px 20px; padding: 9px 12px; } +#rebuild-all { display: block; margin: -8px 18px 20px; } +#rebuild-all-note { margin: -12px 18px 20px; } + +#rebuild-all-dialog { + max-width: 420px; + padding: 26px 28px; + color: var(--text); + background: var(--panel); + border: 1px solid var(--border); +} +#rebuild-all-dialog::backdrop { background: rgb(10 10 15 / 0.75); } +#rebuild-all-dialog h2 { + margin: 0 0 14px; + font-family: var(--font-display); + font-weight: 700; + font-size: 0.8rem; + letter-spacing: 0.22em; + color: var(--text-muted); +} +#rebuild-all-form { display: grid; gap: 16px; } +#rebuild-all-form p { margin: 0; font-size: 0.88rem; line-height: 1.5; } +#rebuild-all-count { color: var(--warn); } +#rebuild-all-error { min-height: 1.2em; color: var(--fail); } +#rebuild-all-error:empty { display: none; } + #jobs { display: flex; flex-direction: column; gap: 1px; margin-top: 8px; } .job { @@ -518,11 +543,31 @@ button { cursor: pointer; }
+ +

QUEUE

+ +
+

REBUILD ALL SONGS

+

Reruns every song from the chosen stage onwards with the inputs it has + now, for when a songtrace update improves that stage.

+ +

+ +
+ + +
+
+
+

NEW SONG

@@ -702,6 +747,62 @@ createSong.addEventListener("click", () => { form.elements.title.focus(); }); +const rebuildAllButton = document.getElementById("rebuild-all"); +const rebuildAllNote = document.getElementById("rebuild-all-note"); +const rebuildAllDialog = document.getElementById("rebuild-all-dialog"); +const rebuildAllForm = document.getElementById("rebuild-all-form"); +const rebuildAllCount = document.getElementById("rebuild-all-count"); +const rebuildAllError = document.getElementById("rebuild-all-error"); + +// The server's rule, for the count only: a rebuilt job's rebuild is the song +// now, and two submissions of one song share it. +function currentSongs() { + const rebuilt = new Set(JOBS.map(j => j.rebuilt_from)); + const songs = new Map(); + for (const job of [...JOBS].reverse()) { + if (!rebuilt.has(job.id)) songs.set(`${job.artist} - ${job.title}`, job); + } + return [...songs.values()]; +} + +rebuildAllButton.addEventListener("click", () => { + const songs = currentSongs(); + const idle = songs.filter(j => j.status === "done" || j.status === "failed").length; + rebuildAllCount.textContent = idle === 1 ? "1 song will be rebuilt." + : `${idle} songs will be rebuilt.`; + if (idle < songs.length) { + rebuildAllCount.textContent += ` ${songs.length - idle} still in progress will be skipped.`; + } + rebuildAllError.textContent = ""; + rebuildAllForm.reset(); + rebuildAllDialog.showModal(); +}); + +document.getElementById("rebuild-all-cancel").addEventListener( + "click", () => rebuildAllDialog.close()); + +rebuildAllForm.addEventListener("submit", async (e) => { + e.preventDefault(); + const stage = rebuildAllForm.elements.force_from.value; + const resp = await fetch("/api/rebuild-all", { + method: "POST", body: new FormData(rebuildAllForm) }); + if (!resp.ok) { + const body = await resp.json().catch(() => ({})); + rebuildAllError.textContent = body.detail || `error: ${resp.status}`; + return; + } + const { ids, skipped } = await resp.json(); + rebuildAllDialog.close(); + let note = `Queued ${ids.length} rebuild${ids.length === 1 ? "" : "s"} from ${stage}.`; + if (skipped.length) { + const reasons = [...new Set(skipped.map(s => s.reason))].join("; "); + note += ` Skipped ${skipped.length}: ${reasons}.`; + } + rebuildAllNote.textContent = note; + rebuildAllNote.hidden = false; + refreshJobs(); +}); + function jobCard(job) { const card = document.createElement("button"); card.type = "button"; @@ -780,6 +881,8 @@ async function loadStages() { const select = form.elements.force_from; select.replaceChildren(new Option("only what changed", ""), ...STAGES.map(s => new Option(s, s))); + rebuildAllForm.elements.force_from.replaceChildren( + new Option("choose a stage", ""), ...STAGES.map(s => new Option(s, s))); } function renderStageTrack(job) { diff --git a/src/songtrace/web.py b/src/songtrace/web.py index b1060b8..1d86152 100644 --- a/src/songtrace/web.py +++ b/src/songtrace/web.py @@ -329,6 +329,32 @@ def create_app(cfg: Config, runner=run_pipeline) -> FastAPI: rebuild_policy.plan(parent.inp, inp), force_from)) return _enqueue(new_id, inp, rebuilt_from=job_id, force_stage=force_from) + @app.post("/api/rebuild-all", status_code=201) + def rebuild_all(force_from: str = Form(...)): + """Rebuild every song from `force_from` on, for when songtrace itself + improved that stage. Each is an ordinary rebuild with unchanged inputs.""" + if force_from not in STAGE_ORDER: + raise HTTPException(400, f"unknown stage {force_from!r}") + with lock: + # a job that was rebuilt is history: its rebuild is the song now, + # possibly under another slug + rebuilt = {j.rebuilt_from for j in jobs.values()} + latest = {song_slug(j.inp): j for j in (jobs[i] for i in order) + if j.id not in rebuilt} + ids, skipped = [], [] + for job in latest.values(): + if job.status in ("queued", "running"): + skipped.append({"id": job.id, "reason": "still in progress"}) + continue + try: + _require_source(job.inp, job.inp, force_from) + except HTTPException as e: + skipped.append({"id": job.id, "reason": e.detail}) + continue + ids.append(_enqueue(uuid.uuid4().hex, job.inp, rebuilt_from=job.id, + force_stage=force_from)["id"]) + return {"ids": ids, "skipped": skipped} + @app.get("/api/stages") def list_stages(): return {"stages": list(STAGE_ORDER)} diff --git a/tests/test_web.py b/tests/test_web.py index a98483c..d72cdc4 100644 --- a/tests/test_web.py +++ b/tests/test_web.py @@ -834,7 +834,7 @@ def test_narrow_stage_labels_stay_in_the_accessibility_tree(): def test_rebuild_mode_reuses_the_one_form(): html = _index_html() - assert html.count(" Date: Sat, 10 Oct 2026 18:16:02 -0700 Subject: [PATCH 2/2] =?UTF-8?q?webui:=20let=20REBUILD=20ALL=20FROM?= =?UTF-8?q?=E2=80=A6=20skip=20individual=20songs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The dialog now lists the songs it would rebuild, all ticked, each with when it was last built and whether that was a rebuild, so songs already rebuilt by hand can be unticked. GET /api/rebuild-all returns that list, so the page no longer repeats the server's rule for which job stands for a song, and POST takes the ticked job ids. A selected job that has since been rebuilt is reported as skipped rather than rebuilt twice. --- README.md | 5 +- src/songtrace/static/index.html | 113 ++++++++++++++++++++++++++------ src/songtrace/web.py | 41 ++++++++---- tests/test_web.py | 35 +++++++++- 4 files changed, 159 insertions(+), 35 deletions(-) diff --git a/README.md b/README.md index e3f455c..e8b304e 100644 --- a/README.md +++ b/README.md @@ -179,8 +179,9 @@ still wins. **REBUILD ALL FROM…**, under CREATE SONG, does the same for every song at once: pick the stage an update improved and each song is rebuilt from there with the -inputs it has now. Songs still in progress are skipped, as are songs whose -uploaded video is gone when the stage is `acquire`. +inputs it has now. Every song starts ticked; untick any already rebuilt by hand +(each shows when it was last built). Songs still in progress are skipped, as +are songs whose uploaded video is gone when the stage is `acquire`. ## YouTube download failures diff --git a/src/songtrace/static/index.html b/src/songtrace/static/index.html index b7c234b..e8ed268 100644 --- a/src/songtrace/static/index.html +++ b/src/songtrace/static/index.html @@ -117,7 +117,7 @@ body { #rebuild-all-note { margin: -12px 18px 20px; } #rebuild-all-dialog { - max-width: 420px; + width: min(480px, calc(100vw - 32px)); padding: 26px 28px; color: var(--text); background: var(--panel); @@ -135,6 +135,36 @@ body { #rebuild-all-form { display: grid; gap: 16px; } #rebuild-all-form p { margin: 0; font-size: 0.88rem; line-height: 1.5; } #rebuild-all-count { color: var(--warn); } +#rebuild-all-songs { margin: 0; padding: 0; border: 0; min-width: 0; } +#rebuild-all-songs legend { + margin-bottom: 6px; + padding: 0; + font-family: var(--font-display); + font-weight: 600; + font-size: 0.68rem; + letter-spacing: 0.14em; + text-transform: uppercase; + color: var(--text-muted); +} +#rebuild-all-list { + max-height: 40vh; + overflow-y: auto; + border-top: 1px solid var(--border); + border-bottom: 1px solid var(--border); +} +.rebuild-all-row { + display: grid; + grid-template-columns: auto 1fr; + gap: 1px 10px; + align-items: baseline; + padding: 7px 4px; + font-size: 0.88rem; + cursor: pointer; +} +.rebuild-all-row input { accent-color: var(--purple); } +.rebuild-all-row:has(input:disabled) { cursor: default; color: var(--text-muted); } +.rebuild-all-row .meta { grid-column: 2; font-size: 0.76rem; color: var(--text-muted); } +.rebuild-all-every { padding-top: 0; color: var(--text-muted); } #rebuild-all-error { min-height: 1.2em; color: var(--fail); } #rebuild-all-error:empty { display: none; } @@ -553,16 +583,24 @@ button { cursor: pointer; }

REBUILD ALL SONGS

-

Reruns every song from the chosen stage onwards with the inputs it has - now, for when a songtrace update improves that stage.

+

Reruns the songs ticked below from the chosen stage onwards with the + inputs they have now, for when a songtrace update improves that stage.

+
+ Songs + +
+

- +
@@ -754,30 +792,63 @@ const rebuildAllForm = document.getElementById("rebuild-all-form"); const rebuildAllCount = document.getElementById("rebuild-all-count"); const rebuildAllError = document.getElementById("rebuild-all-error"); -// The server's rule, for the count only: a rebuilt job's rebuild is the song -// now, and two submissions of one song share it. -function currentSongs() { - const rebuilt = new Set(JOBS.map(j => j.rebuilt_from)); - const songs = new Map(); - for (const job of [...JOBS].reverse()) { - if (!rebuilt.has(job.id)) songs.set(`${job.artist} - ${job.title}`, job); - } - return [...songs.values()]; +const rebuildAllList = document.getElementById("rebuild-all-list"); +const rebuildAllToggle = document.getElementById("rebuild-all-toggle"); +const rebuildAllSubmit = rebuildAllForm.querySelector("button[type=submit]"); + +function rebuildAllBoxes() { + return [...rebuildAllList.querySelectorAll("input:not(:disabled)")]; } -rebuildAllButton.addEventListener("click", () => { - const songs = currentSongs(); - const idle = songs.filter(j => j.status === "done" || j.status === "failed").length; - rebuildAllCount.textContent = idle === 1 ? "1 song will be rebuilt." - : `${idle} songs will be rebuilt.`; - if (idle < songs.length) { - rebuildAllCount.textContent += ` ${songs.length - idle} still in progress will be skipped.`; - } +function updateRebuildAllCount() { + const boxes = rebuildAllBoxes(); + const chosen = boxes.filter(b => b.checked).length; + rebuildAllToggle.checked = chosen === boxes.length && chosen > 0; + rebuildAllToggle.indeterminate = chosen > 0 && chosen < boxes.length; + rebuildAllCount.textContent = chosen === 1 ? "1 song will be rebuilt." + : `${chosen} songs will be rebuilt.`; + // with nothing ticked no ids are sent, which the API reads as every song + rebuildAllSubmit.disabled = chosen === 0; +} + +function rebuildAllRow(song) { + const busy = song.status === "queued" || song.status === "running"; + const row = document.createElement("label"); + row.className = "rebuild-all-row"; + const box = document.createElement("input"); + box.type = "checkbox"; + box.name = "ids"; + box.value = song.id; + box.checked = !busy; + box.disabled = busy; + const name = document.createElement("span"); + name.textContent = `${song.title} · ${song.artist}`; + const meta = document.createElement("span"); + meta.className = "meta"; + const when = song.finished_at && new Date(song.finished_at * 1000).toLocaleString( + undefined, { dateStyle: "medium", timeStyle: "short" }); + meta.textContent = busy ? "in progress, skipped" + : `${song.status === "failed" ? "failed" : song.rebuilt_from ? "rebuilt" : "built"} ${when || ""}`.trim(); + row.append(box, name, meta); + return row; +} + +rebuildAllButton.addEventListener("click", async () => { rebuildAllError.textContent = ""; rebuildAllForm.reset(); + const resp = await fetch("/api/rebuild-all"); + const songs = resp.ok ? await resp.json() : []; + rebuildAllList.replaceChildren(...songs.map(rebuildAllRow)); + updateRebuildAllCount(); rebuildAllDialog.showModal(); }); +rebuildAllList.addEventListener("change", updateRebuildAllCount); +rebuildAllToggle.addEventListener("change", () => { + for (const box of rebuildAllBoxes()) box.checked = rebuildAllToggle.checked; + updateRebuildAllCount(); +}); + document.getElementById("rebuild-all-cancel").addEventListener( "click", () => rebuildAllDialog.close()); diff --git a/src/songtrace/web.py b/src/songtrace/web.py index 1d86152..63d4c31 100644 --- a/src/songtrace/web.py +++ b/src/songtrace/web.py @@ -329,20 +329,39 @@ def create_app(cfg: Config, runner=run_pipeline) -> FastAPI: rebuild_policy.plan(parent.inp, inp), force_from)) return _enqueue(new_id, inp, rebuilt_from=job_id, force_stage=force_from) - @app.post("/api/rebuild-all", status_code=201) - def rebuild_all(force_from: str = Form(...)): - """Rebuild every song from `force_from` on, for when songtrace itself - improved that stage. Each is an ordinary rebuild with unchanged inputs.""" - if force_from not in STAGE_ORDER: - raise HTTPException(400, f"unknown stage {force_from!r}") + def _current_songs() -> list[Job]: + """The job that stands for each song now, newest first.""" with lock: # a job that was rebuilt is history: its rebuild is the song now, # possibly under another slug rebuilt = {j.rebuilt_from for j in jobs.values()} latest = {song_slug(j.inp): j for j in (jobs[i] for i in order) if j.id not in rebuilt} - ids, skipped = [], [] - for job in latest.values(): + position = {job_id: n for n, job_id in enumerate(order)} + return sorted(latest.values(), key=lambda j: position[j.id], reverse=True) + + @app.get("/api/rebuild-all") + def rebuild_all_candidates(): + return [{"id": j.id, "title": j.title, "artist": j.artist, + "status": j.status, "rebuilt_from": j.rebuilt_from, + "finished_at": j.finished_at} + for j in _current_songs()] + + @app.post("/api/rebuild-all", status_code=201) + def rebuild_all(force_from: str = Form(...), ids: list[str] | None = Form(None)): + """Rebuild every song, or the ones in `ids`, from `force_from` on, for + when songtrace itself improved that stage. Each is an ordinary rebuild + with unchanged inputs.""" + if force_from not in STAGE_ORDER: + raise HTTPException(400, f"unknown stage {force_from!r}") + songs = _current_songs() + queued, skipped = [], [] + if ids is not None: + current = {j.id for j in songs} + skipped = [{"id": i, "reason": "no longer the current job for its song"} + for i in ids if i not in current] + songs = [j for j in songs if j.id in set(ids)] + for job in songs: if job.status in ("queued", "running"): skipped.append({"id": job.id, "reason": "still in progress"}) continue @@ -351,9 +370,9 @@ def create_app(cfg: Config, runner=run_pipeline) -> FastAPI: except HTTPException as e: skipped.append({"id": job.id, "reason": e.detail}) continue - ids.append(_enqueue(uuid.uuid4().hex, job.inp, rebuilt_from=job.id, - force_stage=force_from)["id"]) - return {"ids": ids, "skipped": skipped} + queued.append(_enqueue(uuid.uuid4().hex, job.inp, rebuilt_from=job.id, + force_stage=force_from)["id"]) + return {"ids": queued, "skipped": skipped} @app.get("/api/stages") def list_stages(): diff --git a/tests/test_web.py b/tests/test_web.py index d72cdc4..ff03af3 100644 --- a/tests/test_web.py +++ b/tests/test_web.py @@ -1760,7 +1760,8 @@ def test_rebuild_all_rebuilds_every_song_from_the_stage(tmp_path): jobs = [wait_status(client, i, "done") for i in body["ids"]] assert {j["rebuilt_from"] for j in jobs} == {first, second} assert [s["force_from"] for s in seen[2:]] == ["pitch", "pitch"] - assert [s["inp"] for s in seen[2:]] == [s["inp"] for s in seen[:2]] + by_title = lambda runs: sorted((r["inp"] for r in runs), key=lambda i: i.title) + assert by_title(seen[2:]) == by_title(seen[:2]) def test_rebuild_all_skips_jobs_that_were_already_rebuilt(tmp_path): @@ -1819,3 +1820,35 @@ def test_rebuild_all_rejects_an_unknown_stage(tmp_path): make_done_job(client) assert client.post("/api/rebuild-all", data={"force_from": "nope"}).status_code == 400 assert client.post("/api/rebuild-all", data={}).status_code == 422 + + +def test_rebuild_all_lists_the_current_job_of_each_song(tmp_path): + client, _ = make_client(tmp_path, capture_runner_factory([])) + one = make_done_job(client, title="One") + two = make_done_job(client, title="Two") + rerun = client.post(f"/api/jobs/{one}/rerun").json()["id"] + wait_status(client, rerun, "done") + songs = client.get("/api/rebuild-all").json() + assert [(s["id"], s["title"], s["rebuilt_from"]) for s in songs] == [ + (rerun, "One", one), (two, "Two", None)] + assert all(s["status"] == "done" and s["finished_at"] for s in songs) + + +def test_rebuild_all_rebuilds_only_the_songs_selected(tmp_path): + client, _ = make_client(tmp_path, capture_runner_factory([])) + make_done_job(client, title="One") + two = make_done_job(client, title="Two") + resp = client.post("/api/rebuild-all", data={"force_from": "pitch", "ids": [two]}) + body = resp.json() + assert len(body["ids"]) == 1 and body["skipped"] == [] + assert wait_status(client, body["ids"][0], "done")["rebuilt_from"] == two + + +def test_rebuild_all_reports_a_selected_job_that_was_since_rebuilt(tmp_path): + client, _ = make_client(tmp_path, capture_runner_factory([])) + old = make_done_job(client) + rerun = client.post(f"/api/jobs/{old}/rerun").json()["id"] + wait_status(client, rerun, "done") + body = client.post("/api/rebuild-all", data={"force_from": "pitch", "ids": [old]}).json() + assert body == {"ids": [], "skipped": [ + {"id": old, "reason": "no longer the current job for its song"}]} -- 2.54.0