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..b8bc5e1d 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,127 @@ 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"]) + 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") 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..469b14cd 100644 --- a/tests/plugins/career/test_passports.py +++ b/tests/plugins/career/test_passports.py @@ -272,3 +272,102 @@ 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 + + +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