From b7624b7e65762da6637daad7bedf2d6aafaf7ea3 Mon Sep 17 00:00:00 2001 From: Byron Gamatos Date: Sat, 11 Jul 2026 15:19:34 +0200 Subject: [PATCH] refactor(server): extract the enrichment route handlers into routers/enrichment.py (R3) (#868) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 14 /api/enrichment/* routes — status, kick/cancel, per-song state, the Match-Review queue (accept/reject/pick/search/rematch/refresh/states), and the AcoustID fingerprint identify endpoints — plus the route-exclusive candidate sanitizer move to lib/routers/enrichment.py. Bodies verbatim except @app->@router and the seam reads (meta_db->appstate.meta_db, CONFIG_DIR->appstate.config_dir). The enrichment engine (transport, matcher, worker, upload caps) already lives in lib/enrichment.py from the earlier subsystem move and is reached as enrichment.X. No new seams. server.py: 2,925 -> 2,638 (-287). Verified: pyflakes clean; route set identical (143); full pytest 2396 passed (the enrichment route + Match-Review + identify cases, which fake the network on the enrichment module). eslint 0. Co-authored-by: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 2 +- docs/size-exemptions.md | 4 +- lib/routers/enrichment.py | 346 ++++++++++++++++++++++++++++++++++++++ server.py | 295 +------------------------------- 4 files changed, 353 insertions(+), 294 deletions(-) create mode 100644 lib/routers/enrichment.py diff --git a/CHANGELOG.md b/CHANGELOG.md index c960ad2..1a04131 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 `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 `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 66aba6b..1cdce39 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,925 — was 14,037; ratcheted by the R3 `MetadataDB` + `AudioEffectsMappingDB` -extractions and nineteen `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,638 — was 14,037; ratcheted by the R3 `MetadataDB` + `AudioEffectsMappingDB` +extractions and twenty `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/enrichment.py b/lib/routers/enrichment.py new file mode 100644 index 0000000..13e96b9 --- /dev/null +++ b/lib/routers/enrichment.py @@ -0,0 +1,346 @@ +"""Metadata-enrichment route handlers (/api/enrichment/*): status, kick/cancel, +per-song state, the Match-Review queue (accept/reject/pick/search), and AcoustID +fingerprint identify. + +Extracted verbatim from server.py (R3) except @app->@router and the seam reads +(meta_db->appstate.meta_db, CONFIG_DIR->appstate.config_dir). The enrichment +engine itself — transport, matcher, the background worker, and the upload caps — +lives in lib/enrichment.py and is reached here as enrichment.X. +""" + +import asyncio +import os +import shutil +from pathlib import Path + +from fastapi import APIRouter, Body, HTTPException, Request, UploadFile +from fastapi.responses import JSONResponse + +import appstate +import enrichment +import mb_match +from appconfig import _load_config + +import logging +log = logging.getLogger("feedBack.server") +router = APIRouter() + +@router.get("/api/enrichment/status") +def enrichment_status(): + """Enrichment pipeline state: worker flags + row counts by match_state. + Ambient tool-state for the match-review UI (never a home-screen score — + design §11); also what tests poke.""" + return { + "running": enrichment._enrich_status["running"], + "processed": enrichment._enrich_status["processed"], + "last_pass_at": enrichment._enrich_status["last_pass_at"], + "states": appstate.meta_db.enrichment_state_counts(), + "total_songs": appstate.meta_db.count(), + # Per-pass matching progress for the "Refresh Metadata" batch bar + + # per-tile badges (total = songs queued to match this pass, matched = + # done so far, current = the one being matched now). + "total": enrichment._enrich_status.get("total", 0), + "matched": enrichment._enrich_status.get("matched", 0), + "current": enrichment._enrich_status.get("current"), + "cancelling": enrichment._enrich_cancel.is_set(), + } + + +@router.get("/api/enrichment/song/{filename:path}") +def api_enrichment_song(filename: str): + """Read-only per-song match provenance for the Details drawer (launch + polish): which canonical identity this chart matched and how. A tiny + projection of the cache row — no candidates, no cache paths.""" + row = appstate.meta_db.get_enrichment(filename) + if not row: + raise HTTPException(status_code=404, detail="no enrichment row") + return {k: row.get(k) for k in + ("match_state", "canon_artist", "canon_title", + "match_source", "match_score")} + + +@router.post("/api/enrichment/kick") +def api_enrichment_kick(): + """The Settings "Match now" button AND the library's "Refresh Metadata" + button: request an enrichment pass without waiting for a scan to complete. + Processes the songs that still need it (unscanned/changed + retriable + failures) — already-matched songs are left alone, so on a fully-matched + library this is a fast no-op. Single-flight + coalescing like every other + kick — spamming it queues at most one follow-up pass.""" + return {"started": enrichment._kick_enrich()} + + +@router.post("/api/enrichment/cancel") +def api_enrichment_cancel(): + """Stop button on the "Refresh Metadata" batch: signal the running pass to + halt after the current song (an in-flight ≤1/s lookup can't be interrupted, + but no new one is started) and drop any coalesced follow-up. A no-op when + nothing is running.""" + was_running = enrichment._enrich_status["running"] + if was_running: + enrichment._enrich_cancel.set() + return {"ok": True, "was_running": was_running} + + +@router.post("/api/enrichment/rematch") +def api_enrichment_rematch(data: dict = Body(...)): + """The library "Refresh Metadata" button: force a fresh re-match of the + songs the grid is SHOWING (its visible/filtered window). Resets each to + `unscanned` so the next pass re-fetches it from scratch — EXCEPT user-pinned + `manual` rows, which are never auto-overwritten (apply_enrichment_match + guards that) — then kicks one pass. Scoped to the visible set on purpose: + fast (dozens of songs), visible (tiles animate), and it can't blow the whole + ≤1/s rate budget on a 1000-song library the way a full re-sweep would. + Returns the filenames actually queued so the UI badges exactly those.""" + raw = (data or {}).get("filenames") or [] + fns = [str(f) for f in raw if isinstance(f, str)][:500] + queued: list[str] = [] + for fn in fns: + song = appstate.meta_db.enrichment_song_row(fn) + if not song: + continue + h = appstate.meta_db.enrichment_content_hash( + song["artist"], song["title"], song["album"], song["duration"]) + # allow_manual_overwrite=False → a manual pin is left as-is (returns + # False), everything else resets to unscanned (returns True). + if appstate.meta_db.apply_enrichment_match(fn, h, "unscanned", + allow_manual_overwrite=False): + queued.append(fn) + started = enrichment._kick_enrich() if queued else False + return {"queued": queued, "count": len(queued), "started": started} + + +@router.post("/api/enrichment/states") +def api_enrichment_states(data: dict = Body(...)): + """Per-tile match states for the grid's VISIBLE window during a metadata + refresh: the client posts the filenames it is showing and gets back each + one's match_state (+ the song being matched right now, + whether a pass is + running), so a card can animate queued→working→result without a per-song + round-trip. Read-only — safe for demo visitors (no network, no mutation).""" + raw = (data or {}).get("filenames") or [] + # Bound the batch: a visible grid window is dozens of cards; cap defensively. + fns = [str(f) for f in raw if isinstance(f, str)][:500] + return { + "states": appstate.meta_db.enrichment_states_for(fns), + "current": enrichment._enrich_status.get("current"), + "running": enrichment._enrich_status["running"], + } + + +@router.post("/api/enrichment/refresh/{filename:path}") +def api_enrichment_refresh(filename: str): + """The context menu's "Refresh metadata": reset THIS song's match to + unscanned (canonical values + candidates cleared, backoff zeroed) and + kick a pass so it re-matches immediately. An EXPLICIT user action, so it + may discard a manual pin — the automation never does, but the user + asking for a re-match is the one party who owns that pin.""" + song = appstate.meta_db.enrichment_song_row(filename) + if not song: + raise HTTPException(status_code=404, detail="unknown song") + h = appstate.meta_db.enrichment_content_hash( + song["artist"], song["title"], song["album"], song["duration"]) + appstate.meta_db.apply_enrichment_match(filename, h, "unscanned", + allow_manual_overwrite=True) + return {"ok": True, "started": enrichment._kick_enrich()} + + +@router.get("/api/enrichment/review") +def api_enrichment_review(limit: int = 200): + """The Match-Review queue: songs whose text match landed in the medium- + confidence review tier, each with its stored candidate list — the drawer + renders straight from this, no MusicBrainz round-trip. Ordered by the + user's enrich_review_order setting.""" + limit = max(1, min(int(limit), 500)) + cfg = _load_config(appstate.config_dir / "config.json") or {} + order = cfg.get("enrich_review_order", "missing_first") + return { + "songs": appstate.meta_db.enrichment_review_queue(limit=limit, order=order), + "total_review": appstate.meta_db.enrichment_state_counts().get("review", 0), + } + + +@router.post("/api/enrichment/review/{filename:path}/accept") +def api_enrichment_accept(filename: str, data: dict = Body(...)): + """Accept one of the stored review candidates: the row becomes a + user-pinned `manual` match (never auto-reset). Display-only, like every + enrichment write — nothing touches the pack file.""" + recording_id = str((data or {}).get("recording_id") or "") + row = appstate.meta_db.get_enrichment(filename) + if not row or row["match_state"] != "review": + raise HTTPException(status_code=404, detail="no review row for this song") + cand = next((c for c in (row.get("candidates") or []) + if c.get("recording_id") == recording_id), None) + if not cand: + raise HTTPException(status_code=404, detail="candidate not in the stored list") + if not appstate.meta_db.set_enrichment_manual(filename, cand, source="review"): + raise HTTPException(status_code=404, detail="unknown song") + return {"ok": True, "enrichment": appstate.meta_db.get_enrichment(filename)} + + +@router.post("/api/enrichment/review/{filename:path}/reject") +def api_enrichment_reject(filename: str): + """"None of these" — clears any canonical values and parks the row as + failed/rejected (never auto-retried; editing the song's metadata + re-queues it). Valid from `review` or `matched`, never from `manual`.""" + if not appstate.meta_db.set_enrichment_rejected(filename): + raise HTTPException(status_code=404, detail="no rejectable match for this song") + return {"ok": True, "enrichment": appstate.meta_db.get_enrichment(filename)} + + +# The candidate fields a manual pick is allowed to carry — the payload comes +# from our own /api/enrichment/search proxy, but the route re-sanitizes so a +# hand-rolled client can't stuff arbitrary keys/types into the cache row. +_CAND_STR_FIELDS = ("recording_id", "title", "artist", "artist_id", + "artist_sort", "release_id", "album", "year", "isrc") + + +def _sanitize_candidate(raw: dict) -> dict | None: + if not isinstance(raw, dict): + return None + out = {k: str(raw.get(k) or "") for k in _CAND_STR_FIELDS} + if not out["recording_id"] or not out["title"]: + return None + genres = raw.get("genres") or [] + out["genres"] = [str(g) for g in genres if isinstance(g, str)][:5] \ + if isinstance(genres, list) else [] + return out + + +@router.post("/api/enrichment/review/{filename:path}/pick") +def api_enrichment_pick(filename: str, data: dict = Body(...)): + """Fix-match / manual search-and-pick: pin a candidate the user found via + /api/enrichment/search (not limited to the stored review list — this is + the escape hatch for a wrong auto-match too). Sets `manual`, the + highest-authority state.""" + cand = _sanitize_candidate((data or {}).get("candidate")) + if not cand: + raise HTTPException(status_code=400, detail="candidate needs recording_id + title") + if not appstate.meta_db.set_enrichment_manual(filename, cand, source="search"): + raise HTTPException(status_code=404, detail="unknown song") + return {"ok": True, "enrichment": appstate.meta_db.get_enrichment(filename)} + + +@router.get("/api/enrichment/search") +def api_enrichment_search(artist: str = "", title: str = "", limit: int = 8, + filename: str = "", duration: float = 0.0): + """Manual-search proxy to MusicBrainz (throttled + identified like the + background matcher — a user typing in the drawer must not sidestep the + rate limit). `filename` optionally scores results against that song's + stored identity (year/duration corroboration) instead of just the typed + text. `duration` (seconds) lets a caller that HAS the audio but no library + row — e.g. the editor's create modal, which holds the master track — pass + its length so the studio take ranks above live/extended cuts. Sync route on + purpose: FastAPI runs it in the threadpool, so the throttle's sleep never + blocks the event loop.""" + if not (artist.strip() or title.strip()): + raise HTTPException(status_code=400, detail="artist or title required") + limit = max(1, min(int(limit), 25)) + try: + cands = enrichment._mb_search_recordings(artist, title, limit=limit) + except enrichment.EnrichTransportError as e: + return JSONResponse({"error": "musicbrainz unavailable", "detail": str(e)}, + status_code=503) + ref = None + if filename: + ref = appstate.meta_db.enrichment_song_row(filename) + if ref is None: + ref = {"artist": artist, "title": title} + # A caller-supplied duration corroborates the take even without a library row. + if duration and duration > 0 and not ref.get("duration"): + ref = dict(ref) + ref["duration"] = duration + # Alias-enrich so a non-Latin-primary artist (大橋純子) ranks by its + # romanized alias against the typed query ("Junko Ohashi") instead of + # sinking to the bottom with a 0 artist score. + try: + enrichment._alias_enrich(ref, cands) + except enrichment.EnrichTransportError: + pass # aliases are a ranking nicety here; fall back to primary-name scoring + return {"candidates": mb_match.rank_candidates(ref, cands)} + + +@router.post("/api/enrichment/identify") +async def api_enrichment_identify(request: Request): + """Identify a song by AUDIO FINGERPRINT (AcoustID) rather than text — the + reliable way to get the EXACT recording/version (the studio take, not a live + bootleg or an extended cut). Upload the master audio; returns candidates in + the same shape as /search, so the review UI and the editor's Match popup can + render fingerprint hits identically. 412 `needs_setup` when the user hasn't + opted in / has no key (the UI nudges them to Settings); 503 when it's set up + but the fpcalc Chromaprint binary is missing or the network is off. Async so + the multipart is size-capped BEFORE spooling; the blocking fpcalc subprocess + + AcoustID HTTP run in the threadpool via run_in_executor.""" + gate = enrichment._acoustid_gate() + if gate is not None: + return gate + # Pre-parse Content-Length guard — reject an oversized body before Starlette + # spools the multipart to temp disk (mirrors the song-upload endpoint). The + # per-part cap below is the authoritative limit; this is the fast up-front no. + cl = request.headers.get("content-length") + if cl is not None: + try: + cl_int = int(cl) + except ValueError: + return JSONResponse({"error": "Invalid Content-Length header"}, status_code=400) + if cl_int > enrichment._ACOUSTID_MAX_UPLOAD_BYTES + enrichment._MULTIPART_OVERHEAD_SLACK: + return JSONResponse({"error": "audio upload too large (256 MB max)"}, status_code=413) + try: + form = await request.form(max_part_size=enrichment._ACOUSTID_MAX_UPLOAD_BYTES) + except Exception: + return JSONResponse({"error": "audio upload too large (256 MB max)"}, status_code=413) + file = form.get("file") + if not isinstance(file, UploadFile): + raise HTTPException(status_code=400, detail="missing file upload") + import tempfile + ext = (Path(file.filename or "").suffix or ".bin").lower() + tmpdir = tempfile.mkdtemp(prefix="feedback_acoustid_") + tmp = os.path.join(tmpdir, "audio" + ext) + try: + total = 0 + with open(tmp, "wb") as fh: + while True: + chunk = await file.read(1024 * 1024) + if not chunk: + break + total += len(chunk) + if total > enrichment._ACOUSTID_MAX_UPLOAD_BYTES: + return JSONResponse( + {"error": "audio upload too large (256 MB max)"}, status_code=413) + fh.write(chunk) + if total == 0: + raise HTTPException(status_code=400, detail="empty upload") + # fpcalc subprocess + AcoustID HTTP are blocking — off the event loop. + cands = await asyncio.get_event_loop().run_in_executor( + None, enrichment._identify_by_fingerprint, tmp) + except enrichment.EnrichTransportError as e: + return JSONResponse({"error": "acoustid unavailable", "detail": str(e)}, + status_code=503) + finally: + shutil.rmtree(tmpdir, ignore_errors=True) + return {"candidates": cands} + + +@router.post("/api/enrichment/identify/{filename:path}") +def api_enrichment_identify_song(filename: str): + """Identify an EXISTING library song by AUDIO FINGERPRINT — the library-side + counterpart to /api/enrichment/identify (which takes an upload). Fingerprints + the song's own master audio on disk (the manual "Identify by audio" action in + the Fix-metadata / match-review flow). Same candidate shape as /search, so the + review UI renders fingerprint hits like text hits. Same 412/503 gating; 404 + when the song has no full-mix audio to fingerprint.""" + gate = enrichment._acoustid_gate() + if gate is not None: + return gate + audio = enrichment._song_audio_file(filename) + if not audio: + return JSONResponse( + {"error": "no audio", + "detail": "couldn't find this song's master audio to fingerprint " + "(a stems-only pack has no full mix to identify)."}, + status_code=404) + try: + cands = enrichment._identify_by_fingerprint(audio) + except enrichment.EnrichTransportError as e: + return JSONResponse({"error": "acoustid unavailable", "detail": str(e)}, + status_code=503) + return {"candidates": cands} diff --git a/server.py b/server.py index 5372eef..0cf42fa 100644 --- a/server.py +++ b/server.py @@ -18,7 +18,7 @@ configure_logging() log = logging.getLogger("feedBack.server") -from fastapi import Body, FastAPI, UploadFile, File, HTTPException +from fastapi import FastAPI, File from fastapi.staticfiles import StaticFiles from fastapi.responses import FileResponse, JSONResponse, StreamingResponse @@ -52,12 +52,12 @@ from routers import art as art_router from routers import settings as settings_router from routers import song as song_router from routers import library as library_router +from routers import enrichment as enrichment_routes import sloppak as sloppak_mod import loosefolder as loosefolder_mod # Pure text-matching engine for MusicBrainz enrichment (P8): denoise/score/ # tier classification + response parsing. No network/DB in there — the # throttled transport and the song_enrichment writes live in this module. -import mb_match # Metadata extraction lives in a side-effect-free module so ProcessPool # scan workers can import + unpickle _scan_one without re-running this # module's import-time side effects (see lib/scan_worker.py). @@ -1802,325 +1802,38 @@ def scan_status(): return _scan_status -@app.get("/api/enrichment/status") -def enrichment_status(): - """Enrichment pipeline state: worker flags + row counts by match_state. - Ambient tool-state for the match-review UI (never a home-screen score — - design §11); also what tests poke.""" - return { - "running": enrichment._enrich_status["running"], - "processed": enrichment._enrich_status["processed"], - "last_pass_at": enrichment._enrich_status["last_pass_at"], - "states": meta_db.enrichment_state_counts(), - "total_songs": meta_db.count(), - # Per-pass matching progress for the "Refresh Metadata" batch bar + - # per-tile badges (total = songs queued to match this pass, matched = - # done so far, current = the one being matched now). - "total": enrichment._enrich_status.get("total", 0), - "matched": enrichment._enrich_status.get("matched", 0), - "current": enrichment._enrich_status.get("current"), - "cancelling": enrichment._enrich_cancel.is_set(), - } +# ── Enrichment routes → routers/enrichment.py (R3) ────────────────────────── +app.include_router(enrichment_routes.router) -@app.get("/api/enrichment/song/{filename:path}") -def api_enrichment_song(filename: str): - """Read-only per-song match provenance for the Details drawer (launch - polish): which canonical identity this chart matched and how. A tiny - projection of the cache row — no candidates, no cache paths.""" - row = meta_db.get_enrichment(filename) - if not row: - raise HTTPException(status_code=404, detail="no enrichment row") - return {k: row.get(k) for k in - ("match_state", "canon_artist", "canon_title", - "match_source", "match_score")} -@app.post("/api/enrichment/kick") -def api_enrichment_kick(): - """The Settings "Match now" button AND the library's "Refresh Metadata" - button: request an enrichment pass without waiting for a scan to complete. - Processes the songs that still need it (unscanned/changed + retriable - failures) — already-matched songs are left alone, so on a fully-matched - library this is a fast no-op. Single-flight + coalescing like every other - kick — spamming it queues at most one follow-up pass.""" - return {"started": enrichment._kick_enrich()} -@app.post("/api/enrichment/cancel") -def api_enrichment_cancel(): - """Stop button on the "Refresh Metadata" batch: signal the running pass to - halt after the current song (an in-flight ≤1/s lookup can't be interrupted, - but no new one is started) and drop any coalesced follow-up. A no-op when - nothing is running.""" - was_running = enrichment._enrich_status["running"] - if was_running: - enrichment._enrich_cancel.set() - return {"ok": True, "was_running": was_running} -@app.post("/api/enrichment/rematch") -def api_enrichment_rematch(data: dict = Body(...)): - """The library "Refresh Metadata" button: force a fresh re-match of the - songs the grid is SHOWING (its visible/filtered window). Resets each to - `unscanned` so the next pass re-fetches it from scratch — EXCEPT user-pinned - `manual` rows, which are never auto-overwritten (apply_enrichment_match - guards that) — then kicks one pass. Scoped to the visible set on purpose: - fast (dozens of songs), visible (tiles animate), and it can't blow the whole - ≤1/s rate budget on a 1000-song library the way a full re-sweep would. - Returns the filenames actually queued so the UI badges exactly those.""" - raw = (data or {}).get("filenames") or [] - fns = [str(f) for f in raw if isinstance(f, str)][:500] - queued: list[str] = [] - for fn in fns: - song = meta_db.enrichment_song_row(fn) - if not song: - continue - h = meta_db.enrichment_content_hash( - song["artist"], song["title"], song["album"], song["duration"]) - # allow_manual_overwrite=False → a manual pin is left as-is (returns - # False), everything else resets to unscanned (returns True). - if meta_db.apply_enrichment_match(fn, h, "unscanned", - allow_manual_overwrite=False): - queued.append(fn) - started = enrichment._kick_enrich() if queued else False - return {"queued": queued, "count": len(queued), "started": started} -@app.post("/api/enrichment/states") -def api_enrichment_states(data: dict = Body(...)): - """Per-tile match states for the grid's VISIBLE window during a metadata - refresh: the client posts the filenames it is showing and gets back each - one's match_state (+ the song being matched right now, + whether a pass is - running), so a card can animate queued→working→result without a per-song - round-trip. Read-only — safe for demo visitors (no network, no mutation).""" - raw = (data or {}).get("filenames") or [] - # Bound the batch: a visible grid window is dozens of cards; cap defensively. - fns = [str(f) for f in raw if isinstance(f, str)][:500] - return { - "states": meta_db.enrichment_states_for(fns), - "current": enrichment._enrich_status.get("current"), - "running": enrichment._enrich_status["running"], - } -@app.post("/api/enrichment/refresh/{filename:path}") -def api_enrichment_refresh(filename: str): - """The context menu's "Refresh metadata": reset THIS song's match to - unscanned (canonical values + candidates cleared, backoff zeroed) and - kick a pass so it re-matches immediately. An EXPLICIT user action, so it - may discard a manual pin — the automation never does, but the user - asking for a re-match is the one party who owns that pin.""" - song = meta_db.enrichment_song_row(filename) - if not song: - raise HTTPException(status_code=404, detail="unknown song") - h = meta_db.enrichment_content_hash( - song["artist"], song["title"], song["album"], song["duration"]) - meta_db.apply_enrichment_match(filename, h, "unscanned", - allow_manual_overwrite=True) - return {"ok": True, "started": enrichment._kick_enrich()} -@app.get("/api/enrichment/review") -def api_enrichment_review(limit: int = 200): - """The Match-Review queue: songs whose text match landed in the medium- - confidence review tier, each with its stored candidate list — the drawer - renders straight from this, no MusicBrainz round-trip. Ordered by the - user's enrich_review_order setting.""" - limit = max(1, min(int(limit), 500)) - cfg = _load_config(CONFIG_DIR / "config.json") or {} - order = cfg.get("enrich_review_order", "missing_first") - return { - "songs": meta_db.enrichment_review_queue(limit=limit, order=order), - "total_review": meta_db.enrichment_state_counts().get("review", 0), - } -@app.post("/api/enrichment/review/{filename:path}/accept") -def api_enrichment_accept(filename: str, data: dict = Body(...)): - """Accept one of the stored review candidates: the row becomes a - user-pinned `manual` match (never auto-reset). Display-only, like every - enrichment write — nothing touches the pack file.""" - recording_id = str((data or {}).get("recording_id") or "") - row = meta_db.get_enrichment(filename) - if not row or row["match_state"] != "review": - raise HTTPException(status_code=404, detail="no review row for this song") - cand = next((c for c in (row.get("candidates") or []) - if c.get("recording_id") == recording_id), None) - if not cand: - raise HTTPException(status_code=404, detail="candidate not in the stored list") - if not meta_db.set_enrichment_manual(filename, cand, source="review"): - raise HTTPException(status_code=404, detail="unknown song") - return {"ok": True, "enrichment": meta_db.get_enrichment(filename)} -@app.post("/api/enrichment/review/{filename:path}/reject") -def api_enrichment_reject(filename: str): - """"None of these" — clears any canonical values and parks the row as - failed/rejected (never auto-retried; editing the song's metadata - re-queues it). Valid from `review` or `matched`, never from `manual`.""" - if not meta_db.set_enrichment_rejected(filename): - raise HTTPException(status_code=404, detail="no rejectable match for this song") - return {"ok": True, "enrichment": meta_db.get_enrichment(filename)} -# The candidate fields a manual pick is allowed to carry — the payload comes -# from our own /api/enrichment/search proxy, but the route re-sanitizes so a -# hand-rolled client can't stuff arbitrary keys/types into the cache row. -_CAND_STR_FIELDS = ("recording_id", "title", "artist", "artist_id", - "artist_sort", "release_id", "album", "year", "isrc") -def _sanitize_candidate(raw: dict) -> dict | None: - if not isinstance(raw, dict): - return None - out = {k: str(raw.get(k) or "") for k in _CAND_STR_FIELDS} - if not out["recording_id"] or not out["title"]: - return None - genres = raw.get("genres") or [] - out["genres"] = [str(g) for g in genres if isinstance(g, str)][:5] \ - if isinstance(genres, list) else [] - return out -@app.post("/api/enrichment/review/{filename:path}/pick") -def api_enrichment_pick(filename: str, data: dict = Body(...)): - """Fix-match / manual search-and-pick: pin a candidate the user found via - /api/enrichment/search (not limited to the stored review list — this is - the escape hatch for a wrong auto-match too). Sets `manual`, the - highest-authority state.""" - cand = _sanitize_candidate((data or {}).get("candidate")) - if not cand: - raise HTTPException(status_code=400, detail="candidate needs recording_id + title") - if not meta_db.set_enrichment_manual(filename, cand, source="search"): - raise HTTPException(status_code=404, detail="unknown song") - return {"ok": True, "enrichment": meta_db.get_enrichment(filename)} -@app.get("/api/enrichment/search") -def api_enrichment_search(artist: str = "", title: str = "", limit: int = 8, - filename: str = "", duration: float = 0.0): - """Manual-search proxy to MusicBrainz (throttled + identified like the - background matcher — a user typing in the drawer must not sidestep the - rate limit). `filename` optionally scores results against that song's - stored identity (year/duration corroboration) instead of just the typed - text. `duration` (seconds) lets a caller that HAS the audio but no library - row — e.g. the editor's create modal, which holds the master track — pass - its length so the studio take ranks above live/extended cuts. Sync route on - purpose: FastAPI runs it in the threadpool, so the throttle's sleep never - blocks the event loop.""" - if not (artist.strip() or title.strip()): - raise HTTPException(status_code=400, detail="artist or title required") - limit = max(1, min(int(limit), 25)) - try: - cands = enrichment._mb_search_recordings(artist, title, limit=limit) - except enrichment.EnrichTransportError as e: - return JSONResponse({"error": "musicbrainz unavailable", "detail": str(e)}, - status_code=503) - ref = None - if filename: - ref = meta_db.enrichment_song_row(filename) - if ref is None: - ref = {"artist": artist, "title": title} - # A caller-supplied duration corroborates the take even without a library row. - if duration and duration > 0 and not ref.get("duration"): - ref = dict(ref) - ref["duration"] = duration - # Alias-enrich so a non-Latin-primary artist (大橋純子) ranks by its - # romanized alias against the typed query ("Junko Ohashi") instead of - # sinking to the bottom with a 0 artist score. - try: - enrichment._alias_enrich(ref, cands) - except enrichment.EnrichTransportError: - pass # aliases are a ranking nicety here; fall back to primary-name scoring - return {"candidates": mb_match.rank_candidates(ref, cands)} -@app.post("/api/enrichment/identify") -async def api_enrichment_identify(request: Request): - """Identify a song by AUDIO FINGERPRINT (AcoustID) rather than text — the - reliable way to get the EXACT recording/version (the studio take, not a live - bootleg or an extended cut). Upload the master audio; returns candidates in - the same shape as /search, so the review UI and the editor's Match popup can - render fingerprint hits identically. 412 `needs_setup` when the user hasn't - opted in / has no key (the UI nudges them to Settings); 503 when it's set up - but the fpcalc Chromaprint binary is missing or the network is off. Async so - the multipart is size-capped BEFORE spooling; the blocking fpcalc subprocess - + AcoustID HTTP run in the threadpool via run_in_executor.""" - gate = enrichment._acoustid_gate() - if gate is not None: - return gate - # Pre-parse Content-Length guard — reject an oversized body before Starlette - # spools the multipart to temp disk (mirrors the song-upload endpoint). The - # per-part cap below is the authoritative limit; this is the fast up-front no. - cl = request.headers.get("content-length") - if cl is not None: - try: - cl_int = int(cl) - except ValueError: - return JSONResponse({"error": "Invalid Content-Length header"}, status_code=400) - if cl_int > enrichment._ACOUSTID_MAX_UPLOAD_BYTES + enrichment._MULTIPART_OVERHEAD_SLACK: - return JSONResponse({"error": "audio upload too large (256 MB max)"}, status_code=413) - try: - form = await request.form(max_part_size=enrichment._ACOUSTID_MAX_UPLOAD_BYTES) - except Exception: - return JSONResponse({"error": "audio upload too large (256 MB max)"}, status_code=413) - file = form.get("file") - if not isinstance(file, UploadFile): - raise HTTPException(status_code=400, detail="missing file upload") - import tempfile - ext = (Path(file.filename or "").suffix or ".bin").lower() - tmpdir = tempfile.mkdtemp(prefix="feedback_acoustid_") - tmp = os.path.join(tmpdir, "audio" + ext) - try: - total = 0 - with open(tmp, "wb") as fh: - while True: - chunk = await file.read(1024 * 1024) - if not chunk: - break - total += len(chunk) - if total > enrichment._ACOUSTID_MAX_UPLOAD_BYTES: - return JSONResponse( - {"error": "audio upload too large (256 MB max)"}, status_code=413) - fh.write(chunk) - if total == 0: - raise HTTPException(status_code=400, detail="empty upload") - # fpcalc subprocess + AcoustID HTTP are blocking — off the event loop. - cands = await asyncio.get_event_loop().run_in_executor( - None, enrichment._identify_by_fingerprint, tmp) - except enrichment.EnrichTransportError as e: - return JSONResponse({"error": "acoustid unavailable", "detail": str(e)}, - status_code=503) - finally: - shutil.rmtree(tmpdir, ignore_errors=True) - return {"candidates": cands} -@app.post("/api/enrichment/identify/{filename:path}") -def api_enrichment_identify_song(filename: str): - """Identify an EXISTING library song by AUDIO FINGERPRINT — the library-side - counterpart to /api/enrichment/identify (which takes an upload). Fingerprints - the song's own master audio on disk (the manual "Identify by audio" action in - the Fix-metadata / match-review flow). Same candidate shape as /search, so the - review UI renders fingerprint hits like text hits. Same 412/503 gating; 404 - when the song has no full-mix audio to fingerprint.""" - gate = enrichment._acoustid_gate() - if gate is not None: - return gate - audio = enrichment._song_audio_file(filename) - if not audio: - return JSONResponse( - {"error": "no audio", - "detail": "couldn't find this song's master audio to fingerprint " - "(a stems-only pack has no full mix to identify)."}, - status_code=404) - try: - cands = enrichment._identify_by_fingerprint(audio) - except enrichment.EnrichTransportError as e: - return JSONResponse({"error": "acoustid unavailable", "detail": str(e)}, - status_code=503) - return {"candidates": cands} @app.get("/api/startup-status")