soulsync/core/metadata/caa_art.py
BoulderBadgeDad 458658de86 Special-edition cover art: prefer the pinned release own cover over the release-group representative
A MusicBrainz album resolves its art at RELEASE-GROUP scope even for a concrete
release (musicbrainz_search _release_to_album -> _cached_art prefers the rg mbid).
On the Cover Art Archive a release-group front is a single REPRESENTATIVE cover
(CAA picks one release to stand for the group, ~always the standard edition), so
a special edition like "Clair Obscur: Expedition 33 (Gustave Edition)" got the
standard art baked into cover.jpg + embedded tags at download time.

Add core/metadata/caa_art.fetch_release_preferred_art: try the specific release
own /release/<mbid>/front first, fall back to the existing release-group/provider
URL only when the release has no art of its own (404 -> None). Wire it into both
download_cover_art (cover.jpg) and embed_album_art_metadata (tags) so they stay
in sync. min_bytes defaults to 0 so the fallback keeps its prior behavior — this
can only ever ADD a better edition match, never strip a cover that showed before.

Caveat (documented, not fixed here): this only helps when the resolved release IS
the right edition. If the upstream match picked the standard release/group, that
is the separate canonical-version matching problem.

Tests: pure helper (prefers release art, falls back on 404/tiny/exception, no-mbid
passes through, nothing-available -> None). 667 metadata/artwork/deezer/hifi tests pass.
2026-06-21 22:47:09 -07:00

74 lines
3.2 KiB
Python

"""Cover Art Archive helper: prefer a pinned release's OWN cover over the
release-group representative.
On the Cover Art Archive a release-group ``front`` is a single REPRESENTATIVE
cover — CAA designates one release in the group to stand for the whole thing,
which is almost always the standard / most-common edition. So when a download
has pinned a SPECIFIC release (e.g. a "Gustave Edition" the user picked), using
the release-group cover silently swaps in the standard art.
This helper tries the specific release's own ``/release/<mbid>/front`` first and
only falls back to the caller's existing URL (a release-group representative or a
provider cover) when the release has no art of its own — so it can only ever
*improve* on today's behaviour, never strip a cover that was already showing.
Pure: the network fetch is injected, so the preference logic is unit-testable.
"""
from __future__ import annotations
from typing import Callable, Optional, Tuple
COVER_ART_ARCHIVE_URL = "https://coverartarchive.org"
def caa_front_url(mbid: Optional[str], scope: str = "release", size: int = 1200) -> Optional[str]:
"""Build a Cover Art Archive front-cover URL, or None for a falsy mbid.
``scope`` is 'release' (a specific edition) or 'release-group' (the group's
representative). ``size`` selects the CDN thumbnail (e.g. 250/500/1200); 0
requests the bare ``/front`` original."""
if not mbid:
return None
if scope not in ("release", "release-group"):
scope = "release"
suffix = f"-{size}" if size else ""
return f"{COVER_ART_ARCHIVE_URL}/{scope}/{mbid}/front{suffix}"
def fetch_release_preferred_art(
release_mbid: Optional[str],
fallback_url: Optional[str],
*,
fetch_fn: Callable[[str], Tuple[Optional[bytes], Optional[str]]],
size: int = 1200,
min_bytes: int = 0,
) -> Tuple[Optional[bytes], Optional[str], Optional[str]]:
"""Fetch the best cover, preferring the specific release's own art.
Tries ``/release/<release_mbid>/front`` first; on any miss (no such art,
404, or smaller than ``min_bytes``) falls back to ``fallback_url`` (a
release-group / provider cover). ``fetch_fn(url) -> (bytes|None, mime|None)``;
it is expected to return ``(None, None)`` on a 404, which is how a release
with no art of its own advances to the fallback. ``min_bytes`` defaults to 0
(accept any non-empty image) to preserve the fallback path's prior behaviour;
callers can raise it to reject placeholder/error images. Returns
``(bytes|None, mime|None, url_used|None)``. Never raises for a missing cover —
a failed candidate just advances to the next, so coverage never regresses."""
candidates = []
release_url = caa_front_url(release_mbid, "release", size) if release_mbid else None
if release_url:
candidates.append(release_url)
if fallback_url and fallback_url not in candidates:
candidates.append(fallback_url)
for url in candidates:
try:
data, mime = fetch_fn(url)
except Exception:
data, mime = None, None
if data and len(data) > min_bytes:
return data, mime, url
return None, None, None
__all__ = ["COVER_ART_ARCHIVE_URL", "caa_front_url", "fetch_release_preferred_art"]