mirror of
https://github.com/got-feedBack/feedBack.git
synced 2026-09-13 08:29:28 +00:00
feat(career): tuning preference filter + interstitial for gigs
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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01H2bM5jSbMskpdxm2CmuQVj
This commit is contained in:
co-authored by
Claude Sonnet 4.6
parent
a57b62378c
commit
2a455702b8
@@ -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:<name>' 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
|
||||
|
||||
Reference in New Issue
Block a user