From 4cc8fa3b4d99a806acd2becb177e7dadd43ed971 Mon Sep 17 00:00:00 2001 From: Byron Gamatos Date: Sat, 11 Jul 2026 00:38:03 +0200 Subject: [PATCH] refactor(server): extract the profile routes into routers/profile.py (R3) (#854) 6 routes (get/set profile, bundled+custom avatars, avatar upload/serve, progress) + the exclusive _list_bundled_avatars helper. Bodies verbatim; @app -> @router, meta_db -> appstate.meta_db, CONFIG_DIR/STATIC_DIR -> appstate.config_dir/ static_dir (seam), _clean_str from reqfields, _get_progression_content() -> appstate.get_progression_content(). No STATIC_DIR test retarget: _list_bundled_avatars reads appstate.static_dir but test_profile_api doesn't patch STATIC (only the sloppak/audio/traversal suites do, for handlers that stay in server.py). server.py: 7,594 -> 7,478. Verified: pyflakes clean; route table IDENTICAL (143); pytest 2401 passed (61 in test_profile_api); eslint 0. Boot smoke: GET /api/profile 200 (drives get_progression_content), /avatars lists via appstate.static_dir, /progress 200. Co-authored-by: Claude Opus 4.8 (1M context) --- docs/size-exemptions.md | 4 +- lib/routers/profile.py | 138 ++++++++++++++++++++++++++++++++++++++++ server.py | 124 ++---------------------------------- 3 files changed, 144 insertions(+), 122 deletions(-) create mode 100644 lib/routers/profile.py diff --git a/docs/size-exemptions.md b/docs/size-exemptions.md index 28258db..a2fa7d4 100644 --- a/docs/size-exemptions.md +++ b/docs/size-exemptions.md @@ -55,8 +55,8 @@ without a *signed* exemption" is unenforceable. ## Planned, NOT exempt (owned by split plans — listed so nothing falls between states) core `static/app.js` (11,852) · `static/highway.js` (4,168, whole file) · `server.py` -(7,594 — was 14,037; ratcheted by the R3 `MetadataDB` + `AudioEffectsMappingDB` -extractions and ten `routers/` modules) · +(7,478 — was 14,037; ratcheted by the R3 `MetadataDB` + `AudioEffectsMappingDB` +extractions and eleven `routers/` modules) · `lib/metadata_db.py` (4,373 — new in R3; the `MetadataDB` class alone is 4,018 lines and is a monolith in its own right, to be split per-table once the router train lands) · `static/v3/songs.js` (4,134) · `static/capabilities/audio-session.js` diff --git a/lib/routers/profile.py b/lib/routers/profile.py new file mode 100644 index 0000000..51c8a8b --- /dev/null +++ b/lib/routers/profile.py @@ -0,0 +1,138 @@ +"""Player profile — identity, avatars (bundled + custom uploads), and progress. + +Extracted verbatim from ``server.py`` (R3); edits: ``@app`` -> ``@router``, +``meta_db`` -> ``appstate.meta_db``, ``CONFIG_DIR``/``STATIC_DIR`` -> +``appstate.config_dir``/``appstate.static_dir`` (seam), ``_clean_str`` from +``reqfields``, ``_get_progression_content()`` -> +``appstate.get_progression_content()``. The bundled-avatar lister moves with it. +""" + +import logging +import secrets + +from fastapi import APIRouter +from fastapi.responses import FileResponse, JSONResponse + +import appstate +from reqfields import _clean_str + +log = logging.getLogger("feedBack.server") + +router = APIRouter() + + +def _list_bundled_avatars() -> list[str]: + """Bundled default avatar filenames under static/v3/avatars/.""" + d = appstate.static_dir / "v3" / "avatars" + if not d.is_dir(): + return [] + exts = {".svg", ".png", ".webp"} + return sorted( + p.name for p in d.iterdir() + if p.is_file() and p.suffix.lower() in exts and not p.name.startswith(".") + ) + + +@router.get("/api/profile") +def api_get_profile(): + profile = appstate.meta_db.get_profile() + # Equipped cosmetics ride along (resolved to their payloads) so the theme + # and avatar frame apply at boot without an extra request. Never let a + # cosmetics/content problem break the profile read. + cosmetics = {} + try: + shop = appstate.get_progression_content()["shop"] + for slot, item_id in appstate.meta_db.get_equipped().items(): + item = shop.get(item_id) + if item: + cosmetics[slot] = {"item_id": item_id, "payload": item["payload"]} + except Exception: + log.warning("profile cosmetics enrich failed", exc_info=True) + profile["cosmetics"] = cosmetics + return profile + + + +@router.post("/api/profile") +def api_set_profile(data: dict): + """Set/update the player profile. Body: {display_name, avatar:{type,value}}. + avatar.type is 'default' (value = bundled filename) or 'upload' (value = + the /api/profile/avatar/ URL returned by the upload endpoint); omit + avatar to keep the existing one (name-only edit).""" + name = _clean_str(data.get("display_name")) + if not (1 <= len(name) <= 32): + return JSONResponse({"error": "Display name must be 1–32 characters."}, status_code=400) + avatar = data.get("avatar") + if avatar is None: + avatar = {} # omitted → keep the current avatar (name-only edit) + elif not isinstance(avatar, dict): + return JSONResponse({"error": "avatar must be an object."}, status_code=400) + atype = avatar.get("type") + aval = _clean_str(avatar.get("value")) + avatar_url = None + if atype == "default": + if aval not in _list_bundled_avatars(): + return JSONResponse({"error": "Unknown default avatar."}, status_code=400) + avatar_url = f"/static/v3/avatars/{aval}" + elif atype == "upload": + from safepath import safe_join + fname = aval.rsplit("/", 1)[-1] if aval.startswith("/api/profile/avatar/") else "" + target = safe_join(appstate.config_dir / "avatars", fname) if fname else None + if target is None or not target.is_file(): + return JSONResponse({"error": "Uploaded avatar not found."}, status_code=400) + avatar_url = f"/api/profile/avatar/{fname}" + elif atype: + return JSONResponse({"error": "Unknown avatar type."}, status_code=400) + # atype None/missing → keep the current avatar (name-only edit). + return appstate.meta_db.set_profile(name, avatar_url) + + +@router.get("/api/profile/avatars") +def api_list_avatars(): + return [{"name": n, "url": f"/static/v3/avatars/{n}"} for n in _list_bundled_avatars()] + + +@router.post("/api/profile/avatar") +def api_upload_avatar(data: dict): + """Upload a custom avatar as base64 (mirrors the album-art upload pattern). + Re-encodes to a ≤512px PNG under appstate.config_dir/avatars/.""" + import base64 + import io + b64 = data.get("image", "") + if not isinstance(b64, str) or not b64: + return JSONResponse({"error": "No image data"}, status_code=400) + if "," in b64: + b64 = b64.split(",", 1)[1] + try: + raw = base64.b64decode(b64) + except Exception: + return JSONResponse({"error": "Invalid base64"}, status_code=400) + if len(raw) > 6 * 1024 * 1024: + return JSONResponse({"error": "Image too large (max 6 MB)."}, status_code=400) + avatars_dir = appstate.config_dir / "avatars" + avatars_dir.mkdir(parents=True, exist_ok=True) + try: + from PIL import Image + img = Image.open(io.BytesIO(raw)).convert("RGB") + img.thumbnail((512, 512)) + fname = f"upload-{secrets.token_hex(4)}.png" # token busts caches on change + img.save(str(avatars_dir / fname), "PNG") + except Exception as e: + return JSONResponse({"error": f"Invalid image: {e}"}, status_code=400) + return {"url": f"/api/profile/avatar/{fname}"} + + +@router.get("/api/profile/avatar/{name}") +def api_get_avatar(name: str): + from safepath import safe_join + target = safe_join(appstate.config_dir / "avatars", name) + if target is None or not target.is_file(): + return JSONResponse({"error": "not found"}, status_code=404) + return FileResponse(str(target), media_type="image/png") + + +@router.get("/api/profile/progress") +def api_profile_progress(): + """One call for the whole profile badge: {level, xp, xp_in_level, + xp_to_next, current_streak, best_streak, last_active_date}.""" + return appstate.meta_db.get_progress() diff --git a/server.py b/server.py index 2bdcc79..f6de51f 100644 --- a/server.py +++ b/server.py @@ -55,7 +55,7 @@ from dlc_paths import _get_dlc_dir, _resolve_dlc_path # Lives in lib/ because that is the one core dir every packaging path copies. import appstate # Extracted route modules. They import `appstate`, never `server` — one-way graph. -from routers import audio_effects, artist_aliases, loops, playlists, ws_highway, chart, wanted, library_extras, shop, progression +from routers import audio_effects, artist_aliases, loops, playlists, ws_highway, chart, wanted, library_extras, shop, progression, profile import sloppak as sloppak_mod import loosefolder as loosefolder_mod # Pure text-matching engine for MusicBrainz enrichment (P8): denoise/score/ @@ -4665,125 +4665,9 @@ def api_artist_links(name: str): def api_artist_links_refresh(name: str): """Explicit re-fetch of the cached links (the page's manual Refresh).""" return _artist_links_payload(name, force=True) - - -# ── Player profile / unified XP / streak (fee[dB]ack v0.3.0) ────────────────── - -def _list_bundled_avatars() -> list[str]: - """Bundled default avatar filenames under static/v3/avatars/.""" - d = STATIC_DIR / "v3" / "avatars" - if not d.is_dir(): - return [] - exts = {".svg", ".png", ".webp"} - return sorted( - p.name for p in d.iterdir() - if p.is_file() and p.suffix.lower() in exts and not p.name.startswith(".") - ) - - -@app.get("/api/profile") -def api_get_profile(): - profile = meta_db.get_profile() - # Equipped cosmetics ride along (resolved to their payloads) so the theme - # and avatar frame apply at boot without an extra request. Never let a - # cosmetics/content problem break the profile read. - cosmetics = {} - try: - shop = _get_progression_content()["shop"] - for slot, item_id in meta_db.get_equipped().items(): - item = shop.get(item_id) - if item: - cosmetics[slot] = {"item_id": item_id, "payload": item["payload"]} - except Exception: - log.warning("profile cosmetics enrich failed", exc_info=True) - profile["cosmetics"] = cosmetics - return profile - - - -@app.post("/api/profile") -def api_set_profile(data: dict): - """Set/update the player profile. Body: {display_name, avatar:{type,value}}. - avatar.type is 'default' (value = bundled filename) or 'upload' (value = - the /api/profile/avatar/ URL returned by the upload endpoint); omit - avatar to keep the existing one (name-only edit).""" - name = _clean_str(data.get("display_name")) - if not (1 <= len(name) <= 32): - return JSONResponse({"error": "Display name must be 1–32 characters."}, status_code=400) - avatar = data.get("avatar") - if avatar is None: - avatar = {} # omitted → keep the current avatar (name-only edit) - elif not isinstance(avatar, dict): - return JSONResponse({"error": "avatar must be an object."}, status_code=400) - atype = avatar.get("type") - aval = _clean_str(avatar.get("value")) - avatar_url = None - if atype == "default": - if aval not in _list_bundled_avatars(): - return JSONResponse({"error": "Unknown default avatar."}, status_code=400) - avatar_url = f"/static/v3/avatars/{aval}" - elif atype == "upload": - from safepath import safe_join - fname = aval.rsplit("/", 1)[-1] if aval.startswith("/api/profile/avatar/") else "" - target = safe_join(CONFIG_DIR / "avatars", fname) if fname else None - if target is None or not target.is_file(): - return JSONResponse({"error": "Uploaded avatar not found."}, status_code=400) - avatar_url = f"/api/profile/avatar/{fname}" - elif atype: - return JSONResponse({"error": "Unknown avatar type."}, status_code=400) - # atype None/missing → keep the current avatar (name-only edit). - return meta_db.set_profile(name, avatar_url) - - -@app.get("/api/profile/avatars") -def api_list_avatars(): - return [{"name": n, "url": f"/static/v3/avatars/{n}"} for n in _list_bundled_avatars()] - - -@app.post("/api/profile/avatar") -def api_upload_avatar(data: dict): - """Upload a custom avatar as base64 (mirrors the album-art upload pattern). - Re-encodes to a ≤512px PNG under CONFIG_DIR/avatars/.""" - import base64 - import io - b64 = data.get("image", "") - if not isinstance(b64, str) or not b64: - return JSONResponse({"error": "No image data"}, status_code=400) - if "," in b64: - b64 = b64.split(",", 1)[1] - try: - raw = base64.b64decode(b64) - except Exception: - return JSONResponse({"error": "Invalid base64"}, status_code=400) - if len(raw) > 6 * 1024 * 1024: - return JSONResponse({"error": "Image too large (max 6 MB)."}, status_code=400) - avatars_dir = CONFIG_DIR / "avatars" - avatars_dir.mkdir(parents=True, exist_ok=True) - try: - from PIL import Image - img = Image.open(io.BytesIO(raw)).convert("RGB") - img.thumbnail((512, 512)) - fname = f"upload-{secrets.token_hex(4)}.png" # token busts caches on change - img.save(str(avatars_dir / fname), "PNG") - except Exception as e: - return JSONResponse({"error": f"Invalid image: {e}"}, status_code=400) - return {"url": f"/api/profile/avatar/{fname}"} - - -@app.get("/api/profile/avatar/{name}") -def api_get_avatar(name: str): - from safepath import safe_join - target = safe_join(CONFIG_DIR / "avatars", name) - if target is None or not target.is_file(): - return JSONResponse({"error": "not found"}, status_code=404) - return FileResponse(str(target), media_type="image/png") - - -@app.get("/api/profile/progress") -def api_profile_progress(): - """One call for the whole profile badge: {level, xp, xp_in_level, - xp_to_next, current_streak, best_streak, last_active_date}.""" - return meta_db.get_progress() +# ── Player profile (identity / avatars / progress) ─────────────────────────── +# Mounted here (registration order). Implementation in lib/routers/profile.py. +app.include_router(profile.router) @app.post("/api/xp/award")