soulsync/core/metadata/art_lookup.py
BoulderBadgeDad b202c176f7 Cover-art sources: skip low-res art (min-resolution guard) + max-res iTunes
Follow-up to the preferred-art feature. Real test runs showed a source could
win on priority while handing back a small cover: Cover Art Archive is
volunteer-uploaded with no size floor, so CAA-first gave a 599x531 (Taylor
Swift) and a 600x600 (Kendrick GNX) -- front-1200 only caps the max, so a
~600px upload stays ~600px -- and Deezer/iTunes lower in the order never got a
turn.

Fix:
- Minimum-resolution guard: artwork._min_size_art_validator builds the
  resolver's validate hook -- it fetches each candidate, caches the bytes (so
  the winner isn't fetched twice), and accepts art only when its shortest side
  >= metadata_enhancement.min_art_size (default 1000px; 0 disables). Art that's
  too small is a miss, so the resolver falls through to the next source instead
  of winning on priority. Unmeasurable images are accepted (don't over-reject;
  fallback is still today's art). Wired into both embed_album_art_metadata and
  download_cover_art.
- iTunes art upgraded to /3000x3000bb/ (was the 600px default) so it
  contributes high-res when it wins.
- select_preferred_art_url gains a validate passthrough to the resolver.
- config default metadata_enhancement.min_art_size: 1000.

Effect: with an order like caa > deezer > spotify > itunes, a ~600px CAA upload
is now skipped and Deezer's ~1900px wins -- consistent big art. (Spotify art
often maxes ~640px, so it's skipped at the 1000 floor in favor of bigger
sources; lower min_art_size to ~640 to allow it.)

Tests: tests/metadata/test_art_min_size.py (6 -- incl. the real 599x531 and
600x600 cases, shortest-side logic, unmeasurable-accept, no-bytes-reject,
0-disables) + iTunes max-res upgrade test. Full metadata suite green (617).
2026-06-01 12:24:51 -07:00

291 lines
11 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
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