"""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"])