diff --git a/README.md b/README.md
index b58ba91..e8b304e 100644
--- a/README.md
+++ b/README.md
@@ -177,6 +177,12 @@ 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. 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
`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..e8ed268 100644
--- a/src/songtrace/static/index.html
+++ b/src/songtrace/static/index.html
@@ -113,6 +113,61 @@ 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 {
+ width: min(480px, calc(100vw - 32px));
+ 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-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; }
+
#jobs { display: flex; flex-direction: column; gap: 1px; margin-top: 8px; }
.job {
@@ -518,11 +573,39 @@ button { cursor: pointer; }
+
+
QUEUE
+
+
NEW SONG
@@ -702,6 +785,95 @@ 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");
+
+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)")];
+}
+
+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());
+
+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 +952,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..63d4c31 100644
--- a/src/songtrace/web.py
+++ b/src/songtrace/web.py
@@ -329,6 +329,51 @@ 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)
+ 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}
+ 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
+ try:
+ _require_source(job.inp, job.inp, force_from)
+ except HTTPException as e:
+ skipped.append({"id": job.id, "reason": e.detail})
+ continue
+ 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():
return {"stages": list(STAGE_ORDER)}
diff --git a/tests/test_web.py b/tests/test_web.py
index a98483c..ff03af3 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("