feat(acoustid): per-song "Identify by audio" for the library metadata tooling

Add POST /api/enrichment/identify/{filename} — fingerprints an EXISTING library
song's own master audio (resolves the sloppak's original_audio or a loose
folder's audio), the library counterpart to the upload-based /identify used by
the editor. Wire an "Identify by audio" action into the match-review / Fix-match
modal: it renders fingerprint hits in the same candidate list and pins the pick
via the existing /review/{f}/pick. Shared _acoustid_gate() (412 needs_setup /
503) for both endpoints; 404 when a pack has no full mix. Both identify routes
added to the demo-mode block list (they spend fpcalc + the AcoustID budget) —
fixes a pre-existing miss on the upload route.
This commit is contained in:
ChrisBeWithYou
2026-07-04 07:44:42 -05:00
parent b0d0554f2a
commit 2c8176f8a1
2 changed files with 142 additions and 20 deletions
+93 -19
View File
@@ -242,6 +242,11 @@ _DEMO_BLOCKED: list[tuple[str, re.Pattern]] = [
("POST", re.compile(r"^/api/enrichment/review/.+$")), ("POST", re.compile(r"^/api/enrichment/review/.+$")),
("POST", re.compile(r"^/api/enrichment/kick$")), ("POST", re.compile(r"^/api/enrichment/kick$")),
("GET", re.compile(r"^/api/enrichment/search$")), ("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 # Context menus (R2): the per-song re-match mutates the cache + spends
# rate limit; Get-info exposes filesystem paths. # rate limit; Get-info exposes filesystem paths.
("POST", re.compile(r"^/api/enrichment/refresh/.+$")), ("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]) 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: def _mb_lookup_recording(mbid: str) -> dict | None:
"""Direct lookup for a manifest-carried recording MBID (tier 0).""" """Direct lookup for a manifest-carried recording MBID (tier 0)."""
body = _mb_http_get( 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 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 but the fpcalc Chromaprint binary is missing or the network is off. Sync
route: the fpcalc subprocess + HTTP run in FastAPI's threadpool.""" route: the fpcalc subprocess + HTTP run in FastAPI's threadpool."""
if not _acoustid_available(): gate = _acoustid_gate()
enabled, key = _acoustid_settings() if gate is not None:
# Separate "hasn't set it up" (opt-in off or no key → the inline enable return gate
# 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)
content = file.file.read() content = file.file.read()
if not content: if not content:
raise HTTPException(status_code=400, detail="empty upload") raise HTTPException(status_code=400, detail="empty upload")
@@ -7596,6 +7644,32 @@ def api_enrichment_identify(file: UploadFile = File(...)):
return {"candidates": cands} 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") @app.get("/api/startup-status")
def startup_status(): def startup_status():
return _get_startup_status() return _get_startup_status()
+49 -1
View File
@@ -346,7 +346,8 @@
'<div class="flex items-center justify-between gap-3 p-5 pt-3 border-t border-fb-border/40 shrink-0">' + '<div class="flex items-center justify-between gap-3 p-5 pt-3 border-t border-fb-border/40 shrink-0">' +
'<div class="flex items-center gap-3">' + '<div class="flex items-center gap-3">' +
(_single ? '' : '<button data-mr-reject class="text-sm text-fb-textDim hover:text-fb-text">Not a match</button>') + (_single ? '' : '<button data-mr-reject class="text-sm text-fb-textDim hover:text-fb-text">Not a match</button>') +
'<button data-mr-search-toggle class="text-sm text-fb-textDim hover:text-fb-text">Search instead…</button></div>' + '<button data-mr-search-toggle class="text-sm text-fb-textDim hover:text-fb-text">Search instead…</button>' +
'<button data-mr-identify class="text-sm text-fb-primary hover:text-fb-primaryHi" title="Fingerprint this song\'s audio to find the exact recording">Identify by audio</button></div>' +
'<div class="flex items-center gap-2">' + '<div class="flex items-center gap-2">' +
(_single ? '' : '<button data-mr-skip class="text-sm text-fb-textDim hover:text-fb-text px-3 py-2">Skip</button>') + (_single ? '' : '<button data-mr-skip class="text-sm text-fb-textDim hover:text-fb-text px-3 py-2">Skip</button>') +
((song.candidates || []).length ? '<button data-mr-accept class="bg-fb-primary hover:bg-fb-primaryHi text-white px-4 py-2 rounded-md text-sm">Use selected</button>' : '') + ((song.candidates || []).length ? '<button data-mr-accept class="bg-fb-primary hover:bg-fb-primaryHi text-white px-4 py-2 rounded-md text-sm">Use selected</button>' : '') +
@@ -395,6 +396,7 @@
const go = () => runSearch(panel, song); const go = () => runSearch(panel, song);
panel.querySelector('[data-mr-search-go]')?.addEventListener('click', go); panel.querySelector('[data-mr-search-go]')?.addEventListener('click', go);
input?.addEventListener('keydown', (e) => { if (e.key === 'Enter') { e.preventDefault(); 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 // 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 = '<p class="text-xs text-fb-textDim">Fingerprinting audio…</p>';
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 = '<p class="text-xs text-fb-textDim">Audio identification is off — enable AcoustID and add a free API key to use it.</p>';
return;
}
if (status === 404) {
out.innerHTML = "<p class=\"text-xs text-fb-textDim\">No full-mix audio to fingerprint for this song.</p>";
return;
}
if (status === 503) {
out.innerHTML = '<p class="text-xs text-fb-textDim">Audio identification is unavailable right now — try again.</p>';
return;
}
const cands = (body && body.candidates) || [];
if (!cands.length) {
out.innerHTML = '<p class="text-xs text-fb-textDim">No fingerprint match — try text search.</p>';
return;
}
out.innerHTML = '<div class="text-xs font-semibold uppercase tracking-wider text-fb-textDim mb-1">Fingerprint matches (AcoustID)</div>' +
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) { async function post(url, payload) {
try { try {
await fetch(url, { await fetch(url, {