mirror of
https://github.com/got-feedBack/feedBack.git
synced 2026-07-22 12:52:29 +00:00
feat(career): bundle dive bar venue pack
This commit is contained in:
parent
ea0ca94742
commit
68e83597fe
@ -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/
|
||||
<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.
|
||||
Venue packs (crowd-loop videos rendered offline in UE) may be bundled with
|
||||
the plugin under ``venue-packs/<id>/`` or downloaded on demand into
|
||||
``CONFIG_DIR/plugin_uploads/career/venues/<id>/``. 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():
|
||||
|
||||
@ -89,9 +89,12 @@
|
||||
const main = active
|
||||
? `<button data-career-unselect="1" class="career-btn career-btn-ghost">Leave venue</button>`
|
||||
: `<button data-career-play="${esc(v.id)}" class="career-btn career-btn-primary">Play here</button>`;
|
||||
const remove = v.bundled
|
||||
? ''
|
||||
: `<button data-career-delete="${esc(v.id)}" class="career-btn career-btn-ghost">Remove pack</button>`;
|
||||
action = `<div class="flex items-center gap-2">
|
||||
${main}
|
||||
<button data-career-delete="${esc(v.id)}" class="career-btn career-btn-ghost">Remove pack</button>
|
||||
${remove}
|
||||
</div>`;
|
||||
} else if (v.has_pack) {
|
||||
const err = dl.status === 'error'
|
||||
|
||||
BIN
plugins/career/venue-packs/bar/bar-ambience.mp3
Normal file
BIN
plugins/career/venue-packs/bar/bar-ambience.mp3
Normal file
Binary file not shown.
BIN
plugins/career/venue-packs/bar/bored.mp4
Normal file
BIN
plugins/career/venue-packs/bar/bored.mp4
Normal file
Binary file not shown.
BIN
plugins/career/venue-packs/bar/cheer.mp4
Normal file
BIN
plugins/career/venue-packs/bar/cheer.mp4
Normal file
Binary file not shown.
BIN
plugins/career/venue-packs/bar/clap.mp4
Normal file
BIN
plugins/career/venue-packs/bar/clap.mp4
Normal file
Binary file not shown.
BIN
plugins/career/venue-packs/bar/ecstatic.mp4
Normal file
BIN
plugins/career/venue-packs/bar/ecstatic.mp4
Normal file
Binary file not shown.
BIN
plugins/career/venue-packs/bar/engaged.mp4
Normal file
BIN
plugins/career/venue-packs/bar/engaged.mp4
Normal file
Binary file not shown.
BIN
plugins/career/venue-packs/bar/intro.mp4
Normal file
BIN
plugins/career/venue-packs/bar/intro.mp4
Normal file
Binary file not shown.
18
plugins/career/venue-packs/bar/manifest.json
Normal file
18
plugins/career/venue-packs/bar/manifest.json
Normal file
@ -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"
|
||||
}
|
||||
}
|
||||
BIN
plugins/career/venue-packs/bar/neutral.mp4
Normal file
BIN
plugins/career/venue-packs/bar/neutral.mp4
Normal file
Binary file not shown.
@ -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'/);
|
||||
|
||||
@ -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):
|
||||
|
||||
Loading…
Reference in New Issue
Block a user