diff --git a/backend/Dockerfile b/backend/Dockerfile index 6a11a2f..10ceec6 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -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 diff --git a/backend/acoustid.py b/backend/acoustid.py new file mode 100644 index 0000000..bbaa37a --- /dev/null +++ b/backend/acoustid.py @@ -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"]) diff --git a/backend/itunes.py b/backend/itunes.py new file mode 100644 index 0000000..60cf6d1 --- /dev/null +++ b/backend/itunes.py @@ -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, + } diff --git a/backend/models.py b/backend/models.py index 479125d..9c85978 100644 --- a/backend/models.py +++ b/backend/models.py @@ -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) ---------- diff --git a/backend/server.py b/backend/server.py index 74269d0..7a69c5d 100644 --- a/backend/server.py +++ b/backend/server.py @@ -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"]: diff --git a/frontend/src/pages/Settings.jsx b/frontend/src/pages/Settings.jsx index fc48796..9fdd5a5 100644 --- a/frontend/src/pages/Settings.jsx +++ b/frontend/src/pages/Settings.jsx @@ -48,6 +48,7 @@ export default function Settings() { sonarr_url: "", sonarr_api_key: "", opensubs_api_key: "", trakt_client_id: "", trakt_client_secret: "", auto_transcode: "off", queue_paused: false, + music_itunes_fallback: true, music_acoustid_enabled: false, acoustid_api_key: "", }); const [info, setInfo] = useState({}); const [saving, setSaving] = useState(false); @@ -58,6 +59,7 @@ export default function Settings() { const [pwShow, setPwShow] = useState(false); const [pwSaving, setPwSaving] = useState(false); const [enrich, setEnrich] = useState(null); + const [identify, setIdentify] = useState(null); const load = async () => { if (!isAdmin) return; @@ -68,6 +70,9 @@ export default function Settings() { opensubs_api_key: data.opensubs_api_key || "", trakt_client_id: data.trakt_client_id || "", trakt_client_secret: data.trakt_client_secret || "", auto_transcode: data.auto_transcode || "off", queue_paused: !!data.queue_paused, + music_itunes_fallback: data.music_itunes_fallback !== false, + music_acoustid_enabled: !!data.music_acoustid_enabled, + acoustid_api_key: data.acoustid_api_key || "", }); setInfo(data); setTraktStatus(ts); }; @@ -131,6 +136,7 @@ export default function Settings() { useEffect(() => { if (!isAdmin) return; api.get("/tmdb/enrich-all/status").then(({ data }) => setEnrich(data)).catch(() => {}); + api.get("/music/identify/status").then(({ data }) => setIdentify(data)).catch(() => {}); }, [isAdmin]); useEffect(() => { @@ -150,6 +156,33 @@ export default function Settings() { return () => clearInterval(t); }, [enrich?.running]); + const identifyAll = async () => { + if (!window.confirm("Fingerprint every track with an unknown artist or album and identify it via AcoustID? This may take a while.")) return; + try { + await api.post("/music/identify"); + const { data } = await api.get("/music/identify/status"); + setIdentify(data); + toast.success("Identification started"); + } catch (err) { toast.error(err.response?.data?.detail || "Could not start"); } + }; + + useEffect(() => { + if (!identify?.running) return; + const t = setInterval(async () => { + const { data } = await api.get("/music/identify/status"); + setIdentify(data); + if (!data.running) { + clearInterval(t); + if (data.error) { + toast.error(`Identification failed: ${data.error}`); + } else { + toast.success(`Identification done — ${data.identified} of ${data.total} tracks identified`); + } + } + }, 2000); + return () => clearInterval(t); + }, [identify?.running]); + return (
@@ -307,6 +340,65 @@ export default function Settings() { + {/* Music Metadata & Artwork */} +
+

Music Metadata & Artwork

+

+ Album art/tags come from MusicBrainz + Cover Art Archive first (free, no key). These add free fallbacks for what that misses. +

+ + +

No API key needed — always free, no signup.

+ +
+
+ AcoustID audio fingerprint identification + +
+

+ Identifies tracks with missing/unknown artist or album by fingerprinting the actual audio file — useful for badly-tagged files with nothing to text-search on. Free key at{" "} + acoustid.org/api-key. +

+ + + {info.acoustid_configured && s.music_acoustid_enabled && ( +
+ + {identify && !identify.running && identify.error && ( + + Last run failed: {identify.error} + + )} + {identify && !identify.running && !identify.error && identify.total > 0 && ( + + Last run: {identify.identified} of {identify.total} identified + + )} +
+ )} +
+
+