mirror of
https://github.com/got-feedBack/feedBack.git
synced 2026-08-13 04:09:26 +00:00
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:
@@ -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 "")
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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');
|
||||||
|
});
|
||||||
@@ -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": []}
|
||||||
|
|||||||
Reference in New Issue
Block a user