mirror of
https://github.com/got-feedBack/feedBack.git
synced 2026-08-14 04:31:21 +00:00
feat(career): bundle dive bar venue pack (#927)
This commit is contained in:
+35
-11
@@ -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
|
``venues.json`` (data-driven so tuning never touches code). Cumulative
|
||||||
stars unlock venue tiers (bar → club → arena).
|
stars unlock venue tiers (bar → club → arena).
|
||||||
|
|
||||||
Venue packs (crowd-loop videos rendered offline in UE) are heavyweight and
|
Venue packs (crowd-loop videos rendered offline in UE) may be bundled with
|
||||||
never ship with the app: ``venues.json`` points at a release asset per
|
the plugin under ``venue-packs/<id>/`` or downloaded on demand into
|
||||||
venue, downloaded on demand into ``CONFIG_DIR/plugin_uploads/career/venues/
|
``CONFIG_DIR/plugin_uploads/career/venues/<id>/``. Downloaded packs override
|
||||||
<id>/`` on a background thread (constitution: nothing heavy inline on the
|
bundled packs so release assets can replace a built-in starter venue.
|
||||||
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/):
|
Endpoints (all under /api/plugins/career/):
|
||||||
GET /state stars + per-venue unlock/install/download status
|
GET /state stars + per-venue unlock/install/download status
|
||||||
@@ -42,6 +40,7 @@ DOWNLOAD_CHUNK = 1024 * 256
|
|||||||
_lock = threading.Lock()
|
_lock = threading.Lock()
|
||||||
_state = {
|
_state = {
|
||||||
"content": None, # parsed venues.json
|
"content": None, # parsed venues.json
|
||||||
|
"plugin_dir": None, # plugin root; bundled packs live below it
|
||||||
"venues_dir": None, # CONFIG_DIR/plugin_uploads/career/venues
|
"venues_dir": None, # CONFIG_DIR/plugin_uploads/career/venues
|
||||||
"meta_db": None, # MetadataDB (song_stats reads are lock-free / WAL)
|
"meta_db": None, # MetadataDB (song_stats reads are lock-free / WAL)
|
||||||
"log": logging.getLogger("feedBack.plugin.career"),
|
"log": logging.getLogger("feedBack.plugin.career"),
|
||||||
@@ -60,8 +59,27 @@ def _venue_dir(venue_id) -> Path:
|
|||||||
return _state["venues_dir"] / venue_id
|
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):
|
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():
|
def _stars():
|
||||||
@@ -174,12 +192,16 @@ def _download_pack(venue_id, pack, progress):
|
|||||||
|
|
||||||
def setup(app, context):
|
def setup(app, context):
|
||||||
plugin_dir = Path(__file__).resolve().parent
|
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["content"] = json.loads((plugin_dir / "venues.json").read_text(encoding="utf-8"))
|
||||||
_state["venues_dir"] = (
|
_state["venues_dir"] = (
|
||||||
Path(context["config_dir"]) / "plugin_uploads" / PLUGIN_ID / "venues")
|
Path(context["config_dir"]) / "plugin_uploads" / PLUGIN_ID / "venues")
|
||||||
_state["venues_dir"].mkdir(parents=True, exist_ok=True)
|
_state["venues_dir"].mkdir(parents=True, exist_ok=True)
|
||||||
_state["meta_db"] = context.get("meta_db")
|
_state["meta_db"] = context.get("meta_db")
|
||||||
_state["log"] = context.get("log") or _state["log"]
|
_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")
|
@app.get(f"/api/plugins/{PLUGIN_ID}/state")
|
||||||
def get_state():
|
def get_state():
|
||||||
@@ -195,7 +217,8 @@ def setup(app, context):
|
|||||||
"star_threshold": v["star_threshold"],
|
"star_threshold": v["star_threshold"],
|
||||||
"unlocked": stars_total >= v["star_threshold"],
|
"unlocked": stars_total >= v["star_threshold"],
|
||||||
"installed": _installed(v["id"]),
|
"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,
|
"download": dl,
|
||||||
})
|
})
|
||||||
return {
|
return {
|
||||||
@@ -244,12 +267,13 @@ def setup(app, context):
|
|||||||
async def get_pack_file(venue_id: str, filename: str):
|
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):
|
if not VENUE_ID_RE.fullmatch(venue_id) or not PACK_FILENAME_RE.fullmatch(filename):
|
||||||
raise HTTPException(404, "Not found.")
|
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):
|
# 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:
|
try:
|
||||||
resolved = path.resolve()
|
resolved = path.resolve()
|
||||||
resolved.relative_to(_state["venues_dir"].resolve())
|
resolved.relative_to(pack_dir.resolve())
|
||||||
except (OSError, ValueError):
|
except (OSError, ValueError):
|
||||||
raise HTTPException(404, "Not found.")
|
raise HTTPException(404, "Not found.")
|
||||||
if not resolved.is_file():
|
if not resolved.is_file():
|
||||||
|
|||||||
@@ -89,9 +89,12 @@
|
|||||||
const main = active
|
const main = active
|
||||||
? `<button data-career-unselect="1" class="career-btn career-btn-ghost">Leave venue</button>`
|
? `<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>`;
|
: `<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">
|
action = `<div class="flex items-center gap-2">
|
||||||
${main}
|
${main}
|
||||||
<button data-career-delete="${esc(v.id)}" class="career-btn career-btn-ghost">Remove pack</button>
|
${remove}
|
||||||
</div>`;
|
</div>`;
|
||||||
} else if (v.has_pack) {
|
} else if (v.has_pack) {
|
||||||
const err = dl.status === 'error'
|
const err = dl.status === 'error'
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
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', () => {
|
test('shell promotes the career plugin into the sidebar', () => {
|
||||||
const src = fs.readFileSync(SHELL_JS, 'utf8');
|
const src = fs.readFileSync(SHELL_JS, 'utf8');
|
||||||
assert.match(src, /key: 'career',\s*screen: 'plugin-career'/);
|
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):
|
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
|
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
|
assert client.post("/api/plugins/career/packs/club/download").status_code == 403
|
||||||
|
|
||||||
|
|
||||||
def test_pack_file_serving_and_traversal_guard(client):
|
def test_bundled_bar_pack_is_installed_and_served(client):
|
||||||
_install_fake_pack("bar")
|
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")
|
ok = client.get("/api/plugins/career/venues/bar/manifest.json")
|
||||||
assert ok.status_code == 200
|
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"
|
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.status_code == 200
|
||||||
assert video.headers["content-type"].startswith("video/mp4")
|
assert video.headers["content-type"].startswith("video/mp4")
|
||||||
assert video.headers["x-content-type-options"] == "nosniff"
|
assert video.headers["x-content-type-options"] == "nosniff"
|
||||||
# Traversal / junk shapes never resolve.
|
# Traversal / junk shapes never resolve.
|
||||||
for bad in ("../manifest.json", "..%2Fmanifest.json", "x.sh", "MANIFEST.JSON"):
|
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(f"/api/plugins/career/venues/club/{bad}").status_code == 404
|
||||||
assert client.get("/api/plugins/career/venues/../bar/manifest.json").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):
|
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()
|
state = client.get("/api/plugins/career/state").json()
|
||||||
assert {v["id"]: v["installed"] for v in state["venues"]}["bar"] is True
|
assert {v["id"]: v["installed"] for v in state["venues"]}["club"] is True
|
||||||
assert client.delete("/api/plugins/career/packs/bar").status_code == 200
|
assert client.delete("/api/plugins/career/packs/club").status_code == 200
|
||||||
state = client.get("/api/plugins/career/state").json()
|
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):
|
def test_download_worker_end_to_end(client, tmp_path):
|
||||||
|
|||||||
Reference in New Issue
Block a user