Files
kino-app/backend/acoustid.py
T
Myron Blair 671074764e Add iTunes and AcoustID as additional free music enrichment sources
- itunes.py: zero-auth fallback for album artwork when MusicBrainz/Cover Art
  Archive has no match — kicks in automatically during music enrichment,
  toggleable in Settings (on by default, no key required).
- acoustid.py: audio fingerprint identification (Chromaprint fpcalc + the
  AcoustID API) for tracks with missing/unknown artist or album — nothing
  to text-search on, so this identifies from the actual audio content
  instead. Requires a free user-supplied API key from acoustid.org/api-key,
  off by default. New /music/identify endpoint + admin trigger button.
- Dockerfile: added libchromaprint-tools for fpcalc.
- Settings page: new Music Metadata & Artwork section with both toggles.
2026-07-27 21:03:59 -05:00

84 lines
2.9 KiB
Python

"""AcoustID audio fingerprint identification — free, but requires:
1. A free API key from https://acoustid.org/api-key (one signup, no cost)
2. The `fpcalc` binary (Chromaprint) to compute a fingerprint from the actual audio file
Unlike musicbrainz.py/itunes.py (which match on artist+album text), this identifies a track
from its audio content — useful for files with missing/wrong tags where there's nothing
usable to text-search on.
"""
import asyncio
import json
import logging
import subprocess
from pathlib import Path
from typing import Optional, Dict
import requests
logger = logging.getLogger("kino.acoustid")
ACOUSTID_BASE = "https://api.acoustid.org/v2/lookup"
def fingerprint_file(path: Path) -> Optional[Dict]:
"""Runs fpcalc on the file; returns {"duration": float, "fingerprint": str} or None."""
try:
proc = subprocess.run(
["fpcalc", "-json", str(path)],
capture_output=True, timeout=60, text=True,
)
if proc.returncode != 0:
return None
data = json.loads(proc.stdout)
if not data.get("fingerprint"):
return None
return {"duration": data.get("duration"), "fingerprint": data["fingerprint"]}
except FileNotFoundError:
logger.warning("fpcalc not installed — AcoustID identification unavailable")
return None
except Exception:
return None
def lookup_recording(api_key: str, duration: float, fingerprint: str) -> Optional[Dict]:
"""Identifies a recording from its fingerprint. Returns best-match title/artist/album."""
try:
r = requests.get(ACOUSTID_BASE, params={
"client": api_key,
"duration": int(duration or 0),
"fingerprint": fingerprint,
"meta": "recordings+releasegroups",
}, timeout=15)
r.raise_for_status()
data = r.json() or {}
except Exception:
return None
if data.get("status") != "ok":
return None
results = sorted(data.get("results") or [], key=lambda x: x.get("score", 0), reverse=True)
for res in results:
recordings = res.get("recordings") or []
if not recordings:
continue
rec = recordings[0]
releasegroups = rec.get("releasegroups") or []
artist = (rec.get("artists") or [{}])[0].get("name")
title = rec.get("title")
if not (artist and title):
continue
return {
"title": title,
"artist": artist,
"album": releasegroups[0].get("title") if releasegroups else None,
"mbid": rec.get("id"),
}
return None
async def identify_track(api_key: str, path: Path) -> Optional[Dict]:
"""Fingerprint + lookup a single file. Runs the blocking pieces off the event loop."""
fp = await asyncio.to_thread(fingerprint_file, path)
if not fp:
return None
return await asyncio.to_thread(lookup_recording, api_key, fp["duration"], fp["fingerprint"])