Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 6 additions & 0 deletions plugins/career/passports.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"] },
Expand Down
185 changes: 184 additions & 1 deletion plugins/career/routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 = {}
Expand Down Expand Up @@ -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 {},
Expand All @@ -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"
Expand Down Expand Up @@ -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.")
Comment thread
coderabbitai[bot] marked this conversation as resolved.
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
Expand Down
12 changes: 7 additions & 5 deletions tests/plugins/career/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
)"""
)
Expand All @@ -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 "
Expand Down
99 changes: 99 additions & 0 deletions tests/plugins/career/test_passports.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Loading