diff --git a/plugins/career/assets/career.css b/plugins/career/assets/career.css
index 83cd1b0..5fac8c2 100644
--- a/plugins/career/assets/career.css
+++ b/plugins/career/assets/career.css
@@ -34,3 +34,33 @@
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; }
diff --git a/plugins/career/routes.py b/plugins/career/routes.py
index 3705194..dfc6834 100644
--- a/plugins/career/routes.py
+++ b/plugins/career/routes.py
@@ -35,7 +35,7 @@ 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|json)$")
+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
@@ -65,23 +65,39 @@ def _installed(venue_id):
def _stars():
- """(total, per-song dict). Accuracy in song_stats is a 0..1 fraction."""
+ """(total, per-song dict, detail rows). Accuracy is a 0..1 fraction."""
db = _state["meta_db"]
if db is None:
- return 0, {}
+ 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 filename, MAX(best_accuracy) FROM song_stats "
- "WHERE filename IN (SELECT filename FROM songs) GROUP BY filename"
+ "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 = {}
- for filename, acc in rows:
- stars = sum(1 for t in thresholds if (acc or 0.0) >= t)
+ 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
- return sum(per_song.values()), per_song
+ 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):
@@ -93,13 +109,16 @@ def _validate_pack_dir(pack_dir: Path):
loops = manifest.get("loops") or {}
for state in REQUIRED_LOOPS:
name = loops.get(state)
- if not name or not PACK_FILENAME_RE.match(name):
+ 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.match(name) or not (pack_dir / name).is_file()):
+ 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):
@@ -133,7 +152,7 @@ def _download_pack(venue_id, pack, progress):
if info.is_dir():
continue
name = Path(info.filename).name
- if name != info.filename or not PACK_FILENAME_RE.match(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)
@@ -164,7 +183,7 @@ def setup(app, context):
@app.get(f"/api/plugins/{PLUGIN_ID}/state")
def get_state():
- stars_total, per_song = _stars()
+ stars_total, per_song, star_detail = _stars()
venues = []
for v in _state["content"]["venues"]:
with _lock:
@@ -182,19 +201,20 @@ def setup(app, context):
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.match(venue_id) else None
+ 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()
+ stars_total, _, _ = _stars()
if stars_total < venue["star_threshold"]:
raise HTTPException(403, "Venue not unlocked yet.")
with _lock:
@@ -210,7 +230,7 @@ def setup(app, context):
@app.delete(f"/api/plugins/{PLUGIN_ID}/packs/{{venue_id}}")
def delete_pack(venue_id: str):
- if not VENUE_ID_RE.match(venue_id) or _venue(venue_id) is None:
+ 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)
@@ -222,7 +242,7 @@ def setup(app, context):
@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.match(venue_id) or not PACK_FILENAME_RE.match(filename):
+ 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):
@@ -234,7 +254,7 @@ def setup(app, context):
raise HTTPException(404, "Not found.")
if not resolved.is_file():
raise HTTPException(404, "Not found.")
- media = {"mp4": "video/mp4", "webm": "video/webm",
+ media = {"mp4": "video/mp4", "webm": "video/webm", "mp3": "audio/mpeg",
"json": "application/json"}[resolved.suffix.lstrip(".").lower()]
return FileResponse(
resolved,
diff --git a/plugins/career/screen.html b/plugins/career/screen.html
index 9305a8c..38c58ca 100644
--- a/plugins/career/screen.html
+++ b/plugins/career/screen.html
@@ -11,4 +11,11 @@
+
+
+
Your star collection
+
+
+
+
diff --git a/plugins/career/screen.js b/plugins/career/screen.js
index 44a67e7..2cc523d 100644
--- a/plugins/career/screen.js
+++ b/plugins/career/screen.js
@@ -13,6 +13,8 @@
const API = '/api/plugins/career';
const VENUE_OVERRIDE_KEY = 'feedBack-career-venue';
+ const NO_VENUE = '__none__';
+ const PREV_VIZ_KEY = 'feedBack-career-prev-viz';
const POLL_MS = 2000;
let _state = null;
@@ -46,11 +48,12 @@
const gen = ++_manifestReqGen;
const unlocked = state.venues.filter((v) => v.unlocked);
let venue = null;
- try {
- const override = localStorage.getItem(VENUE_OVERRIDE_KEY);
+ let override = null;
+ try { override = localStorage.getItem(VENUE_OVERRIDE_KEY); } catch (_) { /* ok */ }
+ if (override !== NO_VENUE) {
venue = unlocked.find((v) => v.id === override && v.installed) || null;
- } catch (_) { /* ok */ }
- if (!venue) venue = lastOf(unlocked.filter((v) => v.installed));
+ if (!venue) venue = lastOf(unlocked.filter((v) => v.installed));
+ }
if (!venue) {
if (_appliedManifestVenue !== null) {
_appliedManifestVenue = null;
@@ -82,8 +85,12 @@
action = `
Downloading… ${pct}%
`;
} else if (v.installed) {
+ const active = localStorage.getItem(VENUE_OVERRIDE_KEY) === v.id;
+ const main = active
+ ? ``
+ : ``;
action = `
-
+ ${main}
`;
} else if (v.has_pack) {
@@ -108,6 +115,43 @@
`;
}
+ function starGlyphs(n) {
+ let out = '';
+ for (let i = 0; i < 3; i++) {
+ out += `★`;
+ }
+ return out;
+ }
+
+ function renderStars(state) {
+ const list = $('career-star-list');
+ const summary = $('career-star-summary');
+ if (!list || !summary) return;
+ const detail = state.star_detail || [];
+ const tiers = [0, 0, 0, 0];
+ for (const r of detail) tiers[r.stars]++;
+ summary.textContent =
+ `${tiers[3]}× 3★ · ${tiers[2]}× 2★ · ${tiers[1]}× 1★ · ${tiers[0]} unstarred`;
+ if (!detail.length) {
+ list.innerHTML = 'Play songs to start collecting stars — 60% accuracy earns the first one.
';
+ return;
+ }
+ list.innerHTML = detail.map((r) => {
+ let hint = 'maxed';
+ let close = '';
+ if (r.next_star_at != null) {
+ const gap = Math.max(0, r.next_star_at - r.best_accuracy) * 100;
+ hint = `${gap.toFixed(0)}% to next ★`;
+ if (gap <= 5) close = ' close';
+ }
+ return `
+ ${starGlyphs(r.stars)}
+ ${esc(r.title)}${r.artist ? ` — ${esc(r.artist)}` : ''}
+ best ${(r.best_accuracy * 100).toFixed(0)}% · ${hint}
+
`;
+ }).join('');
+ }
+
function render(state) {
const host = $('career-venues');
if (!host) return;
@@ -128,6 +172,7 @@
label.textContent = 'All venues unlocked — enjoy the arena.';
}
host.innerHTML = state.venues.map((v) => venueCardHTML(v, state)).join('');
+ renderStars(state);
}
function schedulePoll(state) {
@@ -187,9 +232,29 @@
fetch(`${API}/packs/${delBtn.dataset.careerDelete}`, { method: 'DELETE' })
.then(refresh);
} else if (playBtn) {
- try { localStorage.setItem(VENUE_OVERRIDE_KEY, playBtn.dataset.careerPlay); } catch (_) { /* ok */ }
+ try {
+ localStorage.setItem(VENUE_OVERRIDE_KEY, playBtn.dataset.careerPlay);
+ // Selecting a venue makes the Venue visualization the default;
+ // remember what the user had so Leave venue can restore it.
+ const cur = localStorage.getItem('vizSelection');
+ if (cur && cur !== 'venue') localStorage.setItem(PREV_VIZ_KEY, cur);
+ localStorage.setItem('vizSelection', 'venue');
+ if (typeof window.setViz === 'function') window.setViz('venue');
+ } catch (_) { /* ok */ }
_appliedManifestVenue = null; // force manifest re-push
refresh();
+ } else if (e.target.closest('[data-career-unselect]')) {
+ try {
+ localStorage.setItem(VENUE_OVERRIDE_KEY, NO_VENUE);
+ const prev = localStorage.getItem(PREV_VIZ_KEY);
+ if (prev) {
+ localStorage.setItem('vizSelection', prev);
+ if (typeof window.setViz === 'function') window.setViz(prev);
+ }
+ } catch (_) { /* ok */ }
+ // keep _appliedManifestVenue: pushCrowdManifest clears the crowd
+ // manifest precisely by seeing it is still set with no venue left
+ refresh();
}
}
diff --git a/plugins/career/venues.json b/plugins/career/venues.json
index d4684ba..c749a65 100644
--- a/plugins/career/venues.json
+++ b/plugins/career/venues.json
@@ -1,5 +1,9 @@
{
- "star_accuracy_thresholds": [0.6, 0.75, 0.85],
+ "star_accuracy_thresholds": [
+ 0.6,
+ 0.75,
+ 0.85
+ ],
"venues": [
{
"id": "bar",
@@ -12,14 +16,14 @@
"id": "club",
"name": "Velvet Room",
"description": "A proper club stage. People actually came to hear you.",
- "star_threshold": 15,
+ "star_threshold": 50,
"pack": null
},
{
"id": "arena",
"name": "Feedback Arena",
"description": "Ten thousand seats. Try not to think about it.",
- "star_threshold": 40,
+ "star_threshold": 150,
"pack": null
}
]
diff --git a/tests/plugins/career/conftest.py b/tests/plugins/career/conftest.py
index f738dd3..eb6ad63 100644
--- a/tests/plugins/career/conftest.py
+++ b/tests/plugins/career/conftest.py
@@ -23,15 +23,16 @@ class FakeMetaDb:
filename TEXT, arrangement TEXT, best_accuracy REAL
)"""
)
- self.conn.execute("CREATE TABLE songs (filename TEXT)")
+ self.conn.execute("CREATE TABLE songs (filename TEXT, title TEXT, artist TEXT)")
def add(self, filename, arrangement, best_accuracy, in_library=True):
self.conn.execute("INSERT INTO song_stats VALUES (?, ?, ?)",
(filename, arrangement, best_accuracy))
if in_library:
self.conn.execute(
- "INSERT INTO songs SELECT ? WHERE NOT EXISTS "
- "(SELECT 1 FROM songs WHERE filename = ?)", (filename, filename))
+ "INSERT INTO songs SELECT ?, ?, ? WHERE NOT EXISTS "
+ "(SELECT 1 FROM songs WHERE filename = ?)",
+ (filename, filename.replace(".feedpak", "").title(), "Test Artist", filename))
self.conn.commit()
diff --git a/tests/plugins/career/test_routes.py b/tests/plugins/career/test_routes.py
index 3ec59bb..581d553 100644
--- a/tests/plugins/career/test_routes.py
+++ b/tests/plugins/career/test_routes.py
@@ -32,7 +32,7 @@ def test_stars_from_best_accuracy_across_arrangements(client, meta_db):
def test_unlock_flags_follow_thresholds(client, meta_db):
- # 6 stars: bar (0) unlocked, club (15) and arena (40) locked.
+ # 6 stars: bar (0) unlocked, club (50) and arena (150) locked.
for i in range(2):
meta_db.add(f"s{i}.feedpak", "guitar", 0.9) # 3 stars each
state = client.get("/api/plugins/career/state").json()
@@ -52,6 +52,18 @@ def test_orphaned_stats_do_not_count(client, meta_db):
assert "gone.feedpak" not in state["stars_per_song"]
+def test_star_detail_rows_sorted_by_next_star_gap(client, meta_db):
+ meta_db.add("far.feedpak", "guitar", 0.61) # 1★, 14% from next
+ meta_db.add("close.feedpak", "guitar", 0.84) # 2★, 1% from next
+ meta_db.add("maxed.feedpak", "guitar", 0.99) # 3★, maxed
+ detail = client.get("/api/plugins/career/state").json()["star_detail"]
+ assert [r["filename"] for r in detail] == \
+ ["close.feedpak", "far.feedpak", "maxed.feedpak"]
+ close = detail[0]
+ assert close["stars"] == 2 and close["next_star_at"] == 0.85
+ assert detail[2]["next_star_at"] is None
+
+
def test_no_stats_still_serves_state(client):
state = client.get("/api/plugins/career/state").json()
assert state["stars_total"] == 0