-
Notifications
You must be signed in to change notification settings - Fork 37
feat(career): career plugin — stars, venue tiers, pack downloads (career mode 2/3) #907
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
05a9bee
a2a48b3
0cc08eb
99b6d3c
702a9c6
8031930
f8fc5e6
36d984f
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,66 @@ | ||
| /* Career plugin — only what the prebuilt core Tailwind doesn't ship | ||
| (plugin files are outside the core content glob, so responsive grid | ||
| variants and cyan button shades live here under plugin-prefixed names). */ | ||
|
|
||
| .career-venues { | ||
| display: grid; | ||
| gap: 1rem; | ||
| grid-template-columns: 1fr; | ||
| } | ||
| @media (min-width: 768px) { | ||
| .career-venues { grid-template-columns: repeat(3, minmax(0, 1fr)); } | ||
| } | ||
|
|
||
| .career-btn { | ||
| font-size: 0.75rem; | ||
| line-height: 1rem; | ||
| padding: 0.25rem 0.5rem; | ||
| border-radius: 0.375rem; | ||
| transition: background-color 0.15s ease; | ||
| } | ||
| .career-btn-primary { background-color: #0891b2; color: #fff; } | ||
| .career-btn-primary:hover { background-color: #06b6d4; } | ||
| .career-btn-ghost { background-color: rgba(31, 41, 55, 0.7); color: #d1d5db; } | ||
| .career-btn-ghost:hover { background-color: rgba(55, 65, 81, 0.9); } | ||
|
|
||
| .career-bar-track { | ||
| height: 0.5rem; | ||
| border-radius: 0.25rem; | ||
| background-color: rgba(31, 41, 55, 0.9); | ||
| overflow: hidden; | ||
| } | ||
| .career-bar-fill { | ||
| height: 100%; | ||
| background-color: #06b6d4; | ||
| transition: width 0.3s ease; | ||
| } | ||
|
|
||
| .career-star-list { | ||
| display: grid; | ||
| gap: 0.375rem; | ||
| } | ||
| .career-star-row { | ||
| display: flex; | ||
| align-items: baseline; | ||
| gap: 0.75rem; | ||
| padding: 0.375rem 0.625rem; | ||
| border-radius: 0.5rem; | ||
| background-color: rgba(31, 41, 55, 0.4); | ||
| font-size: 0.8rem; | ||
| } | ||
| .career-star-row .stars { | ||
| color: #facc15; | ||
| letter-spacing: 0.1em; | ||
| min-width: 3.2em; | ||
| } | ||
| .career-star-row .stars .off { color: rgba(250, 204, 21, 0.25); } | ||
| .career-star-row .song { | ||
| color: #e5e7eb; | ||
| flex: 1; | ||
| overflow: hidden; | ||
| text-overflow: ellipsis; | ||
| white-space: nowrap; | ||
| } | ||
| .career-star-row .song .artist { color: #9ca3af; } | ||
| .career-star-row .hint { color: #6b7280; white-space: nowrap; } | ||
| .career-star-row .hint.close { color: #22d3ee; } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,12 @@ | ||
| { | ||
| "id": "career", | ||
| "name": "Career", | ||
| "version": "0.1.0", | ||
| "bundled": true, | ||
| "private": false, | ||
| "description": "Career mode — gig your way from a local bar to the arena. Earn stars per song; the crowd reacts to how you play.", | ||
| "screen": "screen.html", | ||
| "script": "screen.js", | ||
| "styles": "assets/career.css", | ||
| "routes": "routes.py" | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,267 @@ | ||
| """Career mode — venue progression driven by per-song stars. | ||
|
|
||
| Stars come straight from ``song_stats`` (meta.db): per song, the best | ||
| accuracy across arrangements crosses 0/1/2/3 of the thresholds in | ||
| ``venues.json`` (data-driven so tuning never touches code). Cumulative | ||
| stars unlock venue tiers (bar → club → arena). | ||
|
|
||
| Venue packs (crowd-loop videos rendered offline in UE) are heavyweight and | ||
| never ship with the app: ``venues.json`` points at a release asset per | ||
| venue, downloaded on demand into ``CONFIG_DIR/plugin_uploads/career/venues/ | ||
| <id>/`` on a background thread (constitution: nothing heavy inline on the | ||
| request path), sha256-verified, then served back with the same | ||
| FileResponse/no-cache recipe as highway_3d's custom-video route. | ||
|
|
||
| Endpoints (all under /api/plugins/career/): | ||
| GET /state stars + per-venue unlock/install/download status | ||
| POST /packs/{venue_id}/download start background pack download (409 if running) | ||
| DELETE /packs/{venue_id} remove an installed pack | ||
| GET /venues/{venue_id}/{filename} serve pack files (manifest.json, loops, stingers) | ||
| """ | ||
|
|
||
| import hashlib | ||
| import json | ||
| import logging | ||
| import re | ||
| import shutil | ||
| import tempfile | ||
| import threading | ||
| import urllib.request | ||
| import zipfile | ||
| from pathlib import Path | ||
|
|
||
| from fastapi import HTTPException | ||
| from fastapi.responses import FileResponse | ||
|
|
||
| PLUGIN_ID = "career" | ||
| VENUE_ID_RE = re.compile(r"^[a-z0-9_-]{1,40}$") | ||
| PACK_FILENAME_RE = re.compile(r"^[a-z0-9_-]{1,64}\.(mp4|webm|mp3|json)$") | ||
| REQUIRED_LOOPS = ("bored", "neutral", "engaged", "ecstatic") | ||
| DOWNLOAD_CHUNK = 1024 * 256 | ||
|
|
||
| _lock = threading.Lock() | ||
| _state = { | ||
| "content": None, # parsed venues.json | ||
| "venues_dir": None, # CONFIG_DIR/plugin_uploads/career/venues | ||
| "meta_db": None, # MetadataDB (song_stats reads are lock-free / WAL) | ||
| "log": logging.getLogger("feedBack.plugin.career"), | ||
| "downloads": {}, # venue_id -> {status, bytes_done, bytes_total, error} | ||
| } | ||
|
|
||
|
|
||
| def _venue(venue_id): | ||
| for v in _state["content"]["venues"]: | ||
| if v["id"] == venue_id: | ||
| return v | ||
| return None | ||
|
|
||
|
|
||
| def _venue_dir(venue_id) -> Path: | ||
| return _state["venues_dir"] / venue_id | ||
|
|
||
|
|
||
| def _installed(venue_id): | ||
| return (_venue_dir(venue_id) / "manifest.json").is_file() | ||
|
|
||
|
|
||
| def _stars(): | ||
| """(total, per-song dict, detail rows). Accuracy is a 0..1 fraction.""" | ||
| db = _state["meta_db"] | ||
| if db is None: | ||
| return 0, {}, [] | ||
| thresholds = _state["content"]["star_accuracy_thresholds"] | ||
| # Existing-song filter: a scan hides (not deletes) stats of songs removed | ||
| # from the library, so orphaned rows must not keep counting toward stars. | ||
| rows = db.conn.execute( | ||
| "SELECT s.filename, MAX(s.best_accuracy), " | ||
| " COALESCE(MAX(sg.title), ''), COALESCE(MAX(sg.artist), '') " | ||
| "FROM song_stats s JOIN songs sg ON sg.filename = s.filename " | ||
| "GROUP BY s.filename" | ||
| ).fetchall() | ||
| per_song = {} | ||
| detail = [] | ||
| for filename, acc, title, artist in rows: | ||
| acc = acc or 0.0 | ||
| stars = sum(1 for t in thresholds if acc >= t) | ||
| if stars: | ||
| per_song[filename] = stars | ||
| next_at = next((t for t in thresholds if acc < t), None) | ||
| detail.append({ | ||
| "filename": filename, | ||
| "title": title or filename, | ||
| "artist": artist, | ||
| "stars": stars, | ||
| "best_accuracy": round(acc, 4), | ||
| "next_star_at": next_at, | ||
| }) | ||
| # closest-to-next-star first (a practice worklist), maxed songs last | ||
| detail.sort(key=lambda r: (r["next_star_at"] is None, | ||
| (r["next_star_at"] or 1.0) - r["best_accuracy"])) | ||
| return sum(per_song.values()), per_song, detail | ||
|
|
||
|
|
||
| def _validate_pack_dir(pack_dir: Path): | ||
| """Raise ValueError unless pack_dir holds a complete venue pack.""" | ||
| manifest_path = pack_dir / "manifest.json" | ||
| if not manifest_path.is_file(): | ||
| raise ValueError("pack has no manifest.json") | ||
| manifest = json.loads(manifest_path.read_text(encoding="utf-8")) | ||
| loops = manifest.get("loops") or {} | ||
| for state in REQUIRED_LOOPS: | ||
| name = loops.get(state) | ||
| if not name or not PACK_FILENAME_RE.fullmatch(name): | ||
| raise ValueError(f"manifest is missing the '{state}' loop") | ||
| if not (pack_dir / name).is_file(): | ||
| raise ValueError(f"loop file '{name}' missing from pack") | ||
| for name in (manifest.get("stingers") or {}).values(): | ||
| if name and (not PACK_FILENAME_RE.fullmatch(name) or not (pack_dir / name).is_file()): | ||
| raise ValueError(f"stinger file '{name}' invalid or missing") | ||
| for name in (manifest.get("intro") or {}).values(): | ||
| if name and (not PACK_FILENAME_RE.fullmatch(name) or not (pack_dir / name).is_file()): | ||
| raise ValueError(f"intro file '{name}' invalid or missing") | ||
|
|
||
|
|
||
| def _download_pack(venue_id, pack, progress): | ||
| """Worker thread: stream → sha256 verify → extract → validate → swap in.""" | ||
| log = _state["log"] | ||
| final_dir = _venue_dir(venue_id) | ||
| staging = Path(tempfile.mkdtemp(prefix=f"career-{venue_id}-", | ||
| dir=str(_state["venues_dir"]))) | ||
| zip_path = staging / "pack.zip" | ||
| try: | ||
|
Comment on lines
+128
to
+131
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win Keep staging allocation inside the guarded worker path.
🤖 Prompt for AI Agents |
||
| digest = hashlib.sha256() | ||
| req = urllib.request.Request(pack["url"], headers={"User-Agent": "feedBack-career"}) | ||
| with urllib.request.urlopen(req, timeout=60) as resp, open(zip_path, "wb") as out: | ||
| total = int(resp.headers.get("Content-Length") or pack.get("bytes") or 0) | ||
| progress["bytes_total"] = total | ||
| while True: | ||
| chunk = resp.read(DOWNLOAD_CHUNK) | ||
| if not chunk: | ||
| break | ||
| digest.update(chunk) | ||
| out.write(chunk) | ||
| progress["bytes_done"] += len(chunk) | ||
|
Comment on lines
+134
to
+143
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win Bound both download and extracted-pack sizes.
Also applies to: 130-139 🧰 Tools🪛 Ruff (0.15.20)[error] 115-115: Audit URL open for permitted schemes. Allowing use of (S310) 🤖 Prompt for AI Agents
Comment on lines
+136
to
+143
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win Lock progress mutations as well as reads.
Also applies to: 146-150 🤖 Prompt for AI Agents |
||
| if digest.hexdigest() != pack["sha256"]: | ||
| raise ValueError("sha256 mismatch — corrupt or tampered download") | ||
|
|
||
| extract_dir = staging / "pack" | ||
| extract_dir.mkdir() | ||
| with zipfile.ZipFile(zip_path) as zf: | ||
| for info in zf.infolist(): | ||
| # Zip-slip guard: only flat, whitelisted names get extracted. | ||
| if info.is_dir(): | ||
| continue | ||
| name = Path(info.filename).name | ||
| if name != info.filename or not PACK_FILENAME_RE.fullmatch(name): | ||
| raise ValueError(f"unexpected file in pack: {info.filename!r}") | ||
| with zf.open(info) as src, open(extract_dir / name, "wb") as dst: | ||
| shutil.copyfileobj(src, dst) | ||
| zip_path.unlink() | ||
| _validate_pack_dir(extract_dir) | ||
|
|
||
| if final_dir.exists(): | ||
| shutil.rmtree(final_dir) | ||
| extract_dir.rename(final_dir) | ||
|
Comment on lines
+162
to
+164
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🩺 Stability & Availability | 🟠 Major | 🏗️ Heavy lift Make install and removal atomic per venue.
Also applies to: 215-220 🤖 Prompt for AI Agents |
||
| progress["status"] = "done" | ||
| log.info("career: venue pack '%s' installed", venue_id) | ||
| except Exception as exc: # noqa: BLE001 — surface any failure to the UI | ||
| progress["status"] = "error" | ||
| progress["error"] = str(exc) | ||
| log.warning("career: venue pack '%s' download failed: %s", venue_id, exc) | ||
| finally: | ||
| shutil.rmtree(staging, ignore_errors=True) | ||
|
|
||
|
|
||
| def setup(app, context): | ||
| plugin_dir = Path(__file__).resolve().parent | ||
| _state["content"] = json.loads((plugin_dir / "venues.json").read_text(encoding="utf-8")) | ||
| _state["venues_dir"] = ( | ||
| Path(context["config_dir"]) / "plugin_uploads" / PLUGIN_ID / "venues") | ||
| _state["venues_dir"].mkdir(parents=True, exist_ok=True) | ||
| _state["meta_db"] = context.get("meta_db") | ||
| _state["log"] = context.get("log") or _state["log"] | ||
|
|
||
| @app.get(f"/api/plugins/{PLUGIN_ID}/state") | ||
| def get_state(): | ||
| stars_total, per_song, star_detail = _stars() | ||
| venues = [] | ||
| for v in _state["content"]["venues"]: | ||
| with _lock: | ||
| dl = dict(_state["downloads"].get(v["id"]) or {"status": "idle"}) | ||
| venues.append({ | ||
| "id": v["id"], | ||
| "name": v["name"], | ||
| "description": v.get("description", ""), | ||
| "star_threshold": v["star_threshold"], | ||
| "unlocked": stars_total >= v["star_threshold"], | ||
| "installed": _installed(v["id"]), | ||
| "has_pack": bool(v.get("pack")), | ||
| "download": dl, | ||
| }) | ||
| return { | ||
| "stars_total": stars_total, | ||
| "stars_per_song": per_song, | ||
| "star_detail": star_detail, | ||
| "star_accuracy_thresholds": _state["content"]["star_accuracy_thresholds"], | ||
| "venues": venues, | ||
| } | ||
|
|
||
| @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 | ||
| if venue is None: | ||
| raise HTTPException(404, "Unknown venue.") | ||
| pack = venue.get("pack") | ||
| if not pack: | ||
| raise HTTPException(404, "No pack published for this venue yet.") | ||
| stars_total, _, _ = _stars() | ||
| if stars_total < venue["star_threshold"]: | ||
| raise HTTPException(403, "Venue not unlocked yet.") | ||
| with _lock: | ||
| running = _state["downloads"].get(venue_id) | ||
| if running and running["status"] == "running": | ||
| raise HTTPException(409, "Download already running.") | ||
| progress = {"status": "running", "bytes_done": 0, | ||
| "bytes_total": pack.get("bytes") or 0, "error": None} | ||
| _state["downloads"][venue_id] = progress | ||
| threading.Thread(target=_download_pack, args=(venue_id, pack, progress), | ||
| name=f"career-pack-{venue_id}", daemon=True).start() | ||
| return {"ok": True} | ||
|
|
||
| @app.delete(f"/api/plugins/{PLUGIN_ID}/packs/{{venue_id}}") | ||
| def delete_pack(venue_id: str): | ||
| if not VENUE_ID_RE.fullmatch(venue_id) or _venue(venue_id) is None: | ||
| raise HTTPException(404, "Unknown venue.") | ||
| with _lock: | ||
| running = _state["downloads"].get(venue_id) | ||
| if running and running["status"] == "running": | ||
| raise HTTPException(409, "Download in progress.") | ||
| _state["downloads"].pop(venue_id, None) | ||
| shutil.rmtree(_venue_dir(venue_id), ignore_errors=True) | ||
| return {"ok": True} | ||
|
|
||
| @app.get(f"/api/plugins/{PLUGIN_ID}/venues/{{venue_id}}/{{filename}}") | ||
| async def get_pack_file(venue_id: str, filename: str): | ||
| if not VENUE_ID_RE.fullmatch(venue_id) or not PACK_FILENAME_RE.fullmatch(filename): | ||
| raise HTTPException(404, "Not found.") | ||
| path = _venue_dir(venue_id) / filename | ||
| # Defense-in-depth beyond the regexes (same recipe as highway_3d): | ||
| # the resolved path must stay inside the venues dir. | ||
| try: | ||
| resolved = path.resolve() | ||
| resolved.relative_to(_state["venues_dir"].resolve()) | ||
| except (OSError, ValueError): | ||
| raise HTTPException(404, "Not found.") | ||
| if not resolved.is_file(): | ||
| raise HTTPException(404, "Not found.") | ||
| media = {"mp4": "video/mp4", "webm": "video/webm", "mp3": "audio/mpeg", | ||
| "json": "application/json"}[resolved.suffix.lstrip(".").lower()] | ||
| return FileResponse( | ||
| resolved, | ||
| media_type=media, | ||
| # Pack files are immutable per version, but a re-download after a | ||
| # pack update overwrites in place — no-cache + ETag revalidation | ||
| # keeps browsers honest for the price of a 304. | ||
| headers={"Cache-Control": "no-cache", | ||
| "X-Content-Type-Options": "nosniff"}, | ||
| ) | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,21 @@ | ||
| <div class="max-w-5xl mx-auto px-4 py-6"> | ||
| <div class="flex items-end justify-between flex-wrap gap-3 mb-1"> | ||
| <h1 class="text-2xl font-bold text-white">Career</h1> | ||
| <div id="career-stars-summary" class="text-sm text-gray-400"></div> | ||
| </div> | ||
| <p class="text-sm text-gray-400 mb-4">Earn stars by playing songs well — 60% accuracy is a star, 75% two, 85% three. Stars unlock bigger stages, and the crowd plays along with you.</p> | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win Derive the accuracy copy from configured thresholds. This text hard-codes 🤖 Prompt for AI Agents |
||
| <div id="career-progress-wrap" class="mb-6"> | ||
| <div class="career-bar-track"> | ||
| <div id="career-progress-bar" class="career-bar-fill" style="width:0%"></div> | ||
| </div> | ||
| <div id="career-progress-label" class="text-xs text-gray-500 mt-1"></div> | ||
| </div> | ||
| <div id="career-venues" class="career-venues"></div> | ||
| <div class="mt-8"> | ||
| <div class="flex items-end justify-between flex-wrap gap-2 mb-2"> | ||
| <h2 class="text-lg font-semibold text-white">Your star collection</h2> | ||
| <div id="career-star-summary" class="text-xs text-gray-400"></div> | ||
| </div> | ||
| <div id="career-star-list" class="career-star-list"></div> | ||
| </div> | ||
| </div> | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win
Reject non-video files for loop and stinger entries.
PACK_FILENAME_REallows.jsonfor every manifest value, so a malformed pack can pointloops.boredatmanifest.jsonand still install. The frontend then treats JSON as video. Use a media-only filename rule for loop and stinger values, reserving.jsonformanifest.json.🤖 Prompt for AI Agents