"""Lobby sounds: the DM can replace the dice sound and add sounds for critical successes / failures (MP3, per lobby).""" import json from pathlib import Path from fastapi import Body, Depends, HTTPException, Request from fastapi.responses import Response MAX_SOUND = 1_500_000 # bytes per file SLOTS = ("roll", "crit_success", "crit_failure") FLAGS = ("result", "crit_success", "crit_failure") # result = the short tones for success / failure of a check def schema(con): if "sounds" not in [r["name"] for r in con.execute("PRAGMA table_info(lobbies)")]: con.execute("ALTER TABLE lobbies ADD COLUMN sounds TEXT NOT NULL DEFAULT '{}'") def settings(raw) -> dict: try: s = json.loads(raw or "{}") except ValueError: s = {} return {"result": s.get("result", True), "crit_success": s.get("crit_success", True), "crit_failure": s.get("crit_failure", True), "custom": {k: bool(s.get("custom", {}).get(k)) for k in SLOTS}, "v": int(s.get("v", 0))} def install(api, db, current_user, lobby_for, dm_only, hub, store_dir: Path): store_dir.mkdir(parents=True, exist_ok=True) def path(lid: str, slot: str) -> Path: if slot not in SLOTS: raise HTTPException(404, "Unknown sound") return store_dir / f"{lid}_{slot}.mp3" def save(con, lid, s): s["v"] += 1 con.execute("UPDATE lobbies SET sounds=? WHERE id=?", (json.dumps(s), lid)) @api.put("/lobbies/{lid}/sounds") def set_flags(lid: str, body: dict = Body(...), user: dict = Depends(current_user)): with db() as con: lobby = lobby_for(con, lid, user) dm_only(lobby, user) s = settings(lobby["sounds"]) for k in FLAGS: if k in body: s[k] = bool(body[k]) save(con, lid, s) hub.notify(lid, "lobby") return {"ok": True} @api.post("/lobbies/{lid}/sounds/{slot}") async def upload_sound(lid: str, slot: str, request: Request, user: dict = Depends(current_user)): """Raw MP3 bytes in the body.""" p = path(lid, slot) with db() as con: dm_only(lobby_for(con, lid, user), user) if int(request.headers.get("content-length") or 0) > MAX_SOUND: raise HTTPException(413, f"The file is too large (at most {MAX_SOUND // 1_000_000} MB)") chunks, size = [], 0 async for c in request.stream(): size += len(c) if size > MAX_SOUND: raise HTTPException(413, f"The file is too large (at most {MAX_SOUND // 1_000_000} MB)") chunks.append(c) data = b"".join(chunks) if not (data[:3] == b"ID3" or (len(data) > 4 and data[0] == 0xFF and data[1] & 0xE0 == 0xE0)): raise HTTPException(422, "Please upload an MP3 file") p.write_bytes(data) with db() as con: lobby = lobby_for(con, lid, user) s = settings(lobby["sounds"]) s["custom"][slot] = True save(con, lid, s) hub.notify(lid, "lobby") return {"ok": True} @api.delete("/lobbies/{lid}/sounds/{slot}") def reset_sound(lid: str, slot: str, user: dict = Depends(current_user)): p = path(lid, slot) with db() as con: lobby = lobby_for(con, lid, user) dm_only(lobby, user) s = settings(lobby["sounds"]) s["custom"][slot] = False save(con, lid, s) p.unlink(missing_ok=True) hub.notify(lid, "lobby") return {"ok": True} @api.get("/lobbies/{lid}/sounds/{slot}") def get_sound(lid: str, slot: str, v: int = 0, user: dict = Depends(current_user)): p = path(lid, slot) with db() as con: lobby_for(con, lid, user) if not p.exists(): raise HTTPException(404, "No custom sound") return Response(p.read_bytes(), media_type="audio/mpeg", headers={"Cache-Control": "private, max-age=86400"}) def purge(lid: str): for slot in SLOTS: path(lid, slot).unlink(missing_ok=True) return purge