Files
feedBack/plugins/career/routes.py
T
ea0ca94742 feat(career): career plugin — stars, venue tiers, pack downloads (career mode 2/3) (#907)
* feat(career): career plugin — stars from song_stats, venue tiers, pack downloads (career mode PR2)

Bundled plugin: per-song stars from best_accuracy (60/75/85% → 1/2/3★),
cumulative stars unlock bar → club → arena (data-driven venues.json).
Venue packs (UE-rendered crowd loops) download on demand to
CONFIG_DIR/plugin_uploads/career/ on a background thread with sha256 +
zip-slip validation, served via FileResponse. Career screen (promoted
sidebar entry) shows progress and pushes the active venue's manifest
into the crowd video layer (v3VenueCrowd, PR1) — degrades cleanly when
either side is absent. Pack URLs land in venues.json in PR3.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(career): keep manifest cleanup path alive on delete; badge only for installed venues

Codex preflight: nulling _appliedManifestVenue on delete skipped
pushCrowdManifest's setManifest(null) cleanup, leaving the crowd layer on
a deleted pack; and the 'playing here' badge showed for an override venue
whose pack was removed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(career): generation-guard in-flight manifest fetches

Codex preflight: a manifest fetch resolving after a newer refresh (pack
deleted, venue switched) could re-apply a stale pack over the user's
newer selection — fetches now carry a generation token and bail when
superseded.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(career): exclude orphaned song_stats from star totals

Codex preflight: scans hide rather than delete stats of removed songs, so
stars now apply the same existing-song filter other stats surfaces use
(filename IN (SELECT filename FROM songs)).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(career): 50/150 star thresholds + star collection overview

Byron's progression tuning: club at 50★, arena at 150★. /state now
returns star_detail rows (title/artist joined from the library, stars,
best accuracy, next-star threshold) sorted closest-to-next-star first,
and the career screen renders a collection panel: tier summary plus a
per-song list with a 'N% to next star' practice hint.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(career): venue select/unselect UX, intro manifest support, fullmatch guards

- 'Play here' now also defaults the visualization to Venue (remembering
  the prior viz); active venues show 'Leave venue' which restores it and
  sets the '__none__' override so no installed venue silently reapplies.
- Pack manifests may ship an intro block (flyover video + ambience mp3);
  files validate like loops/stingers, .mp3 added to the serving whitelist.
- Codex preflight: whitelist regexes use fullmatch (trailing-newline names
  could validate but 500 on serving).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(career): let pushCrowdManifest clear the manifest on Leave venue

Codex preflight: nulling _appliedManifestVenue before refresh skipped the
setManifest(null) cleanup branch, leaving the crowd playing.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(career): refresh tailwind output

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-12 22:39:19 +02:00

268 lines
11 KiB
Python

"""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:
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)
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)
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"},
)