mirror of
https://github.com/myronblair/kino-app
synced 2026-07-28 05:23:45 -05:00
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:
+3
-1
@@ -1,10 +1,12 @@
|
|||||||
# Kino backend — FastAPI + ffmpeg
|
# Kino backend — FastAPI + ffmpeg
|
||||||
FROM python:3.11-slim
|
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 \
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
ffmpeg \
|
ffmpeg \
|
||||||
curl \
|
curl \
|
||||||
|
libchromaprint-tools \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|||||||
@@ -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"])
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
@@ -225,6 +225,9 @@ class AppSettings(BaseModel):
|
|||||||
trakt_client_secret: str = ""
|
trakt_client_secret: str = ""
|
||||||
auto_transcode: str = "off"
|
auto_transcode: str = "off"
|
||||||
queue_paused: bool = False
|
queue_paused: bool = False
|
||||||
|
music_itunes_fallback: bool = True
|
||||||
|
music_acoustid_enabled: bool = False
|
||||||
|
acoustid_api_key: str = ""
|
||||||
|
|
||||||
|
|
||||||
class AppSettingsPublic(BaseModel):
|
class AppSettingsPublic(BaseModel):
|
||||||
@@ -243,6 +246,10 @@ class AppSettingsPublic(BaseModel):
|
|||||||
trakt_client_secret: str = ""
|
trakt_client_secret: str = ""
|
||||||
auto_transcode: str = "off"
|
auto_transcode: str = "off"
|
||||||
queue_paused: bool = False
|
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) ----------
|
# ---------- Shows / Episodes (Sonarr) ----------
|
||||||
|
|||||||
+82
-6
@@ -38,6 +38,8 @@ import services as services_client
|
|||||||
import library_scan
|
import library_scan
|
||||||
import library_cleanup
|
import library_cleanup
|
||||||
import musicbrainz as musicbrainz_client
|
import musicbrainz as musicbrainz_client
|
||||||
|
import itunes as itunes_client
|
||||||
|
import acoustid as acoustid_client
|
||||||
import trakt as trakt_client
|
import trakt as trakt_client
|
||||||
import opensubtitles as opensubs_client
|
import opensubtitles as opensubs_client
|
||||||
from transcode import transcode_quick, transcode_abr, transcode_audio_fix, srt_to_vtt
|
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", ""),
|
trakt_client_secret=s.get("trakt_client_secret", ""),
|
||||||
auto_transcode=s.get("auto_transcode", "off"),
|
auto_transcode=s.get("auto_transcode", "off"),
|
||||||
queue_paused=bool(s.get("queue_paused", False)),
|
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", ""),
|
trakt_client_secret=doc.get("trakt_client_secret", ""),
|
||||||
auto_transcode=doc.get("auto_transcode", "off"),
|
auto_transcode=doc.get("auto_transcode", "off"),
|
||||||
queue_paused=bool(doc.get("queue_paused", False)),
|
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():
|
async def _run_music_enrich():
|
||||||
_music_enrich_status.update(running=True, total=0, done=0, updated=0, error="")
|
_music_enrich_status.update(running=True, total=0, done=0, updated=0, error="")
|
||||||
try:
|
try:
|
||||||
|
settings = await get_settings()
|
||||||
|
itunes_fallback = settings.get("music_itunes_fallback", True)
|
||||||
pairs = await db.tracks.aggregate([
|
pairs = await db.tracks.aggregate([
|
||||||
{"$match": {"album_art_url": {"$in": [None, ""]}}},
|
{"$match": {"album_art_url": {"$in": [None, ""]}}},
|
||||||
{"$group": {"_id": {"artist": "$artist", "album": "$album"}}},
|
{"$group": {"_id": {"artist": "$artist", "album": "$album"}}},
|
||||||
@@ -1686,15 +1698,22 @@ async def _run_music_enrich():
|
|||||||
for p in pairs:
|
for p in pairs:
|
||||||
artist = p["_id"]["artist"]; album = p["_id"]["album"]
|
artist = p["_id"]["artist"]; album = p["_id"]["album"]
|
||||||
try:
|
try:
|
||||||
|
art_url = None
|
||||||
release = await asyncio.to_thread(musicbrainz_client.lookup_release, artist, album)
|
release = await asyncio.to_thread(musicbrainz_client.lookup_release, artist, album)
|
||||||
if release and release.get("mbid"):
|
if release and release.get("mbid"):
|
||||||
art_url = await asyncio.to_thread(musicbrainz_client.cover_art_url, release["mbid"])
|
art_url = await asyncio.to_thread(musicbrainz_client.cover_art_url, release["mbid"])
|
||||||
if art_url:
|
if not art_url and itunes_fallback:
|
||||||
res = await db.tracks.update_many(
|
# MusicBrainz has no release, or CAA has no art for it — iTunes needs no
|
||||||
{"artist": artist, "album": album, "album_art_url": {"$in": [None, ""]}},
|
# key/rate-limit pacing, so it's a free second attempt before giving up.
|
||||||
{"$set": {"album_art_url": art_url}},
|
itunes_match = await asyncio.to_thread(itunes_client.lookup_album, artist, album)
|
||||||
)
|
if itunes_match and itunes_match.get("art_url"):
|
||||||
_music_enrich_status["updated"] += res.modified_count
|
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:
|
except Exception as e:
|
||||||
logger.warning(f"Music enrich failed for {artist} - {album}: {e}")
|
logger.warning(f"Music enrich failed for {artist} - {album}: {e}")
|
||||||
_music_enrich_status["done"] += 1
|
_music_enrich_status["done"] += 1
|
||||||
@@ -1705,6 +1724,63 @@ async def _run_music_enrich():
|
|||||||
_music_enrich_status["running"] = False
|
_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")
|
@api.post("/music/enrich")
|
||||||
async def start_music_enrich(user: dict = Depends(require_admin)):
|
async def start_music_enrich(user: dict = Depends(require_admin)):
|
||||||
if _music_enrich_status["running"]:
|
if _music_enrich_status["running"]:
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ export default function Settings() {
|
|||||||
sonarr_url: "", sonarr_api_key: "",
|
sonarr_url: "", sonarr_api_key: "",
|
||||||
opensubs_api_key: "", trakt_client_id: "", trakt_client_secret: "",
|
opensubs_api_key: "", trakt_client_id: "", trakt_client_secret: "",
|
||||||
auto_transcode: "off", queue_paused: false,
|
auto_transcode: "off", queue_paused: false,
|
||||||
|
music_itunes_fallback: true, music_acoustid_enabled: false, acoustid_api_key: "",
|
||||||
});
|
});
|
||||||
const [info, setInfo] = useState({});
|
const [info, setInfo] = useState({});
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
@@ -58,6 +59,7 @@ export default function Settings() {
|
|||||||
const [pwShow, setPwShow] = useState(false);
|
const [pwShow, setPwShow] = useState(false);
|
||||||
const [pwSaving, setPwSaving] = useState(false);
|
const [pwSaving, setPwSaving] = useState(false);
|
||||||
const [enrich, setEnrich] = useState(null);
|
const [enrich, setEnrich] = useState(null);
|
||||||
|
const [identify, setIdentify] = useState(null);
|
||||||
|
|
||||||
const load = async () => {
|
const load = async () => {
|
||||||
if (!isAdmin) return;
|
if (!isAdmin) return;
|
||||||
@@ -68,6 +70,9 @@ export default function Settings() {
|
|||||||
opensubs_api_key: data.opensubs_api_key || "",
|
opensubs_api_key: data.opensubs_api_key || "",
|
||||||
trakt_client_id: data.trakt_client_id || "", trakt_client_secret: data.trakt_client_secret || "",
|
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,
|
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);
|
setInfo(data); setTraktStatus(ts);
|
||||||
};
|
};
|
||||||
@@ -131,6 +136,7 @@ export default function Settings() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isAdmin) return;
|
if (!isAdmin) return;
|
||||||
api.get("/tmdb/enrich-all/status").then(({ data }) => setEnrich(data)).catch(() => {});
|
api.get("/tmdb/enrich-all/status").then(({ data }) => setEnrich(data)).catch(() => {});
|
||||||
|
api.get("/music/identify/status").then(({ data }) => setIdentify(data)).catch(() => {});
|
||||||
}, [isAdmin]);
|
}, [isAdmin]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -150,6 +156,33 @@ export default function Settings() {
|
|||||||
return () => clearInterval(t);
|
return () => clearInterval(t);
|
||||||
}, [enrich?.running]);
|
}, [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 (
|
return (
|
||||||
<FieldCtx.Provider value={{ s, setS, show, setShow }}>
|
<FieldCtx.Provider value={{ s, setS, show, setShow }}>
|
||||||
<div className="min-h-screen bg-[#050505] pt-32 pb-24" data-testid="settings-page">
|
<div className="min-h-screen bg-[#050505] pt-32 pb-24" data-testid="settings-page">
|
||||||
@@ -307,6 +340,65 @@ export default function Settings() {
|
|||||||
</label>
|
</label>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
{/* Music Metadata & Artwork */}
|
||||||
|
<section>
|
||||||
|
<h2 className="font-display text-2xl font-bold text-white mb-3">Music Metadata & Artwork</h2>
|
||||||
|
<p className="text-sm text-[#8A8A8A] mb-4">
|
||||||
|
Album art/tags come from MusicBrainz + Cover Art Archive first (free, no key). These add free fallbacks for what that misses.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<label className="flex items-center gap-3 cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={s.music_itunes_fallback}
|
||||||
|
onChange={(e) => setS({ ...s, music_itunes_fallback: e.target.checked })}
|
||||||
|
className="w-4 h-4 accent-[#D9381E]"
|
||||||
|
data-testid="settings-itunes-fallback"
|
||||||
|
/>
|
||||||
|
<span className="text-sm text-white">Fall back to iTunes Search for artwork MusicBrainz doesn't have</span>
|
||||||
|
</label>
|
||||||
|
<p className="text-xs text-[#8A8A8A] mt-1 ml-7">No API key needed — always free, no signup.</p>
|
||||||
|
|
||||||
|
<div className="mt-6 pt-6 border-t border-[#222]">
|
||||||
|
<div className="flex items-center justify-between mb-3">
|
||||||
|
<span className="text-sm text-white">AcoustID audio fingerprint identification</span>
|
||||||
|
<Badge on={info.acoustid_configured} />
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-[#8A8A8A] mb-4">
|
||||||
|
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{" "}
|
||||||
|
<a className="text-[#D9381E]" href="https://acoustid.org/api-key" target="_blank" rel="noreferrer">acoustid.org/api-key</a>.
|
||||||
|
</p>
|
||||||
|
<label className="flex items-center gap-3 cursor-pointer mb-4">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={s.music_acoustid_enabled}
|
||||||
|
onChange={(e) => setS({ ...s, music_acoustid_enabled: e.target.checked })}
|
||||||
|
className="w-4 h-4 accent-[#D9381E]"
|
||||||
|
data-testid="settings-acoustid-enabled"
|
||||||
|
/>
|
||||||
|
<span className="text-sm text-white">Enable AcoustID identification</span>
|
||||||
|
</label>
|
||||||
|
<Field label="API key" k="acoustid_api_key" mask />
|
||||||
|
{info.acoustid_configured && s.music_acoustid_enabled && (
|
||||||
|
<div className="mt-4 flex items-center gap-4 flex-wrap">
|
||||||
|
<button type="button" onClick={identifyAll} disabled={identify?.running} className="text-xs uppercase tracking-[0.2em] border border-[#222] hover:border-[#D9381E] hover:text-[#D9381E] text-[#8A8A8A] px-4 py-2 transition-colors disabled:opacity-50" data-testid="identify-all-button">
|
||||||
|
{identify?.running ? `Identifying… ${identify.done}/${identify.total}` : "Identify unknown tracks →"}
|
||||||
|
</button>
|
||||||
|
{identify && !identify.running && identify.error && (
|
||||||
|
<span className="text-xs text-[#fca5a5]" data-testid="identify-all-error">
|
||||||
|
Last run failed: {identify.error}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{identify && !identify.running && !identify.error && identify.total > 0 && (
|
||||||
|
<span className="text-xs text-[#8A8A8A]" data-testid="identify-all-summary">
|
||||||
|
Last run: {identify.identified} of {identify.total} identified
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<button type="submit" disabled={saving} className="bg-[#D9381E] hover:bg-[#ED4B32] disabled:opacity-60 text-white px-8 py-3 text-sm uppercase tracking-[0.2em]" data-testid="settings-save-button">
|
<button type="submit" disabled={saving} className="bg-[#D9381E] hover:bg-[#ED4B32] disabled:opacity-60 text-white px-8 py-3 text-sm uppercase tracking-[0.2em]" data-testid="settings-save-button">
|
||||||
{saving ? "Saving…" : "Save Settings"}
|
{saving ? "Saving…" : "Save Settings"}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
Reference in New Issue
Block a user