mirror of
https://github.com/myronblair/kino-app
synced 2026-07-28 05:23:45 -05:00
671074764e
- 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.
84 lines
2.9 KiB
Python
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"])
|