feat(acoustid): pre-parse upload guard + settings UI to enable it

- /api/enrichment/identify is now async: a pre-parse Content-Length check +
  request.form(max_part_size=…) reject an oversized body BEFORE Starlette spools
  the multipart to temp disk (mirrors the song-upload endpoint), and the blocking
  fpcalc subprocess + AcoustID HTTP run off the event loop via run_in_executor.
- The v3 Metadata-matching settings card gains an 'Identify by audio' opt-in
  toggle (acoustid_enabled, default OFF) + an AcoustID key input
  (acoustid_api_key), wired in match-review.js — so the advertised feature is
  reachable from the UI instead of only via a manual settings POST. Reuses
  existing classes only; committed tailwind.min.css stays fresh.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Claude Opus 4.8 (1M context)
2026-07-05 00:02:25 +02:00
parent c401423a37
commit ad6d3636cd
3 changed files with 40 additions and 11 deletions
+28 -11
View File
@@ -7616,41 +7616,58 @@ def api_enrichment_search(artist: str = "", title: str = "", limit: int = 8,
@app.post("/api/enrichment/identify") @app.post("/api/enrichment/identify")
def api_enrichment_identify(file: UploadFile = File(...)): async def api_enrichment_identify(request: Request):
"""Identify a song by AUDIO FINGERPRINT (AcoustID) rather than text — the """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 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 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 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 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 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. Async so
route: the fpcalc subprocess + HTTP run in FastAPI's threadpool.""" the multipart is size-capped BEFORE spooling; the blocking fpcalc subprocess
+ AcoustID HTTP run in the threadpool via run_in_executor."""
gate = _acoustid_gate() gate = _acoustid_gate()
if gate is not None: if gate is not None:
return gate 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 > _ACOUSTID_MAX_UPLOAD_BYTES + _MULTIPART_OVERHEAD_SLACK:
return JSONResponse({"error": "audio upload too large (256 MB max)"}, status_code=413)
try:
form = await request.form(max_part_size=_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 import tempfile
ext = (Path(file.filename or "").suffix or ".bin").lower() ext = (Path(file.filename or "").suffix or ".bin").lower()
tmpdir = tempfile.mkdtemp(prefix="feedback_acoustid_") tmpdir = tempfile.mkdtemp(prefix="feedback_acoustid_")
tmp = os.path.join(tmpdir, "audio" + ext) tmp = os.path.join(tmpdir, "audio" + ext)
try: try:
# Stream the upload to disk with a cap instead of reading it all into
# memory — an oversized upload must not balloon RAM (fpcalc reads from
# the temp file anyway). Generous ceiling for an uncompressed master.
total = 0 total = 0
with open(tmp, "wb") as fh: with open(tmp, "wb") as fh:
while True: while True:
chunk = file.file.read(1024 * 1024) chunk = await file.read(1024 * 1024)
if not chunk: if not chunk:
break break
total += len(chunk) total += len(chunk)
if total > _ACOUSTID_MAX_UPLOAD_BYTES: if total > _ACOUSTID_MAX_UPLOAD_BYTES:
raise HTTPException( return JSONResponse(
status_code=413, {"error": "audio upload too large (256 MB max)"}, status_code=413)
detail="audio upload too large (256 MB max)")
fh.write(chunk) fh.write(chunk)
if total == 0: if total == 0:
raise HTTPException(status_code=400, detail="empty upload") raise HTTPException(status_code=400, detail="empty upload")
cands = _identify_by_fingerprint(tmp) # fpcalc subprocess + AcoustID HTTP are blocking — off the event loop.
cands = await asyncio.get_event_loop().run_in_executor(
None, _identify_by_fingerprint, tmp)
except EnrichTransportError as e: except EnrichTransportError as e:
return JSONResponse({"error": "acoustid unavailable", "detail": str(e)}, return JSONResponse({"error": "acoustid unavailable", "detail": str(e)},
status_code=503) status_code=503)
+7
View File
@@ -771,6 +771,13 @@
</div> </div>
<div class="text-[11px] text-gray-600 mt-1">What a confident match may fill in on its own — matches you confirm in the review queue always apply in full.</div> <div class="text-[11px] text-gray-600 mt-1">What a confident match may fill in on its own — matches you confirm in the review queue always apply in full.</div>
</div> </div>
<!-- Audio fingerprint (AcoustID) — opt-in, default OFF. Wired by match-review.js. -->
<div class="fb-srow-wide mb-1">
<div class="text-[10px] uppercase tracking-wide text-gray-500 mb-1">Audio fingerprint (AcoustID)</div>
<label class="flex items-center gap-2 text-xs text-gray-400 mb-1"><input type="checkbox" id="acoustid-enabled" class="rounded border-gray-600 bg-dark-700 text-accent"> Identify by audio — reads the recording itself for the exact version (studio vs live/extended)</label>
<input type="text" id="acoustid-api-key" placeholder="AcoustID application key" class="w-full bg-dark-700 border border-gray-800 rounded-xl px-2 py-1.5 text-xs text-gray-300 outline-none">
<div class="text-[11px] text-gray-600 mt-1">Opt-in. Get a free key at acoustid.org/new-application; the fpcalc (Chromaprint) binary must be on the server's PATH.</div>
</div>
<div class="grid grid-cols-2 gap-2 mb-1 text-xs text-gray-400 fb-srow-wide"> <div class="grid grid-cols-2 gap-2 mb-1 text-xs text-gray-400 fb-srow-wide">
<label class="flex items-center gap-2">Review queue order <label class="flex items-center gap-2">Review queue order
<select id="enrich-review-order" class="bg-dark-700 border border-gray-800 rounded-xl px-2 py-1.5 text-xs text-gray-300 outline-none"> <select id="enrich-review-order" class="bg-dark-700 border border-gray-800 rounded-xl px-2 py-1.5 text-xs text-gray-300 outline-none">
+5
View File
@@ -531,7 +531,10 @@
// opt-IN per the dev-chat thread. // opt-IN per the dev-chat thread.
const optInToggles = [ const optInToggles = [
['artist-external-links', 'artist_external_links'], ['artist-external-links', 'artist_external_links'],
// Audio fingerprinting is opt-in (needs a key + fpcalc), default OFF.
['acoustid-enabled', 'acoustid_enabled'],
].map(([id, key]) => [document.getElementById(id), key]).filter(([el]) => el); ].map(([id, key]) => [document.getElementById(id), key]).filter(([el]) => el);
const acoustidKeyEl = document.getElementById('acoustid-api-key');
if (!toggles.length && !optInToggles.length && !sel && !btn) return; if (!toggles.length && !optInToggles.length && !sel && !btn) return;
(async () => { (async () => {
try { try {
@@ -540,6 +543,7 @@
const cfg = await r.json(); const cfg = await r.json();
for (const [el, key] of toggles) el.checked = cfg[key] !== false; for (const [el, key] of toggles) el.checked = cfg[key] !== false;
for (const [el, key] of optInToggles) el.checked = cfg[key] === true; for (const [el, key] of optInToggles) el.checked = cfg[key] === true;
if (acoustidKeyEl) acoustidKeyEl.value = cfg.acoustid_api_key || '';
if (sel) { if (sel) {
const t = Number(cfg.enrich_auto_threshold); const t = Number(cfg.enrich_auto_threshold);
const want = Number.isFinite(t) ? t : 0.9; const want = Number.isFinite(t) ? t : 0.9;
@@ -564,6 +568,7 @@
} }
sel?.addEventListener('change', () => save('enrich_auto_threshold', Number(sel.value))); sel?.addEventListener('change', () => save('enrich_auto_threshold', Number(sel.value)));
order?.addEventListener('change', () => save('enrich_review_order', order.value)); order?.addEventListener('change', () => save('enrich_review_order', order.value));
acoustidKeyEl?.addEventListener('change', () => save('acoustid_api_key', acoustidKeyEl.value.trim()));
btn?.addEventListener('click', async () => { btn?.addEventListener('click', async () => {
await post('/api/enrichment/kick'); await post('/api/enrichment/kick');
const line = document.getElementById('enrich-status'); const line = document.getElementById('enrich-status');