diff --git a/server.py b/server.py index 3459e25..953c25a 100644 --- a/server.py +++ b/server.py @@ -242,6 +242,11 @@ _DEMO_BLOCKED: list[tuple[str, re.Pattern]] = [ ("POST", re.compile(r"^/api/enrichment/review/.+$")), ("POST", re.compile(r"^/api/enrichment/kick$")), ("GET", re.compile(r"^/api/enrichment/search$")), + # AcoustID audio fingerprinting: both identify endpoints run fpcalc (CPU) + # and spend the shared AcoustID rate budget on the caller's behalf — same + # rule as the search/kick relays above; not for anonymous demo visitors. + ("POST", re.compile(r"^/api/enrichment/identify$")), + ("POST", re.compile(r"^/api/enrichment/identify/.+$")), # Context menus (R2): the per-song re-match mutates the cache + spends # rate limit; Get-info exposes filesystem paths. ("POST", re.compile(r"^/api/enrichment/refresh/.+$")), @@ -6356,6 +6361,65 @@ def _identify_by_fingerprint(path: str) -> list[dict]: return _acoustid_lookup(fp[0], fp[1]) +def _acoustid_gate() -> "JSONResponse | None": + """Shared availability gate for the identify endpoints: None when ready, + else a 412 needs_setup (opt-in off / no key → the UI re-prompts) or a 503 + (set up but fpcalc/network missing). Never lets a caller pretend a + fingerprint ran.""" + if _acoustid_available(): + return None + enabled, key = _acoustid_settings() + if not enabled or not key: + return JSONResponse( + {"error": "audio fingerprinting not set up", "needs_setup": True, + "detail": "Turn on AcoustID and add a free API key to identify by audio — " + "it reads the recording itself, far more reliable than text search."}, + status_code=412) + return JSONResponse( + {"error": "audio fingerprinting unavailable", "needs_setup": False, + "detail": "the fpcalc (Chromaprint) binary was not found on the server"}, + status_code=503) + + +def _song_audio_file(filename: str) -> "str | None": + """Resolve a LIBRARY song (by filename/id) to a local master-audio file for + fingerprinting: the full-mix `original_audio` extracted from a sloppak, or a + loose folder's audio. None when the song can't be found or ships no full-mix + audio (some packs carry only stems). Mirrors serve_sloppak_file's containment + guards so a crafted filename can't read outside DLC_DIR / the pack.""" + dlc = _get_dlc_dir() + if not dlc: + return None + resolved = _resolve_dlc_path(dlc, filename) + if resolved is None or not resolved.exists(): + return None + if sloppak_mod.is_sloppak(resolved): + try: + canon = resolved.relative_to(dlc.resolve()).as_posix() + except ValueError: + return None + rel = (sloppak_mod.load_manifest(resolved) or {}).get("original_audio") + if not isinstance(rel, str) or not rel.strip(): + return None + src = sloppak_mod.get_cached_source_dir(canon) + if src is None: + try: + src = sloppak_mod.resolve_source_dir(canon, dlc, SLOPPAK_CACHE_DIR) + except Exception: + return None + target = (src / rel.strip()).resolve() + try: + target.relative_to(src.resolve()) + except ValueError: + return None + return str(target) if target.is_file() else None + try: + audio = loosefolder_mod.find_audio(resolved) + except Exception: + audio = None + return str(audio) if audio and Path(str(audio)).is_file() else None + + def _mb_lookup_recording(mbid: str) -> dict | None: """Direct lookup for a manifest-carried recording MBID (tier 0).""" body = _mb_http_get( @@ -7558,25 +7622,9 @@ def api_enrichment_identify(file: UploadFile = File(...)): 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. Sync route: the fpcalc subprocess + HTTP run in FastAPI's threadpool.""" - if not _acoustid_available(): - enabled, key = _acoustid_settings() - # Separate "hasn't set it up" (opt-in off or no key → the inline enable - # nudge) from "configured but the binary/network is missing" (a real - # unavailability). Honesty: never pretend a fingerprint ran. - if not enabled or not key: - return JSONResponse( - {"error": "audio fingerprinting not set up", - "needs_setup": True, - "detail": "Turn on AcoustID and add a free API key to identify " - "by audio — it reads the recording itself, far more " - "reliable than text search."}, - status_code=412) - return JSONResponse( - {"error": "audio fingerprinting unavailable", - "needs_setup": False, - "detail": "the fpcalc (Chromaprint) binary was not found on the " - "server"}, - status_code=503) + gate = _acoustid_gate() + if gate is not None: + return gate content = file.file.read() if not content: raise HTTPException(status_code=400, detail="empty upload") @@ -7596,6 +7644,32 @@ def api_enrichment_identify(file: UploadFile = File(...)): 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 = _acoustid_gate() + if gate is not None: + return gate + audio = _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 = _identify_by_fingerprint(audio) + except EnrichTransportError as e: + return JSONResponse({"error": "acoustid unavailable", "detail": str(e)}, + status_code=503) + return {"candidates": cands} + + @app.get("/api/startup-status") def startup_status(): return _get_startup_status() diff --git a/static/v3/match-review.js b/static/v3/match-review.js index 1ecfe68..06fac3e 100644 --- a/static/v3/match-review.js +++ b/static/v3/match-review.js @@ -346,7 +346,8 @@ '
' + '
' + (_single ? '' : '') + - '
' + + '' + + '
' + '
' + (_single ? '' : '') + ((song.candidates || []).length ? '' : '') + @@ -395,6 +396,7 @@ const go = () => runSearch(panel, song); panel.querySelector('[data-mr-search-go]')?.addEventListener('click', go); input?.addEventListener('keydown', (e) => { if (e.key === 'Enter') { e.preventDefault(); go(); } }); + panel.querySelector('[data-mr-identify]')?.addEventListener('click', () => runIdentify(panel, song)); } // Silent-on-success: the chart just leaves the queue and the next one @@ -447,6 +449,52 @@ }); } + // "Identify by audio" — fingerprint the song's OWN master audio (AcoustID) + // and render the hits into the same search-results area. The reliable path + // when text search can't tell the studio take from live/comp versions. + async function runIdentify(panel, song) { + const out = panel.querySelector('[data-mr-search-results]'); + const sp = panel.querySelector('[data-mr-search-panel]'); + if (!out) return; + sp?.classList.remove('hidden'); // give the results somewhere to render + out.innerHTML = '

Fingerprinting audio…

'; + let body = null, status = 0; + try { + const r = await fetch('/api/enrichment/identify/' + enc(song.filename), { method: 'POST' }); + status = r.status; + body = await r.json().catch(() => null); + } catch (_) { /* falls through to the no-results line */ } + // Honest states — never a fake hit. + if (status === 412 || (body && body.needs_setup)) { + out.innerHTML = '

Audio identification is off — enable AcoustID and add a free API key to use it.

'; + return; + } + if (status === 404) { + out.innerHTML = "

No full-mix audio to fingerprint for this song.

"; + return; + } + if (status === 503) { + out.innerHTML = '

Audio identification is unavailable right now — try again.

'; + return; + } + const cands = (body && body.candidates) || []; + if (!cands.length) { + out.innerHTML = '

No fingerprint match — try text search.

'; + return; + } + out.innerHTML = '
Fingerprint matches (AcoustID)
' + + cands.map((c, i) => candRowHtml(song, c, i, false)).join(''); + out.querySelectorAll('[data-mr-cand]').forEach((btn) => { + btn.addEventListener('click', async () => { + const cand = cands[Number(btn.getAttribute('data-mr-cand'))]; + if (!cand) return; + await post('/api/enrichment/review/' + enc(song.filename) + '/pick', + { candidate: cand }); + settle(song); + }); + }); + } + async function post(url, payload) { try { await fetch(url, {