feat(career): gigs backend — propose a setlist, log the completed set (#954)

* feat(career): gigs backend — propose a setlist, log the completed set

Career v3, WS3 (backend half). A gig is career's verb:

- POST /gigs/propose {instrument, genre, size}: setlist from the
  passport's own stubs — qualifying songs (per the genre's badge bar,
  family-aware) shuffled for a free re-roll, topped with the
  highest-accuracy near-bar songs as stakes, and filled from UNPLAYED
  genre songs when the passport is young (the first gig is how stubs
  start). Names the highest venue the current stars can book.
- POST /gigs: logs a COMPLETED set only (abandoned sets never log — no
  fail state). Per-song accuracy = MAX(last_accuracy) from song_stats,
  freshly written by the set's own plays; encore = avg ≥ the data-driven
  bar (passports.json gig.encore_accuracy, 0.75). Appends to the career
  state file (same atomic _save_json pattern).
- Passports view: per-passport gigs (newest first, capped 20) and
  per-instrument gig_count — the profile wall's gig line lights up.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(career): gig backfill offsets by qualifying taken, not picks length

CodeRabbit on #954: after the stakes loop appends near-bar songs,
qualifying[len(picks):] overshoots and skips eligible qualifying songs
— a stocked passport could still get a short set.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Byron Gamatos
2026-07-14 01:26:23 +02:00
committed by GitHub
co-authored by Claude Fable 5
parent 6272af8d33
commit 7c897e9f2b
5 changed files with 306 additions and 6 deletions
+10
View File
@@ -19,6 +19,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
copied straight to the clipboard for pasting outside the app (shared copied straight to the clipboard for pasting outside the app (shared
`blob-io` helpers replace the download idiom previously duplicated in `blob-io` helpers replace the download idiom previously duplicated in
settings-io and diagnostics-export). settings-io and diagnostics-export).
- **Gigs (backend)** — career mode gains its verb: `POST
/api/plugins/career/gigs/propose` builds a playable setlist for an
instrument+genre (your qualifying songs plus a couple of stakes songs near
the bar; a young passport fills from unplayed genre songs — the first gig
is how stubs start; re-roll by calling again), naming the room your stars
can book. `POST /gigs` logs a **completed** set — per-song accuracies read
from the set's own freshly-recorded stats, an encore flag at the
data-driven bar (avg ≥ 75%) — into the career state; abandoned sets never
log (no fail state: the gig you finished is the gig you played). Passports
carry their gig log; instruments their gig count.
### Changed ### Changed
- **The full mix is a stem** (#933) — core no longer depends on `original_audio:`, a - **The full mix is a stem** (#933) — core no longer depends on `original_audio:`, a
+6
View File
@@ -3,6 +3,12 @@
"songs": 5, "songs": 5,
"min_stars": 2 "min_stars": 2
}, },
"gig": {
"min_songs": 3,
"max_songs": 5,
"stakes_songs": 2,
"encore_accuracy": 0.75
},
"families": [ "families": [
{ "key": "metal", "match": ["metal", "djent", "grindcore", "thrash", "doom"] }, { "key": "metal", "match": ["metal", "djent", "grindcore", "thrash", "doom"] },
{ "key": "blues", "match": ["blues"] }, { "key": "blues", "match": ["blues"] },
+184 -1
View File
@@ -26,11 +26,14 @@ Endpoints (all under /api/plugins/career/):
POST /passports/commit commit to an instrument (the wax seal, Stage 0) POST /passports/commit commit to an instrument (the wax seal, Stage 0)
POST /passports/open open a genre passport for an instrument POST /passports/open open a genre passport for an instrument
POST /drill-state relayed virtuoso.progress snapshot (drill intake) POST /drill-state relayed virtuoso.progress snapshot (drill intake)
POST /gigs/propose build a playable setlist for a genre gig
POST /gigs log a COMPLETED gig (abandoned sets never log)
""" """
import hashlib import hashlib
import json import json
import logging import logging
import random
import re import re
import shutil import shutil
import tempfile import tempfile
@@ -383,6 +386,7 @@ def _passports_view():
cfg = _state["passports_content"] cfg = _state["passports_content"]
graded = set(cfg.get("graded_instruments") or []) graded = set(cfg.get("graded_instruments") or [])
st = _career_state() st = _career_state()
all_gigs = st.get("gigs") if isinstance(st.get("gigs"), list) else []
played, played_seconds = _played_by_instrument_genre() played, played_seconds = _played_by_instrument_genre()
received_at, by_node = _drill_by_node() received_at, by_node = _drill_by_node()
instruments = {} instruments = {}
@@ -439,7 +443,11 @@ def _passports_view():
"drills": {"required": required, "cleared": cleared}, "drills": {"required": required, "cleared": cleared},
"badge": badge, "badge": badge,
}) })
instruments[inst] = {"committed_at": committed_at, "passports": passports} inst_gigs = [g for g in all_gigs if g.get("instrument") == inst]
for p in passports:
p["gigs"] = [g for g in inst_gigs if g.get("genre_key") == p["genre_key"]][-20:][::-1]
instruments[inst] = {"committed_at": committed_at, "passports": passports,
"gig_count": len(inst_gigs)}
return { return {
"config": { "config": {
"badge_requirement": cfg.get("badge_requirement") or {}, "badge_requirement": cfg.get("badge_requirement") or {},
@@ -454,6 +462,60 @@ def _passports_view():
} }
def _gig_config():
cfg = _state["passports_content"].get("gig")
cfg = cfg if isinstance(cfg, dict) else {}
def _num(key, default, cast):
# Tuning data, not code: junk falls back instead of 500ing both gig
# endpoints, and a legitimate 0 (stakes_songs: 0) is respected.
val = cfg.get(key)
if isinstance(val, bool) or not isinstance(val, (int, float)):
return default
return cast(val)
return {
"min_songs": max(1, _num("min_songs", 3, int)),
"max_songs": max(1, _num("max_songs", 5, int)),
"stakes_songs": max(0, _num("stakes_songs", 2, int)),
"encore_accuracy": _num("encore_accuracy", 0.75, float),
}
def _current_venue():
"""Highest unlocked venue (the room you can book today)."""
stars_total, _, _ = _stars()
best = None
for v in _state["content"]["venues"]:
if stars_total >= v["star_threshold"]:
if best is None or v["star_threshold"] >= best["star_threshold"]:
best = v
return best
def _unplayed_genre_songs(gkey, exclude, limit):
"""Library songs of a genre with no stats yet — a young passport's gig
still gets a full set (playing them is how stubs start).
ponytail: full stat-less scan + python-side genre match (a few ms at 7k
songs, single-user); push the match into SQL if propose ever feels slow."""
db = _state["meta_db"]
if db is None:
return []
rows = db.conn.execute(
f"SELECT filename, title, artist, {_genre_expr(db)} AS g FROM songs "
"WHERE filename NOT IN (SELECT filename FROM song_stats)"
).fetchall()
out = []
for filename, title, artist, genre in rows:
if _genre_key(genre) != gkey or filename in exclude:
continue
out.append({"filename": filename, "title": title or filename,
"artist": artist or ""})
if len(out) >= limit:
break
return out
def _validate_pack_dir(pack_dir: Path): def _validate_pack_dir(pack_dir: Path):
"""Raise ValueError unless pack_dir holds a complete venue pack.""" """Raise ValueError unless pack_dir holds a complete venue pack."""
manifest_path = pack_dir / "manifest.json" manifest_path = pack_dir / "manifest.json"
@@ -635,6 +697,127 @@ def setup(app, context):
"snapshot": snapshot}) "snapshot": snapshot})
return {"ok": True} return {"ok": True}
@app.post(f"/api/plugins/{PLUGIN_ID}/gigs/propose")
def propose_gig(body: dict = Body(...)):
inst = str((body or {}).get("instrument") or "")
genre = _genre_display((body or {}).get("genre"))
gkey = genre.lower()
if inst not in (_state["passports_content"].get("instruments") or []):
raise HTTPException(400, "Unknown instrument.")
if not gkey or len(genre) > GENRE_MAX_LEN:
raise HTTPException(400, "Provide a genre.")
cfg = _gig_config()
try:
size = int((body or {}).get("size") or 4)
except (TypeError, ValueError):
raise HTTPException(400, "size must be a number.")
size = max(cfg["min_songs"], min(cfg["max_songs"], size))
played, _seconds = _played_by_instrument_genre()
stubs = list(played.get((inst, gkey), {}).values())
req = _badge_requirement(gkey, inst)
qualifying = [s for s in stubs if s["stars"] >= req["min_stars"]]
rest = [s for s in stubs if s["stars"] < req["min_stars"]]
# The set: mostly songs you own, plus a couple of stakes songs near
# the bar; a young passport fills from unplayed genre songs so the
# first gig is how stubs start. random per call = free re-roll.
random.shuffle(qualifying)
rest.sort(key=lambda s: -s["best_accuracy"])
qtaken = max(1, size - cfg["stakes_songs"])
picks = qualifying[:qtaken]
for s in rest:
if len(picks) >= size:
break
picks.append(s)
# Surplus qualifying songs backfill a short set — a mature passport
# with no near-bar songs left must still fill the bill. Offset by how
# many QUALIFYING songs were taken, not len(picks): rest's stakes
# additions would otherwise skip eligible qualifying songs entirely.
for s in qualifying[qtaken:]:
if len(picks) >= size:
break
picks.append(s)
if len(picks) < size:
exclude = {s["filename"] for s in picks}
picks.extend(_unplayed_genre_songs(gkey, exclude, size - len(picks)))
if not picks:
raise HTTPException(404, "No songs of this genre in the library.")
venue = _current_venue()
return {
"instrument": inst,
"genre": genre,
"genre_key": gkey,
"venue_id": venue["id"] if venue else None,
"venue_name": venue["name"] if venue else "",
"songs": [{"filename": s["filename"], "title": s.get("title") or s["filename"],
"artist": s.get("artist") or ""} for s in picks[:size]],
}
@app.post(f"/api/plugins/{PLUGIN_ID}/gigs")
def log_gig(body: dict = Body(...)):
# Called by the runner ONLY when the set completed — an abandoned set
# never logs (no fail state; the gig you finished is the gig you
# played). Accuracies come from song_stats, freshly written by the
# set's own plays.
inst = str((body or {}).get("instrument") or "")
genre = _genre_display((body or {}).get("genre"))
gkey = genre.lower()
venue_id = str((body or {}).get("venue_id") or "")
songs = (body or {}).get("songs")
if inst not in (_state["passports_content"].get("instruments") or []):
raise HTTPException(400, "Unknown instrument.")
if not gkey or len(genre) > GENRE_MAX_LEN:
raise HTTPException(400, "Provide a genre.")
if venue_id and (not VENUE_ID_RE.fullmatch(venue_id) or _venue(venue_id) is None):
raise HTTPException(400, "Unknown venue.")
if (not isinstance(songs, list) or not songs or len(songs) > 8
or not all(isinstance(f, str) and f.strip() for f in songs)):
raise HTTPException(400, "songs must be 1-8 filenames.")
db = _state["meta_db"]
entries = []
accuracies = []
for filename in songs:
title = filename
accuracy = None
if db is not None:
# The NEWEST row is the set's own just-recorded play — a
# MAX(last_accuracy) across arrangements would happily log a
# stale higher score from another instrument's old session.
row = db.conn.execute(
"SELECT last_accuracy FROM song_stats WHERE filename = ? "
"ORDER BY last_played_at DESC LIMIT 1",
(filename,)).fetchone()
if row and row[0] is not None:
accuracy = round(float(row[0]), 4)
accuracies.append(accuracy)
trow = db.conn.execute(
"SELECT title FROM songs WHERE filename = ?", (filename,)).fetchone()
if trow and trow[0]:
title = trow[0]
entries.append({"filename": filename, "title": title, "accuracy": accuracy})
# Encore needs the WHOLE set scored at the bar — one scored song must
# not earn an encore for a set that was 4/5 unheard.
encore = (len(accuracies) == len(songs) and
sum(accuracies) / len(accuracies) >= _gig_config()["encore_accuracy"])
gig = {
"at": _now_iso(),
"venue_id": venue_id or None,
"instrument": inst,
"genre": genre,
"genre_key": gkey,
"songs": entries,
"encore": encore,
}
with _lock:
st = _career_state()
if not isinstance(st.get("gigs"), list):
st["gigs"] = []
st["gigs"].append(gig)
# ponytail: hard cap — nothing reads past the last 20 per
# passport; the state file must not grow (and export) forever.
st["gigs"] = st["gigs"][-500:]
_save_json(_state_file(), st)
return {"ok": True, "gig": gig}
@app.post(f"/api/plugins/{PLUGIN_ID}/packs/{{venue_id}}/download") @app.post(f"/api/plugins/{PLUGIN_ID}/packs/{{venue_id}}/download")
def start_download(venue_id: str): def start_download(venue_id: str):
venue = _venue(venue_id) if VENUE_ID_RE.fullmatch(venue_id) else None venue = _venue(venue_id) if VENUE_ID_RE.fullmatch(venue_id) else None
+7 -5
View File
@@ -26,7 +26,7 @@ class FakeMetaDb:
self.conn.execute( self.conn.execute(
"""CREATE TABLE song_stats ( """CREATE TABLE song_stats (
filename TEXT, arrangement TEXT, best_accuracy REAL, filename TEXT, arrangement TEXT, best_accuracy REAL,
last_played_at TEXT, last_accuracy REAL, last_played_at TEXT,
seconds_total REAL NOT NULL DEFAULT 0 seconds_total REAL NOT NULL DEFAULT 0
)""" )"""
) )
@@ -38,10 +38,12 @@ class FakeMetaDb:
) )
def add(self, filename, arrangement, best_accuracy, in_library=True, def add(self, filename, arrangement, best_accuracy, in_library=True,
genre="", arrangements=None, last_played_at=None, seconds_total=0): genre="", arrangements=None, last_played_at=None, seconds_total=0,
self.conn.execute("INSERT INTO song_stats VALUES (?, ?, ?, ?, ?)", last_accuracy=None):
(filename, arrangement, best_accuracy, last_played_at, self.conn.execute("INSERT INTO song_stats VALUES (?, ?, ?, ?, ?, ?)",
seconds_total)) (filename, arrangement, best_accuracy,
last_accuracy if last_accuracy is not None else best_accuracy,
last_played_at, seconds_total))
if in_library: if in_library:
self.conn.execute( self.conn.execute(
"INSERT INTO songs SELECT ?, ?, ?, ?, ? WHERE NOT EXISTS " "INSERT INTO songs SELECT ?, ?, ?, ?, ? WHERE NOT EXISTS "
+99
View File
@@ -272,3 +272,102 @@ def test_nearest_targets_the_qualifying_bar_not_next_star(client, meta_db):
p = _passport(client, "guitar", "soul") p = _passport(client, "guitar", "soul")
assert [s["filename"] for s in p["nearest"]] == ["one_star.feedpak", "zero_star.feedpak"] assert [s["filename"] for s in p["nearest"]] == ["one_star.feedpak", "zero_star.feedpak"]
assert all(s["bar_at"] == 0.75 for s in p["nearest"]) assert all(s["bar_at"] == 0.75 for s in p["nearest"])
# ── Gigs ──────────────────────────────────────────────────────────────────────
def test_gig_propose_mixes_owned_and_stakes(client, meta_db):
for i in range(4):
meta_db.add(f"own{i}.feedpak", 0, 0.85, genre="Soul", arrangements=LEAD)
meta_db.add("stake.feedpak", 0, 0.70, genre="Soul", arrangements=LEAD)
meta_db.add_song_only("fresh.feedpak", genre="Soul")
res = client.post("/api/plugins/career/gigs/propose",
json={"instrument": "guitar", "genre": "Soul", "size": 4})
assert res.status_code == 200
gig = res.json()
files = [s["filename"] for s in gig["songs"]]
assert len(files) == 4
assert "stake.feedpak" in files # a near-bar song gives the set stakes
assert gig["venue_id"] == "bar" # 9 stars < 50: the dive bar
# A young passport (nothing played) still gets a playable set from the
# library's unplayed genre songs.
res2 = client.post("/api/plugins/career/gigs/propose",
json={"instrument": "guitar", "genre": "Ska"})
assert res2.status_code == 404 # no ska in the library at all
meta_db.add_song_only("ska1.feedpak", genre="Ska")
res3 = client.post("/api/plugins/career/gigs/propose",
json={"instrument": "guitar", "genre": "Ska"})
assert [s["filename"] for s in res3.json()["songs"]] == ["ska1.feedpak"]
def test_gig_log_computes_encore_and_surfaces_in_passports(client, meta_db):
for i in range(2):
meta_db.add(f"s{i}.feedpak", 0, 0.9, genre="Soul", arrangements=LEAD,
last_accuracy=0.9)
_open(client, "guitar", "Soul")
res = client.post("/api/plugins/career/gigs", json={
"instrument": "guitar", "genre": "Soul", "venue_id": "bar",
"songs": ["s0.feedpak", "s1.feedpak"]})
assert res.status_code == 200
gig = res.json()["gig"]
assert gig["encore"] is True # avg 0.9 ≥ 0.75
assert gig["songs"][0]["accuracy"] == 0.9
view = client.get("/api/plugins/career/passports").json()
assert view["instruments"]["guitar"]["gig_count"] == 1
p = _passport(client, "guitar", "soul")
assert len(p["gigs"]) == 1 and p["gigs"][0]["encore"] is True
def test_gig_log_validation_and_no_fail_state(client):
# Unknown venue / bad songs shapes are rejected; nothing is ever logged
# as a failed gig — the endpoint only appends completed sets.
assert client.post("/api/plugins/career/gigs", json={
"instrument": "guitar", "genre": "Soul", "venue_id": "nope",
"songs": ["x"]}).status_code == 400
assert client.post("/api/plugins/career/gigs", json={
"instrument": "guitar", "genre": "Soul", "songs": []}).status_code == 400
assert client.post("/api/plugins/career/gigs", json={
"instrument": "guitar", "genre": "Soul",
"songs": ["f"] * 9}).status_code == 400
def test_gig_accuracy_reads_newest_row_and_encore_needs_full_set(client, meta_db):
# Newest row wins: a stale higher accuracy on another arrangement must
# not inflate the gig log.
meta_db.add("dual.feedpak", 1, 0.95, genre="Soul", arrangements=BASS,
last_accuracy=0.95, last_played_at="2026-06-01T00:00:00")
meta_db.add("dual.feedpak", 0, 0.60, genre="Soul", arrangements=LEAD,
last_accuracy=0.60, last_played_at="2026-07-14T00:00:00")
res = client.post("/api/plugins/career/gigs", json={
"instrument": "guitar", "genre": "Soul", "songs": ["dual.feedpak"]})
assert res.json()["gig"]["songs"][0]["accuracy"] == 0.6
# A set with an unscored song never earns the encore off one good song.
meta_db.add("scored.feedpak", 0, 0.9, genre="Soul", arrangements=LEAD,
last_accuracy=0.9, last_played_at="2026-07-14T00:01:00")
res2 = client.post("/api/plugins/career/gigs", json={
"instrument": "guitar", "genre": "Soul",
"songs": ["scored.feedpak", "ghost.feedpak"]})
assert res2.json()["gig"]["encore"] is False
def test_gig_propose_backfills_from_surplus_qualifying(client, meta_db):
# Mature passport: plenty of qualifying songs, nothing near the bar,
# nothing unplayed — the set still fills to size.
for i in range(8):
meta_db.add(f"own{i}.feedpak", 0, 0.9, genre="Ska", arrangements=LEAD)
res = client.post("/api/plugins/career/gigs/propose",
json={"instrument": "guitar", "genre": "Ska", "size": 5})
assert len(res.json()["songs"]) == 5
def test_gig_propose_backfill_offset_survives_stakes(client, meta_db):
# 4 qualifying + 1 near-bar stake, size 5: the stake must not shift the
# qualifying backfill window past eligible songs.
for i in range(4):
meta_db.add(f"q{i}.feedpak", 0, 0.9, genre="Reggae", arrangements=LEAD)
meta_db.add("near.feedpak", 0, 0.7, genre="Reggae", arrangements=LEAD)
res = client.post("/api/plugins/career/gigs/propose",
json={"instrument": "guitar", "genre": "Reggae", "size": 5})
files = [s["filename"] for s in res.json()["songs"]]
assert len(files) == 5 and len(set(files)) == 5
assert "near.feedpak" in files