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.
This commit is contained in:
Myron Blair
2026-07-27 21:03:59 -05:00
parent 628a729afd
commit 671074764e
6 changed files with 299 additions and 7 deletions
+3 -1
View File
@@ -1,10 +1,12 @@
# Kino backend — FastAPI + ffmpeg
FROM python:3.11-slim
# ffmpeg for HLS transcoding; util-linux for `nice` (already in base but explicit)
# ffmpeg for HLS transcoding; util-linux for `nice` (already in base but explicit);
# libchromaprint-tools provides `fpcalc`, used for AcoustID audio fingerprinting.
RUN apt-get update && apt-get install -y --no-install-recommends \
ffmpeg \
curl \
libchromaprint-tools \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
+83
View File
@@ -0,0 +1,83 @@
"""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"])
+32
View File
@@ -0,0 +1,32 @@
"""iTunes Search API client — free, no API key, no auth, no rate-limit registration.
Used as a fallback when MusicBrainz + Cover Art Archive has no match/art for a release.
"""
import requests
from typing import Optional, Dict
ITUNES_BASE = "https://itunes.apple.com/search"
def lookup_album(artist: str, album: str) -> Optional[Dict]:
"""Best-effort match for an artist+album against the iTunes Store catalog."""
try:
r = requests.get(ITUNES_BASE, params={
"term": f"{artist} {album}", "media": "music", "entity": "album", "limit": 1,
}, timeout=15)
r.raise_for_status()
results = (r.json() or {}).get("results") or []
except Exception:
return None
if not results:
return None
res = results[0]
art = res.get("artworkUrl100")
if art:
# iTunes serves a small default; the same CDN path accepts arbitrary sizes.
art = art.replace("100x100bb", "1200x1200bb")
return {
"title": res.get("collectionName") or album,
"artist": res.get("artistName") or artist,
"year": (res.get("releaseDate") or "")[:4] or None,
"art_url": art,
}
+7
View File
@@ -225,6 +225,9 @@ class AppSettings(BaseModel):
trakt_client_secret: str = ""
auto_transcode: str = "off"
queue_paused: bool = False
music_itunes_fallback: bool = True
music_acoustid_enabled: bool = False
acoustid_api_key: str = ""
class AppSettingsPublic(BaseModel):
@@ -243,6 +246,10 @@ class AppSettingsPublic(BaseModel):
trakt_client_secret: str = ""
auto_transcode: str = "off"
queue_paused: bool = False
music_itunes_fallback: bool = True
music_acoustid_enabled: bool = False
acoustid_api_key: str = ""
acoustid_configured: bool = False
# ---------- Shows / Episodes (Sonarr) ----------
+82 -6
View File
@@ -38,6 +38,8 @@ import services as services_client
import library_scan
import library_cleanup
import musicbrainz as musicbrainz_client
import itunes as itunes_client
import acoustid as acoustid_client
import trakt as trakt_client
import opensubtitles as opensubs_client
from transcode import transcode_quick, transcode_abr, transcode_audio_fix, srt_to_vtt
@@ -1079,6 +1081,10 @@ async def read_settings(user: dict = Depends(require_admin)):
trakt_client_secret=s.get("trakt_client_secret", ""),
auto_transcode=s.get("auto_transcode", "off"),
queue_paused=bool(s.get("queue_paused", False)),
music_itunes_fallback=bool(s.get("music_itunes_fallback", True)),
music_acoustid_enabled=bool(s.get("music_acoustid_enabled", False)),
acoustid_api_key=s.get("acoustid_api_key", ""),
acoustid_configured=bool(s.get("acoustid_api_key")),
)
@@ -1103,6 +1109,10 @@ async def update_settings(payload: AppSettings, user: dict = Depends(require_adm
trakt_client_secret=doc.get("trakt_client_secret", ""),
auto_transcode=doc.get("auto_transcode", "off"),
queue_paused=bool(doc.get("queue_paused", False)),
music_itunes_fallback=bool(doc.get("music_itunes_fallback", True)),
music_acoustid_enabled=bool(doc.get("music_acoustid_enabled", False)),
acoustid_api_key=doc.get("acoustid_api_key", ""),
acoustid_configured=bool(doc.get("acoustid_api_key")),
)
@@ -1678,6 +1688,8 @@ _music_enrich_status = {"running": False, "total": 0, "done": 0, "updated": 0, "
async def _run_music_enrich():
_music_enrich_status.update(running=True, total=0, done=0, updated=0, error="")
try:
settings = await get_settings()
itunes_fallback = settings.get("music_itunes_fallback", True)
pairs = await db.tracks.aggregate([
{"$match": {"album_art_url": {"$in": [None, ""]}}},
{"$group": {"_id": {"artist": "$artist", "album": "$album"}}},
@@ -1686,15 +1698,22 @@ async def _run_music_enrich():
for p in pairs:
artist = p["_id"]["artist"]; album = p["_id"]["album"]
try:
art_url = None
release = await asyncio.to_thread(musicbrainz_client.lookup_release, artist, album)
if release and release.get("mbid"):
art_url = await asyncio.to_thread(musicbrainz_client.cover_art_url, release["mbid"])
if art_url:
res = await db.tracks.update_many(
{"artist": artist, "album": album, "album_art_url": {"$in": [None, ""]}},
{"$set": {"album_art_url": art_url}},
)
_music_enrich_status["updated"] += res.modified_count
if not art_url and itunes_fallback:
# MusicBrainz has no release, or CAA has no art for it — iTunes needs no
# key/rate-limit pacing, so it's a free second attempt before giving up.
itunes_match = await asyncio.to_thread(itunes_client.lookup_album, artist, album)
if itunes_match and itunes_match.get("art_url"):
art_url = itunes_match["art_url"]
if art_url:
res = await db.tracks.update_many(
{"artist": artist, "album": album, "album_art_url": {"$in": [None, ""]}},
{"$set": {"album_art_url": art_url}},
)
_music_enrich_status["updated"] += res.modified_count
except Exception as e:
logger.warning(f"Music enrich failed for {artist} - {album}: {e}")
_music_enrich_status["done"] += 1
@@ -1705,6 +1724,63 @@ async def _run_music_enrich():
_music_enrich_status["running"] = False
_acoustid_status = {"running": False, "total": 0, "done": 0, "identified": 0, "error": ""}
async def _run_acoustid_identify():
"""Fingerprints tracks with missing/placeholder artist or album and identifies them via
AcoustID — for files with no usable tags to text-search on. Filling in artist/album lets
the normal MusicBrainz/iTunes art enrichment pick them up afterward."""
_acoustid_status.update(running=True, total=0, done=0, identified=0, error="")
try:
settings = await get_settings()
api_key = settings.get("acoustid_api_key", "")
if not api_key:
_acoustid_status["error"] = "No AcoustID API key configured"
return
tracks = await db.tracks.find(
{"$or": [{"artist": "Unknown Artist"}, {"album": "Unknown Album"}]},
{"_id": 0},
).to_list(5000)
_acoustid_status["total"] = len(tracks)
for t in tracks:
try:
path = Path(t["storage_path"])
match = await acoustid_client.identify_track(api_key, path)
if match:
update = {}
if match.get("title") and t.get("artist") == "Unknown Artist":
update["title"] = match["title"]
if match.get("artist"):
update["artist"] = match["artist"]
if match.get("album"):
update["album"] = match["album"]
if update:
await db.tracks.update_one({"id": t["id"]}, {"$set": update})
_acoustid_status["identified"] += 1
except Exception as e:
logger.warning(f"AcoustID identify failed for {t.get('storage_path')}: {e}")
_acoustid_status["done"] += 1
await asyncio.sleep(0.35) # AcoustID's documented rate limit is 3 req/sec
except Exception as e:
_acoustid_status["error"] = str(e)
finally:
_acoustid_status["running"] = False
@api.post("/music/identify")
async def start_acoustid_identify(user: dict = Depends(require_admin)):
if _acoustid_status["running"]:
raise HTTPException(status_code=409, detail="Identification already running")
asyncio.create_task(_run_acoustid_identify())
return {"ok": True}
@api.get("/music/identify/status")
async def acoustid_identify_status(user: dict = Depends(require_admin)):
return _acoustid_status
@api.post("/music/enrich")
async def start_music_enrich(user: dict = Depends(require_admin)):
if _music_enrich_status["running"]: