mirror of
https://github.com/got-feedBack/feedBack.git
synced 2026-08-12 11:49:28 +00:00
refactor(server): extract the artist routes into routers/artist.py (R3) (#870)
The artist page + external-links payload (/api/artist/{name}/page, /links,
/links/refresh) plus their exclusive helpers (_artist_links_payload,
_artist_links_from_mb, the URL-slot table) move to lib/routers/artist.py. Bodies
verbatim except @app->@router and the seam reads (meta_db->appstate.meta_db,
CONFIG_DIR->appstate.config_dir, _default_settings->appstate.default_settings).
MB link enrichment is reached as enrichment.X; the URL-safety validator is
imported from lib/library_registry.py. No new seams.
server.py: 2,507 -> 2,413 (-94).
Verified: pyflakes clean; route set unchanged (143); full pytest 2395 passed.
eslint 0.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
0547f55844
commit
5e30138c87
+1
-1
@@ -27,7 +27,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
### Added
|
### 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`.
|
- **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
|
- **`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
|
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
|
`fastapi.APIRouter`, mounted with `app.include_router(...)` **at the point in the file
|
||||||
|
|||||||
@@ -55,8 +55,8 @@ without a *signed* exemption" is unenforceable.
|
|||||||
## Planned, NOT exempt (owned by split plans — listed so nothing falls between states)
|
## 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`
|
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`
|
(2,413 — 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`) ·
|
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
|
`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
|
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`
|
lands) · `static/v3/songs.js` (4,134) · `static/capabilities/audio-session.js`
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -37,7 +37,7 @@ from metadata_db import MetadataDB
|
|||||||
from audio_effects_db import AudioEffectsMappingDB
|
from audio_effects_db import AudioEffectsMappingDB
|
||||||
from library_registry import ( # registry classes + collection lifecycle moved to lib (R3)
|
from library_registry import ( # registry classes + collection lifecycle moved to lib (R3)
|
||||||
LibraryProviderRegistry, LocalLibraryProvider,
|
LibraryProviderRegistry, LocalLibraryProvider,
|
||||||
_safe_art_redirect_url, _sync_collection_provider,
|
_sync_collection_provider,
|
||||||
)
|
)
|
||||||
from dlc_paths import _get_dlc_dir, _resolve_dlc_path
|
from dlc_paths import _get_dlc_dir, _resolve_dlc_path
|
||||||
# The router seam. Imported as a module (never `from appstate import ...`) so
|
# 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 library as library_router
|
||||||
from routers import enrichment as enrichment_routes
|
from routers import enrichment as enrichment_routes
|
||||||
from routers import media as media_router
|
from routers import media as media_router
|
||||||
|
from routers import artist as artist_router
|
||||||
import sloppak as sloppak_mod
|
import sloppak as sloppak_mod
|
||||||
import loosefolder as loosefolder_mod
|
import loosefolder as loosefolder_mod
|
||||||
# Pure text-matching engine for MusicBrainz enrichment (P8): denoise/score/
|
# 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
|
# re-fetched only by the explicit refresh. Both links routes are demo-blocked
|
||||||
# (they store server state + spend the shared MB rate limit).
|
# (they store server state + spend the shared MB rate limit).
|
||||||
|
|
||||||
# MB artist url-relation types → the page's link slots (locked position 4:
|
# ── Artist routes → routers/artist.py (R3) ──────────────────────────────────
|
||||||
# whitelist only, links-only forever). Everything not listed is dropped.
|
app.include_router(artist_router.router)
|
||||||
_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(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) ───────────────────────────
|
# ── Player profile (identity / avatars / progress) ───────────────────────────
|
||||||
# Mounted here (registration order). Implementation in lib/routers/profile.py.
|
# Mounted here (registration order). Implementation in lib/routers/profile.py.
|
||||||
app.include_router(profile.router)
|
app.include_router(profile.router)
|
||||||
|
|||||||
Reference in New Issue
Block a user