mirror of
https://github.com/got-feedBack/feedBack.git
synced 2026-09-13 08:29:28 +00:00
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:
@@ -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()
|
||||||
|
|||||||
@@ -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, {
|
||||||
|
|||||||
Reference in New Issue
Block a user