feat(career): extract the whole setlist before the gig starts

A feedpak is a zip, and the first play of one pays for its extraction into
sloppak_cache. Inside a set that cost landed BETWEEN songs: the player finished
a number and then sat there waiting for the next one to unpack, mid-gig.

A setlist is a known list up front, so unpack it all while the poster is still on
screen. New POST /gigs/prepare walks the set through resolve_source_dir; the
poster's Play button shows "Preparing set…" while it runs.

Best-effort by design, at every level:
  - a corrupt pak in the set does not sink the prepare (it is reported in
    `failed`; the play itself surfaces the error exactly as it does outside a
    gig — slow beats blocked)
  - a host without the library resolvers degrades to a no-op rather than 500
  - a failed request just falls through to the old lazy extraction

Ordering matters and is pinned: the set is unpacked BEFORE the stage is borrowed
(venue/viz overwritten) and before the queue starts, so a proposal cancelled
while unpacking leaves nothing half-applied to unwind.

Tests unpack REAL zips rather than mocking the extractor: every song of the set
lands on disk before the first note, a re-prepare does not duplicate the unpack,
one bad pak still leaves the good one prepared, and no-library / empty-setlist
degrade cleanly. 18/18.

NB the other half of the gig report — the per-song results popup interrupting
the set (and worse, claimAutoExit'ing so the queue would not advance until it was
dismissed) — is fixed in the note_detect plugin repo, which is not part of this
checkout.
This commit is contained in:
byrongamatos
2026-07-15 00:03:13 +02:00
parent 4e0e3c5417
commit a82ea14169
4 changed files with 245 additions and 2 deletions
+41
View File
@@ -46,6 +46,7 @@ from pathlib import Path
from fastapi import Body, HTTPException from fastapi import Body, HTTPException
from fastapi.responses import FileResponse from fastapi.responses import FileResponse
import sloppak
from progression import instrument_for_arrangement from progression import instrument_for_arrangement
PLUGIN_ID = "career" PLUGIN_ID = "career"
@@ -734,6 +735,46 @@ def setup(app, context):
"snapshot": snapshot}) "snapshot": snapshot})
return {"ok": True} return {"ok": True}
@app.post(f"/api/plugins/{PLUGIN_ID}/gigs/prepare")
def prepare_gig(body: dict = Body(...)):
"""Unpack every song of the set BEFORE the gig starts.
A feedpak is a zip: the first play of one pays for its extraction into
sloppak_cache. Inside a set that cost landed BETWEEN songs — the player
finished a number and then sat waiting for the next one to unpack, mid-
gig. A set is a known list up front, so extract it all while the player
is still looking at the poster.
Idempotent and cheap on a warm cache: resolve_source_dir() returns the
already-unpacked dir without rewriting it. Best-effort per song — one
bad feedpak must not block the set from starting (the play itself will
surface the error, exactly as it does outside a gig).
"""
files = [str(f) for f in ((body or {}).get("songs") or []) if f]
if not files:
return {"ok": True, "prepared": 0, "failed": []}
# .get, not []: a host that doesn't hand us the resolvers (or has no
# library configured) must degrade to "extract lazily, as before" — this
# is an optimisation, and it is never allowed to be the thing that stops
# a gig from starting.
get_dlc = context.get("get_dlc_dir")
get_cache = context.get("get_sloppak_cache_dir")
dlc_root = get_dlc() if callable(get_dlc) else None
cache_root = get_cache() if callable(get_cache) else None
if dlc_root is None or cache_root is None:
return {"ok": False, "prepared": 0, "failed": files, "error": "no library"}
prepared, failed = 0, []
for fn in files:
try:
sloppak.resolve_source_dir(fn, Path(dlc_root), Path(cache_root))
prepared += 1
except Exception as exc: # noqa: BLE001 — one bad pak can't sink the set
_state["log"].warning("career: gig pre-extract failed for %s: %s", fn, exc)
failed.append(fn)
return {"ok": True, "prepared": prepared, "failed": failed}
@app.post(f"/api/plugins/{PLUGIN_ID}/gigs/propose") @app.post(f"/api/plugins/{PLUGIN_ID}/gigs/propose")
def propose_gig(body: dict = Body(...)): def propose_gig(body: dict = Body(...)):
inst = str((body or {}).get("instrument") or "") inst = str((body or {}).get("instrument") or "")
+32 -2
View File
@@ -1123,10 +1123,40 @@
sfx('page'); sfx('page');
} }
function startGig() { // Unpack the whole set before the first note.
//
// A feedpak is a zip, and the first play of one pays for its extraction. In
// a set that cost landed BETWEEN songs: the player finished a number and
// then sat there waiting for the next one to unpack, mid-gig. The setlist is
// known up front, so warm it all while the poster is still on screen.
//
// Best-effort by design: a library that won't pre-extract must not stop the
// gig from starting — the play itself surfaces the error the same way it
// does outside a gig. Slow is better than blocked.
async function prepareGigSongs(prop, btn) {
const label = btn && btn.textContent;
if (btn) { btn.disabled = true; btn.textContent = 'Preparing set…'; }
try {
await fetch(`${API}/gigs/prepare`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ songs: prop.songs.map((s) => s.filename) }),
});
} catch (_) { /* start anyway — first play will extract as it always did */ }
if (btn) { btn.disabled = false; if (label) btn.textContent = label; }
}
async function startGig(btn) {
const prop = _ppGigProposal; const prop = _ppGigProposal;
const q = window.feedBack && window.feedBack.playQueue; const q = window.feedBack && window.feedBack.playQueue;
if (!prop || !q || typeof q.start !== 'function' || typeof window.playSong !== 'function') return; if (!prop || !q || typeof q.start !== 'function' || typeof window.playSong !== 'function') return;
// Extract the setlist BEFORE the stage is borrowed and the queue starts,
// so a failure here leaves nothing half-applied to unwind.
await prepareGigSongs(prop, btn);
// The poster's Play could have been cancelled while we were unpacking.
if (_ppGigProposal !== prop) return;
// The gig BORROWS the stage: stash whatever venue/viz the user had so // The gig BORROWS the stage: stash whatever venue/viz the user had so
// the set ending gives it back (unlike "Play here", which is an // the set ending gives it back (unlike "Play here", which is an
// explicit persistent choice on the venue card). // explicit persistent choice on the venue card).
@@ -1424,7 +1454,7 @@
} }
const gigBtn = e.target.closest('[data-pp-gig]'); const gigBtn = e.target.closest('[data-pp-gig]');
if (gigBtn) { bookGig(gigBtn.dataset.ppGig); return; } if (gigBtn) { bookGig(gigBtn.dataset.ppGig); return; }
if (e.target.closest('[data-pp-gig-play]')) { startGig(); return; } if (e.target.closest('[data-pp-gig-play]')) { startGig(e.target.closest('[data-pp-gig-play]')); return; }
if (e.target.closest('[data-pp-gig-reroll]')) { if (e.target.closest('[data-pp-gig-reroll]')) {
if (_ppGigProposal) bookGig(_ppGigProposal.genre_key); if (_ppGigProposal) bookGig(_ppGigProposal.genre_key);
return; return;
+77
View File
@@ -0,0 +1,77 @@
// A gig is a SET, not a run of unrelated songs.
//
// Reported from a live gig: the player finished the first song and had to sit
// through the per-song results popup before the next one would start, and then
// wait again while that song was extracted from its feedpak zip.
//
// This file covers the CORE half — career pre-extracts the whole setlist before
// the first note. The other half (note_detect must not show its per-song summary
// inside a gig) lives in the note_detect plugin repo, which is not part of this
// checkout: plugins/*/ is gitignored here and note_detect ships from
// feedBack-plugin-notedetect. A test reading it from core would pass on a dev
// box (where the plugin happens to be bundled) and fail in CI, which is worse
// than no test.
//
// The pre-extraction is tested for REAL behaviour — actually unpacking zips — in
// tests/plugins/career/test_routes.py. These are the wiring guards around it.
'use strict';
const { test } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const ROOT = path.join(__dirname, '..', '..');
const CAREER = fs.readFileSync(path.join(ROOT, 'plugins', 'career', 'screen.js'), 'utf8');
const CAREER_ROUTES = fs.readFileSync(path.join(ROOT, 'plugins', 'career', 'routes.py'), 'utf8');
function extractBlock(src, signature) {
const start = src.indexOf(signature);
assert.ok(start !== -1, `signature '${signature}' not found`);
const openBrace = src.indexOf('{', start);
let depth = 1;
let i = openBrace + 1;
while (i < src.length && depth > 0) {
const ch = src[i];
if (ch === '{') depth++;
else if (ch === '}') depth--;
i++;
}
assert.ok(depth === 0, `unbalanced braces after '${signature}'`);
return src.slice(start, i);
}
test('startGig extracts the whole setlist before starting the queue', () => {
const fn = extractBlock(CAREER, 'async function startGig(');
const prepIdx = fn.search(/await\s+prepareGigSongs\s*\(/);
const startIdx = fn.search(/q\.start\s*\(/);
assert.ok(prepIdx !== -1, 'startGig must pre-extract the set');
assert.ok(startIdx !== -1, 'q.start not found');
assert.ok(prepIdx < startIdx,
'the set must be unpacked BEFORE the queue starts — otherwise the player ' +
'waits between songs, which is the bug');
});
test('the stage is only borrowed once the set is ready', () => {
const fn = extractBlock(CAREER, 'async function startGig(');
const prepIdx = fn.search(/await\s+prepareGigSongs\s*\(/);
const stageIdx = fn.search(/VENUE_OVERRIDE_KEY/);
assert.ok(prepIdx < stageIdx,
'a gig cancelled while unpacking must not leave the venue/viz overwritten');
assert.match(fn, /_ppGigProposal\s*!==\s*prop/,
'a proposal dismissed while unpacking must not then start a gig');
});
test('pre-extraction never blocks the gig from starting', () => {
const fn = extractBlock(CAREER, 'async function prepareGigSongs(');
assert.match(fn, /catch\s*\(/,
'a failed prepare must fall through to the old lazy extraction, not abort the gig');
});
test('the prepare route degrades instead of failing', () => {
assert.match(CAREER_ROUTES, /def prepare_gig/, 'prepare route missing');
assert.match(CAREER_ROUTES, /context\.get\(\s*["']get_dlc_dir["']\s*\)/,
'a host without the library resolvers must degrade, not 500 — pre-extraction ' +
'is an optimisation and can never be why a gig will not start');
});
+95
View File
@@ -174,3 +174,98 @@ def test_double_download_409s(client, monkeypatch):
career_routes._state["downloads"]["bar"] = {"status": "running"} career_routes._state["downloads"]["bar"] = {"status": "running"}
assert client.post("/api/plugins/career/packs/bar/download").status_code == 409 assert client.post("/api/plugins/career/packs/bar/download").status_code == 409
assert client.delete("/api/plugins/career/packs/bar").status_code == 409 assert client.delete("/api/plugins/career/packs/bar").status_code == 409
# ── gig pre-extraction (the wait between songs) ─────────────────────────────
#
# A feedpak is a zip: the first play of one pays for its extraction into
# sloppak_cache. Inside a set that cost landed BETWEEN songs — the player
# finished a number and then sat waiting for the next one to unpack, mid-gig.
# The setlist is known up front, so extract it all while the poster is up.
def _career_client_with_library(tmp_path, meta_db, dlc, cache):
from fastapi import FastAPI
from fastapi.testclient import TestClient
import routes as career_routes
app = FastAPI()
career_routes.setup(app, {
"config_dir": str(tmp_path),
"meta_db": meta_db,
"get_dlc_dir": lambda: dlc,
"get_sloppak_cache_dir": lambda: cache,
})
return TestClient(app)
def _write_feedpak(dlc, name, title="T"):
"""A minimal but REAL feedpak zip, so resolve_source_dir genuinely unpacks."""
import json as _json
import zipfile as _zip
p = dlc / name
with _zip.ZipFile(p, "w") as z:
z.writestr("manifest.json", _json.dumps({"title": title, "artist": "A", "arrangements": []}))
return p
def test_gig_prepare_extracts_every_song_up_front(tmp_path, meta_db):
dlc = tmp_path / "dlc"; dlc.mkdir()
cache = tmp_path / "cache"; cache.mkdir()
for n in ("one.feedpak", "two.feedpak", "three.feedpak"):
_write_feedpak(dlc, n)
client = _career_client_with_library(tmp_path, meta_db, dlc, cache)
before = list(cache.iterdir())
assert before == [], "nothing unpacked yet"
res = client.post("/api/plugins/career/gigs/prepare",
json={"songs": ["one.feedpak", "two.feedpak", "three.feedpak"]})
assert res.status_code == 200
body = res.json()
assert body["ok"] is True
assert body["prepared"] == 3, body
assert body["failed"] == []
# The point of the whole exercise: the set is on disk BEFORE the first note.
assert len(list(cache.iterdir())) == 3, "every song of the set must be unpacked"
def test_gig_prepare_is_idempotent_on_a_warm_cache(tmp_path, meta_db):
dlc = tmp_path / "dlc"; dlc.mkdir()
cache = tmp_path / "cache"; cache.mkdir()
_write_feedpak(dlc, "one.feedpak")
client = _career_client_with_library(tmp_path, meta_db, dlc, cache)
first = client.post("/api/plugins/career/gigs/prepare", json={"songs": ["one.feedpak"]}).json()
second = client.post("/api/plugins/career/gigs/prepare", json={"songs": ["one.feedpak"]}).json()
assert first["prepared"] == second["prepared"] == 1
assert len(list(cache.iterdir())) == 1, "a re-prepare must not duplicate the unpack"
def test_one_bad_feedpak_does_not_stop_the_set(tmp_path, meta_db):
# A corrupt pak in the setlist must not block the gig: the play itself will
# surface the error exactly as it does outside a gig. Slow beats blocked.
dlc = tmp_path / "dlc"; dlc.mkdir()
cache = tmp_path / "cache"; cache.mkdir()
_write_feedpak(dlc, "good.feedpak")
(dlc / "bad.feedpak").write_bytes(b"not a zip at all")
client = _career_client_with_library(tmp_path, meta_db, dlc, cache)
body = client.post("/api/plugins/career/gigs/prepare",
json={"songs": ["good.feedpak", "bad.feedpak"]}).json()
assert body["ok"] is True, "a bad pak must not fail the whole prepare"
assert body["prepared"] == 1
assert body["failed"] == ["bad.feedpak"]
def test_gig_prepare_degrades_without_a_library(tmp_path, meta_db, client):
# The stock fixture's context has no dlc/cache resolvers. That must be a
# graceful no-op, not a 500 — pre-extraction is an optimisation and can
# never be the reason a gig won't start.
res = client.post("/api/plugins/career/gigs/prepare", json={"songs": ["x.feedpak"]})
assert res.status_code == 200
assert res.json()["prepared"] == 0
def test_gig_prepare_empty_setlist(tmp_path, meta_db, client):
res = client.post("/api/plugins/career/gigs/prepare", json={"songs": []})
assert res.status_code == 200
assert res.json() == {"ok": True, "prepared": 0, "failed": []}