Sokhi (continued from #806): volume-numbered series ('B小町 …キャラクター ソングCD Vol.2' / 'Vol.2.5' / 'Vol.4' / 'Vol.4.5') got each other's art from both normal downloads and the retag tool. Two distinct holes, one principle: 1. The art picker's _album_matches validates by significant-token SUBSET — built to tolerate '(Deluxe)'/'- Remastered' suffixes. CJK strips out of the normalizer entirely, so Vol.4 → {b,tv,cd,vol,4}, a clean subset of Vol.4.5's {b,tv,cd,vol,4,5}: the wrong volume validated as "the same album with a suffix". Affected every fuzzy art source (iTunes, Deezer, AudioDB, Spotify) in downloads, retag, and the missing-art repair. 2. MusicBrainz match_release scores by string similarity — Vol.4 vs Vol.4.5 is 0.973, so the wrong volume could win the match outright, and its MBID then feeds Cover Art Archive with NO downstream validation (CAA is MBID-keyed, trusted by design). With Sokhi's MB metadata source this is the likely path in his logs (his release-group 404s push re-matching). The shared rule (core.text.title_match.numeric_tokens_differ): digit-bearing tokens must be IDENTICAL between the two titles. A number on one side only — volume, part, sequel, remaster year — is a different release, never a suffix. '1989' vs '1989 (Deluxe)' still matches (digits shared); 'Album' vs 'Album 2' now rejects (sequels!). Art picker rejects outright (falls through to next source / the download's own art — the designed cost of a false reject); MB matcher halves the candidate's confidence, landing it below the 70 gate while the exact-volume result is untouched. Tests: helper truth table, the exact reported pairs through _album_matches, and match_release end-to-end (wrong volume alone → no match beats a wrong MBID; exact volume beats near-identical wrong one despite lower MB score). 828 matching/metadata + 301 musicbrainz/retag/artwork tests pass.
303 lines
12 KiB
Python
303 lines
12 KiB
Python
"""Per-source album-art lookups + availability for the cover-art picker.
|
|
|
|
Bridges the pure resolver in ``art_sources.py`` to the real metadata clients.
|
|
Each supported source contributes two things:
|
|
|
|
- **availability** — is this source usable for the current user right now?
|
|
Free sources (CAA, Deezer, iTunes, AudioDB) are always available; account
|
|
sources (Spotify) only when connected. This is what powers "not everybody
|
|
has access to every source": the UI offers only available sources and the
|
|
resolver skips the rest.
|
|
- **lookup** — ``(artist, album, metadata) -> cover_url | None``, calling an
|
|
EXISTING client method. Every lookup is individually guarded so any error or
|
|
miss degrades to ``None`` (the resolver then falls through to the next
|
|
source, finally to the download's own art) — a flaky source can never raise
|
|
into, or break, a download.
|
|
|
|
Lookups are cached per album via ``build_art_lookup`` so resolving art for a
|
|
16-track album hits each source at most once.
|
|
|
|
Client accessors are imported lazily inside each function to keep this module
|
|
import-light (the pure resolver + its tests never pull a network client).
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import re
|
|
from typing import Callable, Dict, List, Optional
|
|
|
|
from utils.logging_config import get_logger
|
|
|
|
from core.metadata.art_sources import ART_CAPABLE_SOURCES
|
|
|
|
logger = get_logger("metadata.art_lookup")
|
|
|
|
# Sources that need no account/config — always offered.
|
|
_FREE_SOURCES = ("caa", "deezer", "itunes", "audiodb")
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Availability
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _spotify_available() -> bool:
|
|
"""Spotify art is only usable when the user is connected. Reuse the
|
|
canonical accessor, which returns a client only when authenticated."""
|
|
try:
|
|
from core.metadata.registry import get_client_for_source
|
|
return get_client_for_source("spotify") is not None
|
|
except Exception:
|
|
return False
|
|
|
|
|
|
_AVAILABILITY: Dict[str, Callable[[], bool]] = {
|
|
"spotify": _spotify_available,
|
|
}
|
|
|
|
|
|
def is_art_source_available(source: str) -> bool:
|
|
"""Is ``source`` usable right now? Free sources are always available;
|
|
account sources defer to their connection check. Unknown/unsupported
|
|
sources are never available."""
|
|
name = (source or "").strip().lower()
|
|
if name not in ART_CAPABLE_SOURCES:
|
|
return False
|
|
if name in _FREE_SOURCES:
|
|
return True
|
|
check = _AVAILABILITY.get(name)
|
|
return bool(check()) if check else False
|
|
|
|
|
|
def available_art_sources() -> List[str]:
|
|
"""The supported art sources currently usable for this user, in the
|
|
default priority order — for populating the settings UI."""
|
|
return [s for s in ART_CAPABLE_SOURCES if is_art_source_available(s)]
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Album-match validation. Every client's search returns its top hit
|
|
# unvalidated (results[0]), so a source that lacks the album could hand back a
|
|
# DIFFERENT one — embedding wrong-album art, which is worse than the download's
|
|
# own cover. We therefore confirm the returned album matches the request before
|
|
# trusting its art; a mismatch returns None so the resolver falls through,
|
|
# preserving the "worst case = the cover you'd get today" guarantee.
|
|
# ---------------------------------------------------------------------------
|
|
|
|
_STOPWORDS = {"the", "a", "an"}
|
|
|
|
|
|
def _norm(s) -> str:
|
|
return re.sub(r"[^a-z0-9]+", " ", str(s or "").lower()).strip()
|
|
|
|
|
|
def _significant_tokens(s) -> set:
|
|
return {t for t in _norm(s).split() if t not in _STOPWORDS}
|
|
|
|
|
|
def _result_album_artist(obj):
|
|
"""Best-effort (album_name, artist_name) from a search result — handles
|
|
both raw dicts (Deezer/AudioDB) and dataclasses (iTunes/Spotify)."""
|
|
if isinstance(obj, dict):
|
|
name = obj.get("title") or obj.get("name") or obj.get("strAlbum") or ""
|
|
artist = obj.get("strArtist") or ""
|
|
a = obj.get("artist")
|
|
if not artist and isinstance(a, dict):
|
|
artist = a.get("name", "")
|
|
elif not artist and isinstance(a, str):
|
|
artist = a
|
|
return name, artist
|
|
name = getattr(obj, "name", None) or getattr(obj, "title", None) or ""
|
|
arts = getattr(obj, "artists", None)
|
|
if arts is None:
|
|
arts = getattr(obj, "artist", None)
|
|
if isinstance(arts, (list, tuple)):
|
|
artist = ", ".join(str(x) for x in arts if x)
|
|
else:
|
|
artist = str(arts) if arts else ""
|
|
return name, artist
|
|
|
|
|
|
def _album_matches(req_artist, req_album, got_artist, got_album) -> bool:
|
|
"""True when the returned album plausibly IS the requested one.
|
|
|
|
Both album and artist are compared by significant-token subset (stopwords
|
|
dropped), which tolerates leading articles, word order, "(Deluxe)"/
|
|
"- Remastered" suffixes, punctuation, and "feat."/"&"/multi-artist ordering.
|
|
Lenient enough to never reject a genuine hit — a false reject just falls
|
|
back to today's art — yet strict enough to drop a different album (the
|
|
generic-title case, e.g. two "Greatest Hits", is caught by the artist gate)."""
|
|
ra, ga = _significant_tokens(req_album), _significant_tokens(got_album)
|
|
if not ra or not ga:
|
|
return False
|
|
if not (ra <= ga or ga <= ra):
|
|
return False
|
|
# Sokhi: the subset tolerance exists for '(Deluxe)'/'- Remastered'
|
|
# suffixes, but a NUMERIC difference is a different release, not a
|
|
# suffix. 'B小町 …CD Vol.4' normalizes to {b,tv,cd,vol,4} — a subset of
|
|
# Vol.4.5's {b,tv,cd,vol,4,5} — so volume 4 was hanging volume 4.5's
|
|
# cover. Any number present on only ONE side (volume, part, sequel,
|
|
# remaster year) rejects the match; the resolver then falls through to
|
|
# the next source / the download's own art, which is the designed cost
|
|
# of a false reject here. (Shared rule — the MusicBrainz release matcher
|
|
# applies the same guard so the MBID-keyed CAA path can't slip either.)
|
|
from core.text.title_match import numeric_tokens_differ
|
|
if numeric_tokens_differ(req_album, got_album):
|
|
return False
|
|
ta, tg = _significant_tokens(req_artist), _significant_tokens(got_artist)
|
|
if not ta:
|
|
return True # requested artist unknown -> album match suffices
|
|
if not tg:
|
|
return False # asked for an artist, none returned -> can't confirm
|
|
return ta <= tg or tg <= ta or bool(ta & tg)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Per-source lookups — each returns a cover URL or None, never raises.
|
|
# Non-CAA sources validate the returned album before trusting its art.
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _caa_art(artist: str, album: str, metadata: dict) -> Optional[str]:
|
|
# Cover Art Archive is keyed by the MusicBrainz release id, so it's THE
|
|
# release's art by definition — no fuzzy match to validate. Resolves only
|
|
# once MusicBrainz enrichment has found the release.
|
|
mbid = (metadata or {}).get("musicbrainz_release_id")
|
|
if not mbid:
|
|
return None
|
|
return f"https://coverartarchive.org/release/{mbid}/front-1200"
|
|
|
|
|
|
def _deezer_art(artist: str, album: str, metadata: dict) -> Optional[str]:
|
|
from core.metadata.registry import get_deezer_client
|
|
client = get_deezer_client()
|
|
if not client:
|
|
return None
|
|
data = client.search_album(artist, album)
|
|
if not data:
|
|
return None
|
|
got_album, got_artist = _result_album_artist(data)
|
|
if not _album_matches(artist, album, got_artist, got_album):
|
|
return None
|
|
url = data.get("cover_xl") or data.get("cover_big") or data.get("cover_medium")
|
|
if not url:
|
|
return None
|
|
try:
|
|
from core.deezer_client import _upgrade_deezer_cover_url
|
|
return _upgrade_deezer_cover_url(url)
|
|
except Exception:
|
|
return url
|
|
|
|
|
|
def _itunes_art(artist: str, album: str, metadata: dict) -> Optional[str]:
|
|
from core.metadata.registry import get_itunes_client
|
|
client = get_itunes_client()
|
|
if not client:
|
|
return None
|
|
for alb in (client.search_albums(f"{artist} {album}") or []):
|
|
url = getattr(alb, "image_url", None)
|
|
if not url:
|
|
continue
|
|
got_album, got_artist = _result_album_artist(alb)
|
|
if _album_matches(artist, album, got_artist, got_album):
|
|
# iTunes serves any size via the WxH segment — request the max so
|
|
# iTunes contributes high-res art, not the 600px default.
|
|
return re.sub(r"/\d+x\d+bb\.", "/3000x3000bb.", url)
|
|
return None
|
|
|
|
|
|
def _audiodb_art(artist: str, album: str, metadata: dict) -> Optional[str]:
|
|
from core.audiodb_client import AudioDBClient
|
|
data = AudioDBClient().search_album(artist, album)
|
|
if not data:
|
|
return None
|
|
got_album, got_artist = _result_album_artist(data)
|
|
if not _album_matches(artist, album, got_artist, got_album):
|
|
return None
|
|
return data.get("strAlbumThumb") or None
|
|
|
|
|
|
def _spotify_art(artist: str, album: str, metadata: dict) -> Optional[str]:
|
|
from core.metadata.registry import get_client_for_source
|
|
client = get_client_for_source("spotify")
|
|
if not client:
|
|
return None
|
|
for alb in (client.search_albums(f"{artist} {album}") or []):
|
|
url = getattr(alb, "image_url", None)
|
|
if not url:
|
|
continue
|
|
got_album, got_artist = _result_album_artist(alb)
|
|
if _album_matches(artist, album, got_artist, got_album):
|
|
return url
|
|
return None
|
|
|
|
|
|
_LOOKUPS: Dict[str, Callable[[str, str, dict], Optional[str]]] = {
|
|
"caa": _caa_art,
|
|
"deezer": _deezer_art,
|
|
"itunes": _itunes_art,
|
|
"audiodb": _audiodb_art,
|
|
"spotify": _spotify_art,
|
|
}
|
|
|
|
|
|
def select_preferred_art_url(
|
|
artist: Optional[str],
|
|
album: Optional[str],
|
|
metadata: Optional[dict],
|
|
configured_order,
|
|
validate: Optional[Callable[[str, str], bool]] = None,
|
|
) -> Optional[str]:
|
|
"""Pick a cover-art URL from the user's configured source order, or None.
|
|
|
|
``None`` means "feature off, or nothing in the list resolved" — the caller
|
|
then keeps its existing art (today's behavior), so the worst case is simply
|
|
the cover you'd get today. This is the single entry point the art pipeline
|
|
calls; it's a no-op (returns ``None`` immediately) unless ``album_art_order``
|
|
is an explicit non-empty list, which keeps every existing install untouched.
|
|
|
|
``validate(source, url)`` is an optional gate forwarded to the resolver — the
|
|
art pipeline passes one that fetches the candidate and rejects images below a
|
|
minimum resolution, so a too-small cover (e.g. a low-res Cover Art Archive
|
|
upload) is skipped and the next source is tried instead of winning by
|
|
priority alone.
|
|
"""
|
|
if not isinstance(configured_order, (list, tuple)) or not configured_order:
|
|
return None
|
|
from core.metadata.art_sources import effective_art_order, resolve_cover_art
|
|
order = [s for s in effective_art_order(configured_order) if is_art_source_available(s)]
|
|
if not order:
|
|
return None
|
|
lookup = build_art_lookup(artist or "", album or "", metadata or {})
|
|
url, _src = resolve_cover_art(order, lookup, validate=validate)
|
|
return url
|
|
|
|
|
|
def build_art_lookup(
|
|
artist: str,
|
|
album: str,
|
|
metadata: Optional[dict] = None,
|
|
) -> Callable[[str], Optional[str]]:
|
|
"""Return a ``source_name -> cover_url | None`` callable for one album,
|
|
suitable to pass straight to ``art_sources.resolve_cover_art``. Results are
|
|
cached per source so re-resolving across an album's tracks costs at most one
|
|
lookup per source, and every lookup is guarded (errors → None)."""
|
|
meta = metadata or {}
|
|
cache: Dict[str, Optional[str]] = {}
|
|
|
|
def lookup(source: str) -> Optional[str]:
|
|
name = (source or "").strip().lower()
|
|
if name in cache:
|
|
return cache[name]
|
|
fn = _LOOKUPS.get(name)
|
|
url: Optional[str] = None
|
|
if fn is not None:
|
|
try:
|
|
url = fn(artist, album, meta)
|
|
except Exception as exc:
|
|
logger.debug("[art] %s lookup failed: %s", name, exc)
|
|
url = None
|
|
cache[name] = url
|
|
return url
|
|
|
|
return lookup
|