mirror of
https://github.com/got-feedBack/feedBack.git
synced 2026-09-14 08:50:03 +00:00
feat(enrichment): make AcoustID self-serve — opt-in toggle + API key in settings
Fingerprinting was env-var only (ACOUSTID_API_KEY), so only an operator could enable it. Add two core settings so a user can turn it on themselves: - acoustid_enabled (bool, default OFF — opt-in) - acoustid_api_key (string, ≤128 chars, trimmed; env var stays a fallback) _acoustid_available()/_acoustid_lookup() now resolve (enabled, key) from settings via _acoustid_settings(). /api/enrichment/identify distinguishes "not set up" (412 needs_setup — the UI nudges the user to enable it) from "set up but fpcalc/network missing" (503) so the client never fakes a match. Verified: default off; POST round-trips + trims; 412 vs 503 gating; bad types/over-length rejected. acoustid_match unit tests green (8/8).
This commit is contained in:
@@ -6258,10 +6258,26 @@ def _fpcalc_bin() -> str | None:
|
|||||||
return shutil.which("fpcalc")
|
return shutil.which("fpcalc")
|
||||||
|
|
||||||
|
|
||||||
|
def _acoustid_settings() -> "tuple[bool, str]":
|
||||||
|
"""(enabled, api_key) for AcoustID, resolved from settings with an env-var
|
||||||
|
fallback for the key. Opt-in: `acoustid_enabled` defaults off. The key lives
|
||||||
|
in settings so a user can set it themselves in the UI; $ACOUSTID_API_KEY is a
|
||||||
|
server-wide fallback for a headless deploy."""
|
||||||
|
cfg = _load_config(CONFIG_DIR / "config.json") or {}
|
||||||
|
enabled = cfg.get("acoustid_enabled", False) is True
|
||||||
|
key = cfg.get("acoustid_api_key")
|
||||||
|
if not isinstance(key, str) or not key.strip():
|
||||||
|
key = os.environ.get("ACOUSTID_API_KEY", "")
|
||||||
|
return enabled, (key or "").strip()
|
||||||
|
|
||||||
|
|
||||||
def _acoustid_available() -> bool:
|
def _acoustid_available() -> bool:
|
||||||
"""True only when the network is on, an API key is set, AND fpcalc exists."""
|
"""True only when the user opted in, a key is set (settings or env), the
|
||||||
return (_enrich_network_enabled()
|
network is on, AND fpcalc exists."""
|
||||||
and acoustid_match.is_configured()
|
enabled, key = _acoustid_settings()
|
||||||
|
return (enabled
|
||||||
|
and _enrich_network_enabled()
|
||||||
|
and acoustid_match.is_configured(key)
|
||||||
and _fpcalc_bin() is not None)
|
and _fpcalc_bin() is not None)
|
||||||
|
|
||||||
|
|
||||||
@@ -6295,7 +6311,7 @@ def _acoustid_lookup(duration: int, fingerprint: str) -> list[dict]:
|
|||||||
"""Look a fingerprint up on AcoustID → candidate dicts (mb_match shape).
|
"""Look a fingerprint up on AcoustID → candidate dicts (mb_match shape).
|
||||||
Throttled + offline-guarded like the MusicBrainz path. [] when unavailable
|
Throttled + offline-guarded like the MusicBrainz path. [] when unavailable
|
||||||
or no hit; raises EnrichTransportError for network-shaped failures."""
|
or no hit; raises EnrichTransportError for network-shaped failures."""
|
||||||
key = acoustid_match.api_key()
|
_, key = _acoustid_settings()
|
||||||
if not key or not _enrich_network_enabled():
|
if not key or not _enrich_network_enabled():
|
||||||
return []
|
return []
|
||||||
import requests
|
import requests
|
||||||
@@ -7534,14 +7550,28 @@ def api_enrichment_identify(file: UploadFile = File(...)):
|
|||||||
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. 503 when fingerprinting isn't configured
|
render fingerprint hits identically. 412 `needs_setup` when the user hasn't
|
||||||
(needs the fpcalc Chromaprint binary + an ACOUSTID_API_KEY). Sync route: the
|
opted in / has no key (the UI nudges them to Settings); 503 when it's set up
|
||||||
fpcalc subprocess + HTTP run in FastAPI's threadpool."""
|
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():
|
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(
|
return JSONResponse(
|
||||||
{"error": "audio fingerprinting unavailable",
|
{"error": "audio fingerprinting unavailable",
|
||||||
"detail": "requires the fpcalc (Chromaprint) binary and an "
|
"needs_setup": False,
|
||||||
"ACOUSTID_API_KEY"},
|
"detail": "the fpcalc (Chromaprint) binary was not found on the "
|
||||||
|
"server"},
|
||||||
status_code=503)
|
status_code=503)
|
||||||
content = file.file.read()
|
content = file.file.read()
|
||||||
if not content:
|
if not content:
|
||||||
@@ -9908,6 +9938,17 @@ def _default_settings():
|
|||||||
# the external browser, never media delivered in-app.
|
# the external browser, never media delivered in-app.
|
||||||
"artist_pages_enabled": True,
|
"artist_pages_enabled": True,
|
||||||
"artist_external_links": False,
|
"artist_external_links": False,
|
||||||
|
# Audio fingerprinting (AcoustID + Chromaprint). OPT-IN, default OFF.
|
||||||
|
# Text matching (MusicBrainz) can't reliably pick the exact recording
|
||||||
|
# for a song with many comp/live/reissue takes (especially a
|
||||||
|
# non-title-track — the title can't find the album); fingerprinting
|
||||||
|
# reads the audio itself and resolves the EXACT recording. Needs the
|
||||||
|
# user's own free AcoustID application key
|
||||||
|
# (https://acoustid.org/new-application) plus the `fpcalc` binary. The
|
||||||
|
# key lives here (settings) — not only an env var — so a user can set it
|
||||||
|
# themselves in the UI; $ACOUSTID_API_KEY stays a server-wide fallback.
|
||||||
|
"acoustid_enabled": False,
|
||||||
|
"acoustid_api_key": "",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -10082,13 +10123,24 @@ def save_settings(data: dict):
|
|||||||
"enrich_apply_names", "enrich_apply_year",
|
"enrich_apply_names", "enrich_apply_year",
|
||||||
"enrich_apply_genres", "enrich_apply_art",
|
"enrich_apply_genres", "enrich_apply_art",
|
||||||
# Artist pages (PR-B): page on/off + external-links opt-in.
|
# Artist pages (PR-B): page on/off + external-links opt-in.
|
||||||
"artist_pages_enabled", "artist_external_links"):
|
"artist_pages_enabled", "artist_external_links",
|
||||||
|
# AcoustID audio-fingerprinting opt-in (default off).
|
||||||
|
"acoustid_enabled"):
|
||||||
if _bool_key in data:
|
if _bool_key in data:
|
||||||
raw = data[_bool_key]
|
raw = data[_bool_key]
|
||||||
if raw is not None:
|
if raw is not None:
|
||||||
if not isinstance(raw, bool):
|
if not isinstance(raw, bool):
|
||||||
return {"error": f"{_bool_key} must be a boolean"}
|
return {"error": f"{_bool_key} must be a boolean"}
|
||||||
updates[_bool_key] = raw
|
updates[_bool_key] = raw
|
||||||
|
if "acoustid_api_key" in data:
|
||||||
|
# Free AcoustID application key (opaque token). null is a no-op, empty
|
||||||
|
# string clears; length-capped so a bad POST can't bloat config.json.
|
||||||
|
# Never logged. The matcher trims + validates presence at read time.
|
||||||
|
raw = data["acoustid_api_key"]
|
||||||
|
if raw is not None:
|
||||||
|
if not isinstance(raw, str) or len(raw) > 128:
|
||||||
|
return {"error": "acoustid_api_key must be a string (at most 128 chars)"}
|
||||||
|
updates["acoustid_api_key"] = raw.strip()
|
||||||
if "enrich_review_order" in data:
|
if "enrich_review_order" in data:
|
||||||
raw = data["enrich_review_order"]
|
raw = data["enrich_review_order"]
|
||||||
if raw is not None:
|
if raw is not None:
|
||||||
|
|||||||
Reference in New Issue
Block a user