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
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,9 @@ plugins/*/
!plugins/achievements/
!plugins/achievements/**
plugins/achievements/__pycache__/
!plugins/career/
!plugins/career/**
plugins/career/__pycache__/
!plugins/highway_3d/
!plugins/highway_3d/**
plugins/highway_3d/__pycache__/
Expand Down
66 changes: 66 additions & 0 deletions plugins/career/assets/career.css
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; }
12 changes: 12 additions & 0 deletions plugins/career/plugin.json
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"
}
267 changes: 267 additions & 0 deletions plugins/career/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")
Comment on lines +110 to +118

Copy link
Copy Markdown
Contributor

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_RE allows .json for every manifest value, so a malformed pack can point loops.bored at manifest.json and still install. The frontend then treats JSON as video. Use a media-only filename rule for loop and stinger values, reserving .json for manifest.json.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@plugins/career/routes.py` around lines 94 - 102, Update the validation around
REQUIRED_LOOPS and stingers to use a media-only filename rule that rejects .json
and accepts only supported video files; continue checking that each referenced
file exists in pack_dir. Keep PACK_FILENAME_RE available for manifest.json
validation, but do not use it for loop or stinger entries.

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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The 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.

mkdtemp() runs before try; if the config directory is unavailable or disk-full, the thread exits with the download permanently marked running. The UI will poll forever and DELETE will remain 409. Initialize staging = None, create it inside try, and ensure failures update the progress state.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@plugins/career/routes.py` around lines 109 - 112, Move staging initialization
in the worker flow around mkdtemp into the existing try block, starting with
staging = None before it. Ensure allocation failures are caught by the worker’s
exception handling and update the venue’s progress state from running to a
failed/completed state so polling and deletion do not remain blocked.

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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Bound both download and extracted-pack sizes.

Content-Length and pack["bytes"] only update progress; the stream accepts unlimited bytes, and ZIP extraction has no compressed, uncompressed, file-count, or per-file limits. A bad or compromised release asset can fill CONFIG_DIR before verification completes. Enforce maximum download and extraction quotas.

Also applies to: 130-139

🧰 Tools
🪛 Ruff (0.15.20)

[error] 115-115: Audit URL open for permitted schemes. Allowing use of file: or custom schemes is often unexpected.

(S310)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@plugins/career/routes.py` around lines 115 - 124, The download loop around
urllib.request.urlopen must enforce a maximum archive size while reading, rather
than only recording Content-Length or pack["bytes"] in progress. Add extraction
quotas in the ZIP handling flow as well, covering compressed size, total
uncompressed size, file count, and each file’s uncompressed size; reject the
asset before writing beyond any limit or filling CONFIG_DIR, while preserving
existing digest, progress, and verification behavior.

Comment on lines +136 to +143

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

Lock progress mutations as well as reads.

get_state() snapshots progress under _lock, but the worker mutates bytes_total, bytes_done, status, and error without it. Readers can observe inconsistent progress or an error status before its message is written. Update these fields through a lock-protected helper.

Also applies to: 146-150

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@plugins/career/routes.py` around lines 117 - 124, Protect all progress
mutations in the download worker with the existing _lock, including bytes_total,
bytes_done, status, and error updates. Add or reuse a lock-protected helper for
updating progress fields, then use it in the loop around resp.read, digest/write
progress updates, and the status/error paths referenced near lines 146-150 so
get_state() never observes a partial update.

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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | 🏗️ Heavy lift

Make install and removal atomic per venue.

rmtree(final_dir) followed by rename exposes a missing/partial pack and can lose the old pack if the rename fails. Separately, DELETE removes the download state before deleting files, allowing a concurrent POST to install a new pack that the old DELETE then removes. Use a per-venue removing state and an atomic versioned-directory swap.

Also applies to: 215-220

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@plugins/career/routes.py` around lines 143 - 145, Update the venue install
and DELETE flows around final_dir and extract_dir to use a per-venue removing
state, preventing concurrent installs while removal is in progress. Install into
a new versioned directory and atomically swap it into place only after
extraction completes; remove the old version afterward, preserving the previous
pack if the swap fails. Ensure DELETE removes the files before clearing the
download state so a concurrent POST cannot install content that the deletion
then removes.

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"},
)
21 changes: 21 additions & 0 deletions plugins/career/screen.html
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>

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The 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 60%/75%/85%, while venues.json and /state make thresholds data-driven. Any tuning will leave the screen advertising incorrect star rules. Render this copy from state.star_accuracy_thresholds.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@plugins/career/screen.html` at line 6, Update the instructional paragraph
near the career screen markup to derive all star accuracy percentages from
state.star_accuracy_thresholds instead of hard-coding 60%, 75%, and 85%.
Preserve the existing explanatory text while interpolating the configured
thresholds in the correct star-order.

<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>
Loading
Loading