From 2a455702b80ab86d78d379e1f9ffc297d3ad2b1c Mon Sep 17 00:00:00 2001 From: byrongamatos Date: Thu, 3 Sep 2026 17:52:30 +0200 Subject: [PATCH] feat(career): tuning preference filter + interstitial for gigs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Users can now pick a tuning preference before booking a gig: - Any (default), Standard only, Drop only, or a specific tuning - Backend filters the song pool (stubs + filler) by that preference - Empty-filter case returns a 404 with a descriptive message; frontend reverts the pref to 'any' and shows a notification - Interstitial pause before first song and on tuning changes (all prefs except 'specific') via window.feedBack.holdAutoplay(); opens the tuner panel in auto mode while the user retunes - 'Specific' gigs skip interstitials (every song already shares one tuning) - Graceful degradation: no holdAutoplay → interstitial silently skipped New backend: - _tuning_ok_fn helper for standard/drop/specific classification - _fill_genre_songs accepts optional tuning_ok filter - propose_gig batch-fetches tuning_name for played stubs, applies filter - GET /gigs/tunings endpoint for the specific-tuning picker Tests: - tests/test_career_gig_tuning.py — 17 Python tests (classification, filter) - tests/js/career_gig_tuning.test.js — 9 JS tests (interstitial logic) - tests/plugins/career/conftest.py — songs table schema gets tuning_name Co-Authored-By: Claude Sonnet 4.6 Claude-Session: https://claude.ai/code/session_01H2bM5jSbMskpdxm2CmuQVj --- plugins/career/assets/career.css | 12 +++ plugins/career/routes.py | 81 ++++++++++++-- plugins/career/screen.js | 136 +++++++++++++++++++++-- tests/js/career_gig_tuning.test.js | 168 +++++++++++++++++++++++++++++ tests/plugins/career/conftest.py | 11 +- tests/test_career_gig_tuning.py | 130 ++++++++++++++++++++++ 6 files changed, 519 insertions(+), 19 deletions(-) create mode 100644 tests/js/career_gig_tuning.test.js create mode 100644 tests/test_career_gig_tuning.py diff --git a/plugins/career/assets/career.css b/plugins/career/assets/career.css index 921eae3..e215716 100644 --- a/plugins/career/assets/career.css +++ b/plugins/career/assets/career.css @@ -789,3 +789,15 @@ } .pp-giglog-row { font-size: 0.7rem; color: #6d5d40; padding: 0.1rem 0; } .pp-giglog-row b { color: #9a5b16; letter-spacing: 0.06em; } + +/* Tuning preference pills on gig poster */ +.pp-tuning-row { display: flex; align-items: center; gap: 0.4rem; flex-wrap: wrap; margin: 0.5rem 0 0.3rem; } +.pp-tuning-pill { font-size: 0.65rem; padding: 0.2rem 0.6rem; border-radius: 999px; opacity: 0.7; } +.pp-tuning-pill-on { opacity: 1; border-color: #d9a253; color: #d9a253; } +.pp-tuning-select { font-size: 0.7rem; background: #1a1510; color: #c8b48a; border: 1px solid rgba(138,122,94,0.5); border-radius: 4px; padding: 0.15rem 0.4rem; } +.pp-poster-tuning-chip { font-size: 0.6rem; color: #8a7a5e; margin-left: 0.35rem; } + +/* Gig interstitial — tune up banner */ +.pp-gig-strip.pp-interstitial { pointer-events: auto; display: flex; align-items: center; gap: 0.75rem; border-radius: 8px; border-color: rgba(64,128,224,0.5); } +.pp-gig-tune-label { color: #4080e0; letter-spacing: 0.08em; font-size: 0.78rem; } +.pp-gig-start-btn { font-size: 0.7rem; padding: 0.25rem 0.7rem; } diff --git a/plugins/career/routes.py b/plugins/career/routes.py index f53a510..24c0934 100644 --- a/plugins/career/routes.py +++ b/plugins/career/routes.py @@ -529,7 +529,7 @@ def _current_venue(): return best -def _fill_genre_songs(gkey, exclude, limit): +def _fill_genre_songs(gkey, exclude, limit, tuning_ok=None): """Library songs of a genre to round out a gig — ANY song of the genre the set hasn't already picked. @@ -553,17 +553,41 @@ def _fill_genre_songs(gkey, exclude, limit): if db is None: return [] rows = db.conn.execute( - f"SELECT filename, title, artist, {_genre_expr(db)} AS g FROM songs" + f"SELECT filename, title, artist, {_genre_expr(db)} AS g, tuning_name FROM songs" ).fetchall() pool = [ - {"filename": filename, "title": title or filename, "artist": artist or ""} - for filename, title, artist, genre in rows - if _genre_key(genre) == gkey and filename not in exclude + {"filename": fn, "title": title or fn, "artist": artist or "", "tuning_name": tn or ""} + for fn, title, artist, genre, tn in rows + if _genre_key(genre) == gkey and fn not in exclude + and (tuning_ok is None or tuning_ok(tn or "")) ] random.shuffle(pool) # re-roll must vary; free per call return pool[:limit] +def _tuning_ok_fn(tuning_pref): + """Return a (tuning_name: str) -> bool callable for the given preference. + + Returns None for 'any' (no filter). 'standard' matches names ending in + ' Standard'; 'drop' matches names containing 'Drop' (covers Drop D, Drop C, + Double Drop D, etc.); 'specific:' matches exact names. Unknown or + malformed prefs treat as 'any' rather than hard-failing — a stale client + request must not break gig booking. + """ + if not tuning_pref or tuning_pref == "any": + return None + if tuning_pref == "standard": + return lambda n: bool(n) and n.endswith(" Standard") + if tuning_pref == "drop": + return lambda n: bool(n) and "Drop" in n + if tuning_pref.startswith("specific:"): + spec = tuning_pref[len("specific:"):] + if not spec or len(spec) > 64: + return None + return lambda n, _s=spec: n == _s + return None # unknown pref → no filter + + def _validate_pack_dir(pack_dir: Path): """Raise ValueError unless pack_dir holds a complete venue pack.""" manifest_path = pack_dir / "manifest.json" @@ -823,6 +847,8 @@ def setup(app, context): raise HTTPException(400, "Unknown instrument.") if not gkey or len(genre) > GENRE_MAX_LEN: raise HTTPException(400, "Provide a genre.") + tuning_pref = str((body or {}).get("tuning_pref") or "any") + tuning_ok = _tuning_ok_fn(tuning_pref) cfg = _gig_config() try: size = int((body or {}).get("size") or 4) @@ -831,6 +857,18 @@ def setup(app, context): size = max(cfg["min_songs"], min(cfg["max_songs"], size)) played, _seconds = _played_by_instrument_genre() stubs = list(played.get((inst, gkey), {}).values()) + # Annotate stubs with tuning_name (batch lookup to avoid N+1). + if stubs and _state["meta_db"] is not None: + fns = [s["filename"] for s in stubs] + ph = ",".join("?" * len(fns)) + tn_rows = _state["meta_db"].conn.execute( + f"SELECT filename, tuning_name FROM songs WHERE filename IN ({ph})", fns + ).fetchall() + tn_by_file = {fn: (tn or "") for fn, tn in tn_rows} + for s in stubs: + s.setdefault("tuning_name", tn_by_file.get(s["filename"], "")) + if tuning_ok is not None: + stubs = [s for s in stubs if tuning_ok(s.get("tuning_name", ""))] 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"]] @@ -855,18 +893,26 @@ def setup(app, context): picks.append(s) if len(picks) < size: exclude = {s["filename"] for s in picks} - picks.extend(_fill_genre_songs(gkey, exclude, size - len(picks))) + picks.extend(_fill_genre_songs(gkey, exclude, size - len(picks), tuning_ok)) if not picks: + if tuning_pref and tuning_pref != "any": + label = ("standard-tuning " if tuning_pref == "standard" + else "drop-tuning " if tuning_pref == "drop" + else f"“{tuning_pref[len('specific:'):]}”-tuning " + if tuning_pref.startswith("specific:") else "") + raise HTTPException(404, f"No {label}songs of this genre in the library.") raise HTTPException(404, "No songs of this genre in the library.") venue = _current_venue() return { "instrument": inst, "genre": genre, "genre_key": gkey, + "tuning_pref": tuning_pref, "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]], + "artist": s.get("artist") or "", "tuning_name": s.get("tuning_name") or ""} + for s in picks[:size]], } @app.post(f"/api/plugins/{PLUGIN_ID}/gigs") @@ -935,6 +981,27 @@ def setup(app, context): _save_json(_state_file(), st) return {"ok": True, "gig": gig} + @app.get(f"/api/plugins/{PLUGIN_ID}/gigs/tunings") + def gig_tunings(genre: str = ""): + """Distinct tuning names present in a genre's song pool, for the + specific-tuning picker in the gig poster UI. Sorted by the library's + own tuning_sort_key so the list matches the main library tuning filter.""" + gkey = _genre_key(_genre_display(genre)) if genre else "" + if not gkey: + return {"tunings": []} + db = _state["meta_db"] + if db is None: + return {"tunings": []} + rows = db.conn.execute( + f"SELECT tuning_name, tuning_sort_key, {_genre_expr(db)} AS g " + "FROM songs WHERE tuning_name != ''" + ).fetchall() + seen: dict[str, int] = {} + for tn, sk, genre_raw in rows: + if _genre_key(genre_raw) == gkey and tn and tn not in seen: + seen[tn] = sk or 0 + return {"tunings": sorted(seen.keys(), key=lambda n: (seen[n], n))} + @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/plugins/career/screen.js b/plugins/career/screen.js index 4e47db1..10f3ca9 100644 --- a/plugins/career/screen.js +++ b/plugins/career/screen.js @@ -26,6 +26,7 @@ const PP_SEEN_KEY = 'feedBack-career-badges-seen'; const PP_INST_KEY = 'feedBack-career-instrument'; const PP_TAB_KEY = 'feedBack-career-tab'; + const PP_TUNING_PREF_KEY = 'feedBack-career-tuning-pref'; const PP_LABELS = { guitar: 'Guitar', bass: 'Bass', keys: 'Keys', drums: 'Drums' }; const PP_BROCHURE_ART = ['🎸', '🎷', '🎹', '🥁', '🎺', '🎻', '🎤', '🪕']; @@ -43,7 +44,11 @@ let _ppBootstrapped = false; let _ppNotified = {}; // badges chimed this session (slam still pending) let _ppGigProposal = null; // the booking poster's proposal, while open - let _ppGigRun = null; // {songs, venue_id, genre, genre_key, instrument, idx} mid-set + let _ppGigRun = null; // {songs, venue_id, genre, genre_key, instrument, tuning_pref, idx} mid-set + let _ppGigTuningPref = 'any'; // loaded from localStorage in boot() + let _ppGigTuningNames = []; // cached distinct tuning names for the specific picker + let _ppGigTuningHold = null; // holdAutoplay release fn — non-null = interstitial active + let _ppGigLastTuning = null; // tuning_name of the current gig song (for change detection) function $(id) { return document.getElementById(id); } @@ -1085,13 +1090,25 @@ function gigPosterHTML(prop) { const bill = prop.songs.map((s, i) => - `
${i + 1}. ${esc(s.title)}${s.artist ? ` ${esc(s.artist)}` : ''}
`).join(''); + `
${i + 1}. ${esc(s.title)}${s.artist ? ` ${esc(s.artist)}` : ''}${s.tuning_name ? ` ${esc(s.tuning_name)}` : ''}
`).join(''); + const PREF_LABELS = { any: 'Any', standard: 'Standard', drop: 'Drop', specific: 'Specific…' }; + const isSpecific = _ppGigTuningPref.startsWith('specific:'); + const curPill = isSpecific ? 'specific' : _ppGigTuningPref; + const pills = Object.keys(PREF_LABELS).map((p) => + `` + ).join(''); + const selVal = isSpecific ? _ppGigTuningPref.slice('specific:'.length) : ''; + const selOpts = _ppGigTuningNames.length + ? `${_ppGigTuningNames.map((n) => ``).join('')}` + : ``; + const selHidden = isSpecific ? '' : ' hidden'; return `