From c59883aac922f5ec4b885589a2be09a42613c8cc Mon Sep 17 00:00:00 2001 From: byrongamatos Date: Tue, 14 Jul 2026 01:05:20 +0200 Subject: [PATCH 1/2] =?UTF-8?q?feat(career):=20gigs=20backend=20=E2=80=94?= =?UTF-8?q?=20propose=20a=20setlist,=20log=20the=20completed=20set?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- CHANGELOG.md | 10 ++ plugins/career/passports.json | 6 + plugins/career/routes.py | 183 ++++++++++++++++++++++++- tests/plugins/career/conftest.py | 12 +- tests/plugins/career/test_passports.py | 86 ++++++++++++ 5 files changed, 291 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 89c3f8a9..b2dcfcb6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 `blob-io` helpers replace the download idiom previously duplicated in 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 - **The full mix is a stem** (#933) — core no longer depends on `original_audio:`, a diff --git a/plugins/career/passports.json b/plugins/career/passports.json index d2a44e47..e16d06f1 100644 --- a/plugins/career/passports.json +++ b/plugins/career/passports.json @@ -3,6 +3,12 @@ "songs": 5, "min_stars": 2 }, + "gig": { + "min_songs": 3, + "max_songs": 5, + "stakes_songs": 2, + "encore_accuracy": 0.75 + }, "families": [ { "key": "metal", "match": ["metal", "djent", "grindcore", "thrash", "doom"] }, { "key": "blues", "match": ["blues"] }, diff --git a/plugins/career/routes.py b/plugins/career/routes.py index 2a2ffc7c..802d2360 100644 --- a/plugins/career/routes.py +++ b/plugins/career/routes.py @@ -26,11 +26,14 @@ POST /passports/commit commit to an instrument (the wax seal, Stage 0) POST /passports/open open a genre passport for an instrument 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 json import logging +import random import re import shutil import tempfile @@ -383,6 +386,7 @@ def _passports_view(): cfg = _state["passports_content"] graded = set(cfg.get("graded_instruments") or []) st = _career_state() + all_gigs = st.get("gigs") if isinstance(st.get("gigs"), list) else [] played, played_seconds = _played_by_instrument_genre() received_at, by_node = _drill_by_node() instruments = {} @@ -439,7 +443,11 @@ def _passports_view(): "drills": {"required": required, "cleared": cleared}, "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 { "config": { "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): """Raise ValueError unless pack_dir holds a complete venue pack.""" manifest_path = pack_dir / "manifest.json" @@ -635,6 +697,125 @@ def post_drill_state(body: dict = Body(...)): "snapshot": snapshot}) 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"]) + picks = qualifying[:max(1, size - cfg["stakes_songs"])] + 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. + for s in qualifying[len(picks):] if len(picks) < size else []: + if len(picks) >= size: + break + if s not in picks: + 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") def start_download(venue_id: str): venue = _venue(venue_id) if VENUE_ID_RE.fullmatch(venue_id) else None diff --git a/tests/plugins/career/conftest.py b/tests/plugins/career/conftest.py index 205eb81d..00a94a5e 100644 --- a/tests/plugins/career/conftest.py +++ b/tests/plugins/career/conftest.py @@ -26,7 +26,7 @@ def __init__(self): self.conn.execute( """CREATE TABLE song_stats ( 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 )""" ) @@ -38,10 +38,12 @@ def __init__(self): ) def add(self, filename, arrangement, best_accuracy, in_library=True, - genre="", arrangements=None, last_played_at=None, seconds_total=0): - self.conn.execute("INSERT INTO song_stats VALUES (?, ?, ?, ?, ?)", - (filename, arrangement, best_accuracy, last_played_at, - seconds_total)) + genre="", arrangements=None, last_played_at=None, seconds_total=0, + last_accuracy=None): + self.conn.execute("INSERT INTO song_stats VALUES (?, ?, ?, ?, ?, ?)", + (filename, arrangement, best_accuracy, + last_accuracy if last_accuracy is not None else best_accuracy, + last_played_at, seconds_total)) if in_library: self.conn.execute( "INSERT INTO songs SELECT ?, ?, ?, ?, ? WHERE NOT EXISTS " diff --git a/tests/plugins/career/test_passports.py b/tests/plugins/career/test_passports.py index 13ac92f0..cfdf6af3 100644 --- a/tests/plugins/career/test_passports.py +++ b/tests/plugins/career/test_passports.py @@ -272,3 +272,89 @@ def test_nearest_targets_the_qualifying_bar_not_next_star(client, meta_db): p = _passport(client, "guitar", "soul") 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"]) +# ── 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 From 72212e7bfd231241b431e66a8e80e368507531ad Mon Sep 17 00:00:00 2001 From: byrongamatos Date: Tue, 14 Jul 2026 01:21:51 +0200 Subject: [PATCH 2/2] fix(career): gig backfill offsets by qualifying taken, not picks length MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- plugins/career/routes.py | 12 +++++++----- tests/plugins/career/test_passports.py | 13 +++++++++++++ 2 files changed, 20 insertions(+), 5 deletions(-) diff --git a/plugins/career/routes.py b/plugins/career/routes.py index 802d2360..b8bc5e1d 100644 --- a/plugins/career/routes.py +++ b/plugins/career/routes.py @@ -722,18 +722,20 @@ def propose_gig(body: dict = Body(...)): # first gig is how stubs start. random per call = free re-roll. random.shuffle(qualifying) rest.sort(key=lambda s: -s["best_accuracy"]) - picks = qualifying[:max(1, size - cfg["stakes_songs"])] + 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. - for s in qualifying[len(picks):] if len(picks) < size else []: + # 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 - if s not in picks: - picks.append(s) + picks.append(s) if len(picks) < size: exclude = {s["filename"] for s in picks} picks.extend(_unplayed_genre_songs(gkey, exclude, size - len(picks))) diff --git a/tests/plugins/career/test_passports.py b/tests/plugins/career/test_passports.py index cfdf6af3..469b14cd 100644 --- a/tests/plugins/career/test_passports.py +++ b/tests/plugins/career/test_passports.py @@ -358,3 +358,16 @@ def test_gig_propose_backfills_from_surplus_qualifying(client, meta_db): 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