diff --git a/CHANGELOG.md b/CHANGELOG.md index db738fa..7fbc6a2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,7 +27,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - **Perf harness now measures 2D-highway frame time (R3c gate).** `scripts/perf-baseline.mjs` gains a `--song` mode that reports per-frame draw-cost p50/p95/p99 (draw-tagged via `highway.addDrawHook`), the metric that gates the `highway.js` split. Maintainer/CI-only; baseline recorded in `docs/perf-baseline.md`. -- **`routers/` — extracting `server.py`'s route layer, cheapest-first (R3).** Each PR moves a cohesive route group into a `fastapi.APIRouter` under `lib/routers/`, mounted with `app.include_router(...)` at its original site (FastAPI matches in registration order; the full route table stays byte-identical). Bodies are verbatim — only the decorator receiver (`@app` → `@router`) and singleton reads (`meta_db` → `appstate.meta_db`, resolved at call time) change. So far: `audio_effects` (5), `artist_aliases` (5), `loops` (3), `playlists` (12 + covers), `ws_highway` (the 902-line highway chart WebSocket), `chart` (split/unsplit/work/fileinfo — unblocked by the DLC-path substrate), `library_extras`, `wanted`, `shop`, `progression`, `profile`, `stats` (the `/api/stats/{path}` catch-all stays registered last so it can't shadow `/recent` `/best` `/top`), `version` (`/api/version`; VERSION-file lookup adjusted for the router subdir depth), `art` (the `/api/song/{f}/art*` serve/cover-search/candidates/upload/url + `/api/art/{f}/override` routes; the shared `_song_pack_art_exists`/`_art_override_paths`/`_art_safe_name` helpers stay in `server.py` for the song/delete routes and are reached through the `appstate` seam, the CAA/release transport as `enrichment.X`), and `settings` (`GET`/`POST /api/settings`, `/reset`, and the two-phase atomic export/import bundle `/api/settings/export|import`; the shared `_default_settings` builder stays in `server.py` and is reached through the `appstate` seam), and `song` (upload/delete + the metadata write-back, user-meta, overrides, gap-fill, and per-song info routes; the scan/ingest helpers stay in `server.py` and are reached through new `appstate` seams — `kick_scan`, `invalidate_song_caches`, `stat_for_cache`, and a `scan_status()` getter — the `get_song_info` catch-all mounts after the art routes so it can't shadow them), and `library` + collections (the provider list/art/sync endpoints, the library query surface, and collection CRUD → `lib/routers/library.py`; the `LibraryProviderRegistry`/`LocalLibraryProvider`/`SmartCollectionProvider` classes + shared query/collection helpers move to `lib/library_registry.py`, and the registry instance + local provider ride the `appstate` seam — server.py still constructs the singleton and exposes `register_library_provider`/`unregister_library_provider` to plugins via `plugin_context` unchanged), and the `enrichment` route handlers (`/api/enrichment/*`: status, kick/cancel, per-song state, the Match-Review queue, and AcoustID identify → `lib/routers/enrichment.py`; the engine already lives in `lib/enrichment.py` and is reached as `enrichment.X`), and `media` (the file-serving routes — song audio `/audio/{f}`, the local-audio-path resolver `/api/audio-local-path`, and raw sloppak-member serving `/api/sloppak/{f}/file/{rel}` → `lib/routers/media.py`; the cache/static path seams were already in `appstate`), and `diagnostics` (`/api/diagnostics/export|preview|hardware`; the plugins-root lookup adjusted for the router subdir depth, `_running_version` reached through the `appstate` seam, pure payload-cap helpers re-exported for the `server._diag_*` tests), and `tunings` (`/api/tunings`; the pure `config.json` reader moved to `lib/appconfig.py`, the tuning-provider registry read through the `appstate` seam so plugin-contributed tunings still merge). The DLC library-path resolution (`_get_dlc_dir`, pure `_resolve_dlc_path`) moved to `lib/dlc_paths.py`, reading paths through the seam; `config_dir`/`dlc_dir`/`dlc_dir_env` now ride the `appstate` seam (env-derived, so the pop-and-reimport fixtures reconfigure it for free), and the shared request-field sanitizer `_clean_str` moved to `lib/reqfields.py`. The next cut is picked by a dependency-closure scan that ranks groups by how many `monkeypatch.setattr(server, …)` targets they'd drag along. +- **`routers/` — extracting `server.py`'s route layer, cheapest-first (R3).** Each PR moves a cohesive route group into a `fastapi.APIRouter` under `lib/routers/`, mounted with `app.include_router(...)` at its original site (FastAPI matches in registration order; the full route table stays byte-identical). Bodies are verbatim — only the decorator receiver (`@app` → `@router`) and singleton reads (`meta_db` → `appstate.meta_db`, resolved at call time) change. So far: `audio_effects` (5), `artist_aliases` (5), `loops` (3), `playlists` (12 + covers), `ws_highway` (the 902-line highway chart WebSocket), `chart` (split/unsplit/work/fileinfo — unblocked by the DLC-path substrate), `library_extras`, `wanted`, `shop`, `progression`, `profile`, `stats` (the `/api/stats/{path}` catch-all stays registered last so it can't shadow `/recent` `/best` `/top`), `version` (`/api/version`; VERSION-file lookup adjusted for the router subdir depth), `art` (the `/api/song/{f}/art*` serve/cover-search/candidates/upload/url + `/api/art/{f}/override` routes; the shared `_song_pack_art_exists`/`_art_override_paths`/`_art_safe_name` helpers stay in `server.py` for the song/delete routes and are reached through the `appstate` seam, the CAA/release transport as `enrichment.X`), and `settings` (`GET`/`POST /api/settings`, `/reset`, and the two-phase atomic export/import bundle `/api/settings/export|import`; the shared `_default_settings` builder stays in `server.py` and is reached through the `appstate` seam), and `song` (upload/delete + the metadata write-back, user-meta, overrides, gap-fill, and per-song info routes; the scan/ingest helpers stay in `server.py` and are reached through new `appstate` seams — `kick_scan`, `invalidate_song_caches`, `stat_for_cache`, and a `scan_status()` getter — the `get_song_info` catch-all mounts after the art routes so it can't shadow them), and `library` + collections (the provider list/art/sync endpoints, the library query surface, and collection CRUD → `lib/routers/library.py`; the `LibraryProviderRegistry`/`LocalLibraryProvider`/`SmartCollectionProvider` classes + shared query/collection helpers move to `lib/library_registry.py`, and the registry instance + local provider ride the `appstate` seam — server.py still constructs the singleton and exposes `register_library_provider`/`unregister_library_provider` to plugins via `plugin_context` unchanged), and the `enrichment` route handlers (`/api/enrichment/*`: status, kick/cancel, per-song state, the Match-Review queue, and AcoustID identify → `lib/routers/enrichment.py`; the engine already lives in `lib/enrichment.py` and is reached as `enrichment.X`), and `media` (the file-serving routes — song audio `/audio/{f}`, the local-audio-path resolver `/api/audio-local-path`, and raw sloppak-member serving `/api/sloppak/{f}/file/{rel}` → `lib/routers/media.py`; the cache/static path seams were already in `appstate`), and `artist` (the artist page + external-links payload `/api/artist/{name}/page|links|links/refresh` → `lib/routers/artist.py`; MB link enrichment reached as `enrichment.X`), and `diagnostics` (`/api/diagnostics/export|preview|hardware`; the plugins-root lookup adjusted for the router subdir depth, `_running_version` reached through the `appstate` seam, pure payload-cap helpers re-exported for the `server._diag_*` tests), and `tunings` (`/api/tunings`; the pure `config.json` reader moved to `lib/appconfig.py`, the tuning-provider registry read through the `appstate` seam so plugin-contributed tunings still merge). The DLC library-path resolution (`_get_dlc_dir`, pure `_resolve_dlc_path`) moved to `lib/dlc_paths.py`, reading paths through the seam; `config_dir`/`dlc_dir`/`dlc_dir_env` now ride the `appstate` seam (env-derived, so the pop-and-reimport fixtures reconfigure it for free), and the shared request-field sanitizer `_clean_str` moved to `lib/reqfields.py`. The next cut is picked by a dependency-closure scan that ranks groups by how many `monkeypatch.setattr(server, …)` targets they'd drag along. - **`routers/` — the first extracted route module (R3).** The five audio-effects mapping endpoints move out of `server.py` into `lib/routers/audio_effects.py` as a `fastapi.APIRouter`, mounted with `app.include_router(...)` **at the point in the file diff --git a/docs/size-exemptions.md b/docs/size-exemptions.md index 13d4e8c..0861ae5 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` -(2,507 — was 14,037; ratcheted by the R3 `MetadataDB` + `AudioEffectsMappingDB` -extractions and twenty-one `routers/` modules, plus lib/library_registry.py for the provider-registry classes (album-art in `lib/routers/art.py`, the settings + export/import bundle in `lib/routers/settings.py`); the ~930-line metadata-enrichment subsystem — MB/CAA/AcoustID transport, matcher, background worker — now lives in `lib/enrichment.py`) · +(2,413 — was 14,037; ratcheted by the R3 `MetadataDB` + `AudioEffectsMappingDB` +extractions and twenty-two `routers/` modules, plus lib/library_registry.py for the provider-registry classes (album-art in `lib/routers/art.py`, the settings + export/import bundle in `lib/routers/settings.py`); the ~930-line metadata-enrichment subsystem — MB/CAA/AcoustID transport, matcher, background worker — now lives in `lib/enrichment.py`) · `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/artist.py b/lib/routers/artist.py new file mode 100644 index 0000000..74d06ad --- /dev/null +++ b/lib/routers/artist.py @@ -0,0 +1,126 @@ +"""Artist routes: the artist page + external-links payload +(/api/artist/{name}/page, /links, /links/refresh). + +Extracted verbatim from server.py (R3) except @app->@router and the seam reads +(meta_db->appstate.meta_db, CONFIG_DIR->appstate.config_dir, _default_settings-> +appstate.default_settings). MusicBrainz link enrichment is reached as +enrichment.X; the shared URL-safety validator lives in lib/library_registry.py. +""" + +from fastapi import APIRouter + +import appstate +import enrichment +from appconfig import _load_config +from library_registry import _safe_art_redirect_url + +import logging +log = logging.getLogger("feedBack.server") +router = APIRouter() + +# MB artist url-relation types → the page's link slots (locked position 4: +# whitelist only, links-only forever). Everything not listed is dropped. +_ARTIST_URL_REL_SLOTS = { + "official homepage": "official", + "setlistfm": "tour", + "concerts": "tour", + "youtube": "video", + "video channel": "video", + "social network": "social", + "bandcamp": "social", + "soundcloud": "social", + "wikipedia": "wikipedia", + "wikidata": "wikipedia", +} + + +def _artist_links_from_mb(body: dict) -> tuple[dict, list]: + """Whitelist an MB artist doc's url-relations into the page's link slots: + {official, tour, video, social: [...], wikipedia}. Every URL passes the + same http(s)-scheme gate as art redirects (_safe_art_redirect_url) so a + hostile javascript:/data:/file: resource can never reach an href. First + URL wins per single slot; social collects up to 5; wikipedia is preferred + over wikidata when both exist. Also returns MB's genre names (capped).""" + links: dict = {} + social: list = [] + wikidata_url = None + for rel in (body or {}).get("relations") or []: + if not isinstance(rel, dict): + continue + rtype = str(rel.get("type") or "").strip().lower() + slot = _ARTIST_URL_REL_SLOTS.get(rtype) + if not slot: + continue + url = rel.get("url") + url = url.get("resource") if isinstance(url, dict) else url + if _safe_art_redirect_url(url) is None: + continue + if slot == "social": + if url not in social and len(social) < 5: + social.append(url) + elif rtype == "wikidata": + wikidata_url = wikidata_url or url + elif slot not in links: + links[slot] = url + if social: + links["social"] = social + if "wikipedia" not in links and wikidata_url: + links["wikipedia"] = wikidata_url + genres = [str(g.get("name")) for g in (body or {}).get("genres") or [] + if isinstance(g, dict) and g.get("name")] + return links, genres[:8] + + +def _artist_links_payload(name: str, force: bool = False) -> dict: + """Shared by GET links + POST refresh. Order of gates: the user's opt-in + setting (external links are OFF by default — the dev-chat thread's call), + then a known mb_artist_id (no id → nothing to look up), then the cache + (unless force), then the offline guard, then ONE throttled fetch.""" + cfg = _load_config(appstate.config_dir / "config.json") or appstate.default_settings() + if cfg.get("artist_external_links") is not True: + return {"links": {}, "matched": False, "disabled": True} + canonical = appstate.meta_db._terminal_canonical((name or "").strip()) + mbid = appstate.meta_db.artist_known_mb_id(appstate.meta_db._raw_variants_for(canonical)) + mbid = (mbid or "").strip().lower() + # The id is interpolated into the MB request path — same strict-shape rule + # as the manifest identity keys (_MBID_RE), so a junk/hostile value stored + # via a hand-rolled /pick body can never reach the request line. + if not mbid or not enrichment._MBID_RE.match(mbid): + return {"links": {}, "matched": False} + if not force: + cached = appstate.meta_db.get_artist_enrichment(mbid) + if cached: + return {"links": cached["url_rels"], "genres": cached["genres"], + "matched": True, "cached": True, "mb_artist_id": mbid} + if not enrichment._enrich_network_enabled(): + return {"links": {}, "matched": True, "offline": True, "mb_artist_id": mbid} + try: + body = enrichment._mb_http_get(f"artist/{mbid}", {"inc": "url-rels+genres+tags"}) + except enrichment.EnrichTransportError: + return {"links": {}, "matched": True, "offline": True, "mb_artist_id": mbid} + links, genres = _artist_links_from_mb(body or {}) + appstate.meta_db.put_artist_enrichment(mbid, links, genres) + return {"links": links, "genres": genres, "matched": True, "cached": False, + "mb_artist_id": mbid} + + +@router.get("/api/artist/{name:path}/page") +def api_artist_page(name: str): + """The artist page's all-LOCAL payload — counts, albums, aliases, similar- + in-library, mosaic art, play-all seed. Never touches the network; an + unmatched or even unknown artist still returns a functional page.""" + return appstate.meta_db.artist_page(name) + + +@router.get("/api/artist/{name:path}/links") +def api_artist_links(name: str): + """External links for a matched artist — cached after the first call. + Sync route on purpose (like /api/enrichment/search): FastAPI runs it in + the threadpool so the MB throttle's sleep never blocks the event loop.""" + return _artist_links_payload(name) + + +@router.post("/api/artist/{name:path}/links/refresh") +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) diff --git a/server.py b/server.py index 9b4af5f..1a85e79 100644 --- a/server.py +++ b/server.py @@ -37,7 +37,7 @@ from metadata_db import MetadataDB from audio_effects_db import AudioEffectsMappingDB from library_registry import ( # registry classes + collection lifecycle moved to lib (R3) LibraryProviderRegistry, LocalLibraryProvider, - _safe_art_redirect_url, _sync_collection_provider, + _sync_collection_provider, ) from dlc_paths import _get_dlc_dir, _resolve_dlc_path # The router seam. Imported as a module (never `from appstate import ...`) so @@ -54,6 +54,7 @@ from routers import song as song_router from routers import library as library_router from routers import enrichment as enrichment_routes from routers import media as media_router +from routers import artist as artist_router import sloppak as sloppak_mod import loosefolder as loosefolder_mod # Pure text-matching engine for MusicBrainz enrichment (P8): denoise/score/ @@ -2052,112 +2053,18 @@ app.include_router(artist_aliases.router) # re-fetched only by the explicit refresh. Both links routes are demo-blocked # (they store server state + spend the shared MB rate limit). -# MB artist url-relation types → the page's link slots (locked position 4: -# whitelist only, links-only forever). Everything not listed is dropped. -_ARTIST_URL_REL_SLOTS = { - "official homepage": "official", - "setlistfm": "tour", - "concerts": "tour", - "youtube": "video", - "video channel": "video", - "social network": "social", - "bandcamp": "social", - "soundcloud": "social", - "wikipedia": "wikipedia", - "wikidata": "wikipedia", -} +# ── Artist routes → routers/artist.py (R3) ────────────────────────────────── +app.include_router(artist_router.router) -def _artist_links_from_mb(body: dict) -> tuple[dict, list]: - """Whitelist an MB artist doc's url-relations into the page's link slots: - {official, tour, video, social: [...], wikipedia}. Every URL passes the - same http(s)-scheme gate as art redirects (_safe_art_redirect_url) so a - hostile javascript:/data:/file: resource can never reach an href. First - URL wins per single slot; social collects up to 5; wikipedia is preferred - over wikidata when both exist. Also returns MB's genre names (capped).""" - links: dict = {} - social: list = [] - wikidata_url = None - for rel in (body or {}).get("relations") or []: - if not isinstance(rel, dict): - continue - rtype = str(rel.get("type") or "").strip().lower() - slot = _ARTIST_URL_REL_SLOTS.get(rtype) - if not slot: - continue - url = rel.get("url") - url = url.get("resource") if isinstance(url, dict) else url - if _safe_art_redirect_url(url) is None: - continue - if slot == "social": - if url not in social and len(social) < 5: - social.append(url) - elif rtype == "wikidata": - wikidata_url = wikidata_url or url - elif slot not in links: - links[slot] = url - if social: - links["social"] = social - if "wikipedia" not in links and wikidata_url: - links["wikipedia"] = wikidata_url - genres = [str(g.get("name")) for g in (body or {}).get("genres") or [] - if isinstance(g, dict) and g.get("name")] - return links, genres[:8] -def _artist_links_payload(name: str, force: bool = False) -> dict: - """Shared by GET links + POST refresh. Order of gates: the user's opt-in - setting (external links are OFF by default — the dev-chat thread's call), - then a known mb_artist_id (no id → nothing to look up), then the cache - (unless force), then the offline guard, then ONE throttled fetch.""" - cfg = _load_config(CONFIG_DIR / "config.json") or _default_settings() - if cfg.get("artist_external_links") is not True: - return {"links": {}, "matched": False, "disabled": True} - canonical = meta_db._terminal_canonical((name or "").strip()) - mbid = meta_db.artist_known_mb_id(meta_db._raw_variants_for(canonical)) - mbid = (mbid or "").strip().lower() - # The id is interpolated into the MB request path — same strict-shape rule - # as the manifest identity keys (_MBID_RE), so a junk/hostile value stored - # via a hand-rolled /pick body can never reach the request line. - if not mbid or not enrichment._MBID_RE.match(mbid): - return {"links": {}, "matched": False} - if not force: - cached = meta_db.get_artist_enrichment(mbid) - if cached: - return {"links": cached["url_rels"], "genres": cached["genres"], - "matched": True, "cached": True, "mb_artist_id": mbid} - if not enrichment._enrich_network_enabled(): - return {"links": {}, "matched": True, "offline": True, "mb_artist_id": mbid} - try: - body = enrichment._mb_http_get(f"artist/{mbid}", {"inc": "url-rels+genres+tags"}) - except enrichment.EnrichTransportError: - return {"links": {}, "matched": True, "offline": True, "mb_artist_id": mbid} - links, genres = _artist_links_from_mb(body or {}) - meta_db.put_artist_enrichment(mbid, links, genres) - return {"links": links, "genres": genres, "matched": True, "cached": False, - "mb_artist_id": mbid} -@app.get("/api/artist/{name:path}/page") -def api_artist_page(name: str): - """The artist page's all-LOCAL payload — counts, albums, aliases, similar- - in-library, mosaic art, play-all seed. Never touches the network; an - unmatched or even unknown artist still returns a functional page.""" - return meta_db.artist_page(name) -@app.get("/api/artist/{name:path}/links") -def api_artist_links(name: str): - """External links for a matched artist — cached after the first call. - Sync route on purpose (like /api/enrichment/search): FastAPI runs it in - the threadpool so the MB throttle's sleep never blocks the event loop.""" - return _artist_links_payload(name) -@app.post("/api/artist/{name:path}/links/refresh") -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 (identity / avatars / progress) ─────────────────────────── # Mounted here (registration order). Implementation in lib/routers/profile.py. app.include_router(profile.router)