diff --git a/plugins/career/routes.py b/plugins/career/routes.py index dfc6834..ef0fb20 100644 --- a/plugins/career/routes.py +++ b/plugins/career/routes.py @@ -5,12 +5,10 @@ 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/ -/`` 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. +Venue packs (crowd-loop videos rendered offline in UE) may be bundled with +the plugin under ``venue-packs//`` or downloaded on demand into +``CONFIG_DIR/plugin_uploads/career/venues//``. Downloaded packs override +bundled packs so release assets can replace a built-in starter venue. Endpoints (all under /api/plugins/career/): GET /state stars + per-venue unlock/install/download status @@ -42,6 +40,7 @@ DOWNLOAD_CHUNK = 1024 * 256 _lock = threading.Lock() _state = { "content": None, # parsed venues.json + "plugin_dir": None, # plugin root; bundled packs live below it "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"), @@ -60,8 +59,27 @@ def _venue_dir(venue_id) -> Path: return _state["venues_dir"] / venue_id +def _bundled_venue_dir(venue_id) -> Path: + return _state["plugin_dir"] / "venue-packs" / venue_id + + +def _pack_dir(venue_id): + """Runtime pack location: downloaded override first, bundled fallback.""" + local = _venue_dir(venue_id) + if (local / "manifest.json").is_file(): + return local + bundled = _bundled_venue_dir(venue_id) + if (bundled / "manifest.json").is_file(): + return bundled + return local + + def _installed(venue_id): - return (_venue_dir(venue_id) / "manifest.json").is_file() + return (_pack_dir(venue_id) / "manifest.json").is_file() + + +def _bundled(venue_id): + return (_bundled_venue_dir(venue_id) / "manifest.json").is_file() def _stars(): @@ -174,12 +192,16 @@ def _download_pack(venue_id, pack, progress): def setup(app, context): plugin_dir = Path(__file__).resolve().parent + _state["plugin_dir"] = plugin_dir _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"] + for v in _state["content"]["venues"]: + if _bundled(v["id"]): + _validate_pack_dir(_bundled_venue_dir(v["id"])) @app.get(f"/api/plugins/{PLUGIN_ID}/state") def get_state(): @@ -195,7 +217,8 @@ def setup(app, context): "star_threshold": v["star_threshold"], "unlocked": stars_total >= v["star_threshold"], "installed": _installed(v["id"]), - "has_pack": bool(v.get("pack")), + "bundled": _bundled(v["id"]), + "has_pack": _bundled(v["id"]) or bool(v.get("pack")), "download": dl, }) return { @@ -244,12 +267,13 @@ def setup(app, context): 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 + pack_dir = _pack_dir(venue_id) + path = pack_dir / filename # Defense-in-depth beyond the regexes (same recipe as highway_3d): - # the resolved path must stay inside the venues dir. + # the resolved path must stay inside the selected pack dir. try: resolved = path.resolve() - resolved.relative_to(_state["venues_dir"].resolve()) + resolved.relative_to(pack_dir.resolve()) except (OSError, ValueError): raise HTTPException(404, "Not found.") if not resolved.is_file(): diff --git a/plugins/career/screen.js b/plugins/career/screen.js index 2cc523d..1d081cb 100644 --- a/plugins/career/screen.js +++ b/plugins/career/screen.js @@ -89,9 +89,12 @@ const main = active ? `` : ``; + const remove = v.bundled + ? '' + : ``; action = `
${main} - + ${remove}
`; } else if (v.has_pack) { const err = dl.status === 'error' diff --git a/plugins/career/venue-packs/bar/bar-ambience.mp3 b/plugins/career/venue-packs/bar/bar-ambience.mp3 new file mode 100644 index 0000000..56c8a2f Binary files /dev/null and b/plugins/career/venue-packs/bar/bar-ambience.mp3 differ diff --git a/plugins/career/venue-packs/bar/bored.mp4 b/plugins/career/venue-packs/bar/bored.mp4 new file mode 100644 index 0000000..09e850f Binary files /dev/null and b/plugins/career/venue-packs/bar/bored.mp4 differ diff --git a/plugins/career/venue-packs/bar/cheer.mp4 b/plugins/career/venue-packs/bar/cheer.mp4 new file mode 100644 index 0000000..d59f3c4 Binary files /dev/null and b/plugins/career/venue-packs/bar/cheer.mp4 differ diff --git a/plugins/career/venue-packs/bar/clap.mp4 b/plugins/career/venue-packs/bar/clap.mp4 new file mode 100644 index 0000000..89959ab Binary files /dev/null and b/plugins/career/venue-packs/bar/clap.mp4 differ diff --git a/plugins/career/venue-packs/bar/ecstatic.mp4 b/plugins/career/venue-packs/bar/ecstatic.mp4 new file mode 100644 index 0000000..039f7ab Binary files /dev/null and b/plugins/career/venue-packs/bar/ecstatic.mp4 differ diff --git a/plugins/career/venue-packs/bar/engaged.mp4 b/plugins/career/venue-packs/bar/engaged.mp4 new file mode 100644 index 0000000..f0a4cbd Binary files /dev/null and b/plugins/career/venue-packs/bar/engaged.mp4 differ diff --git a/plugins/career/venue-packs/bar/intro.mp4 b/plugins/career/venue-packs/bar/intro.mp4 new file mode 100644 index 0000000..b7277e3 Binary files /dev/null and b/plugins/career/venue-packs/bar/intro.mp4 differ diff --git a/plugins/career/venue-packs/bar/manifest.json b/plugins/career/venue-packs/bar/manifest.json new file mode 100644 index 0000000..913f3f9 --- /dev/null +++ b/plugins/career/venue-packs/bar/manifest.json @@ -0,0 +1,18 @@ +{ + "venue": "bar", + "version": 1, + "loops": { + "bored": "bored.mp4", + "neutral": "neutral.mp4", + "engaged": "engaged.mp4", + "ecstatic": "ecstatic.mp4" + }, + "stingers": { + "clap": "clap.mp4", + "cheer": "cheer.mp4" + }, + "intro": { + "video": "intro.mp4", + "audio": "bar-ambience.mp3" + } +} diff --git a/plugins/career/venue-packs/bar/neutral.mp4 b/plugins/career/venue-packs/bar/neutral.mp4 new file mode 100644 index 0000000..b141f0e Binary files /dev/null and b/plugins/career/venue-packs/bar/neutral.mp4 differ diff --git a/tests/js/career_plugin.test.js b/tests/js/career_plugin.test.js index 98da433..30a1dc0 100644 --- a/tests/js/career_plugin.test.js +++ b/tests/js/career_plugin.test.js @@ -33,6 +33,24 @@ test('venues.json defines the 3 ascending tiers with star thresholds', () => { } }); +test('bar venue pack ships with intro media in the plugin checkout', () => { + const packDir = path.join(PLUGIN_DIR, 'venue-packs', 'bar'); + const manifest = JSON.parse(fs.readFileSync(path.join(packDir, 'manifest.json'), 'utf8')); + assert.deepEqual(Object.keys(manifest.loops).sort(), + ['bored', 'ecstatic', 'engaged', 'neutral']); + assert.equal(manifest.intro.video, 'intro.mp4'); + assert.equal(manifest.intro.audio, 'bar-ambience.mp3'); + for (const f of [ + ...Object.values(manifest.loops), + ...Object.values(manifest.stingers), + manifest.intro.video, + manifest.intro.audio, + ]) { + const stat = fs.statSync(path.join(packDir, f)); + assert.ok(stat.size > 0, `${f} must be present`); + } +}); + test('shell promotes the career plugin into the sidebar', () => { const src = fs.readFileSync(SHELL_JS, 'utf8'); assert.match(src, /key: 'career',\s*screen: 'plugin-career'/); diff --git a/tests/plugins/career/test_routes.py b/tests/plugins/career/test_routes.py index 581d553..e104740 100644 --- a/tests/plugins/career/test_routes.py +++ b/tests/plugins/career/test_routes.py @@ -76,7 +76,8 @@ def test_download_unknown_venue_404s(client): def test_download_without_published_pack_404s(client): - # venues.json ships pack: null until packs are released. + # Bundled packs are already installed; download still requires a published + # remote pack entry. assert client.post("/api/plugins/career/packs/bar/download").status_code == 404 @@ -86,28 +87,46 @@ def test_download_locked_venue_403s(client, monkeypatch): assert client.post("/api/plugins/career/packs/club/download").status_code == 403 -def test_pack_file_serving_and_traversal_guard(client): - _install_fake_pack("bar") +def test_bundled_bar_pack_is_installed_and_served(client): + state = client.get("/api/plugins/career/state").json() + bar = {v["id"]: v for v in state["venues"]}["bar"] + assert bar["installed"] is True + assert bar["bundled"] is True + assert bar["has_pack"] is True + ok = client.get("/api/plugins/career/venues/bar/manifest.json") assert ok.status_code == 200 + manifest = ok.json() + assert manifest["loops"]["ecstatic"] == "ecstatic.mp4" + assert manifest["intro"] == {"video": "intro.mp4", "audio": "bar-ambience.mp3"} + assert client.get("/api/plugins/career/venues/bar/intro.mp4").status_code == 200 + audio = client.get("/api/plugins/career/venues/bar/bar-ambience.mp3") + assert audio.status_code == 200 + assert audio.headers["content-type"].startswith("audio/mpeg") + + +def test_pack_file_serving_and_traversal_guard(client): + _install_fake_pack("club") + ok = client.get("/api/plugins/career/venues/club/manifest.json") + assert ok.status_code == 200 assert ok.json()["loops"]["ecstatic"] == "ecstatic.mp4" - video = client.get("/api/plugins/career/venues/bar/bored.mp4") + video = client.get("/api/plugins/career/venues/club/bored.mp4") assert video.status_code == 200 assert video.headers["content-type"].startswith("video/mp4") assert video.headers["x-content-type-options"] == "nosniff" # Traversal / junk shapes never resolve. for bad in ("../manifest.json", "..%2Fmanifest.json", "x.sh", "MANIFEST.JSON"): - assert client.get(f"/api/plugins/career/venues/bar/{bad}").status_code == 404 - assert client.get("/api/plugins/career/venues/../bar/manifest.json").status_code == 404 + assert client.get(f"/api/plugins/career/venues/club/{bad}").status_code == 404 + assert client.get("/api/plugins/career/venues/../club/manifest.json").status_code == 404 def test_state_reports_installed_and_delete_removes(client): - _install_fake_pack("bar") + _install_fake_pack("club") state = client.get("/api/plugins/career/state").json() - assert {v["id"]: v["installed"] for v in state["venues"]}["bar"] is True - assert client.delete("/api/plugins/career/packs/bar").status_code == 200 + assert {v["id"]: v["installed"] for v in state["venues"]}["club"] is True + assert client.delete("/api/plugins/career/packs/club").status_code == 200 state = client.get("/api/plugins/career/state").json() - assert {v["id"]: v["installed"] for v in state["venues"]}["bar"] is False + assert {v["id"]: v["installed"] for v in state["venues"]}["club"] is False def test_download_worker_end_to_end(client, tmp_path):