Lets users pick which providers' cover art to use and in what priority, generalizing the single prefer_caa_art toggle into an ordered, mix-and-match list (Sokhi's request). Fully opt-in: default album_art_order is [], so every existing install is byte-for-byte unchanged until the user enables sources. How it works: - Per album, walk the user's ordered sources top-to-bottom; the first source that actually has THIS album's cover wins. A miss falls through to the next; if all miss, the download's own art is kept (today's default). The worst case is always exactly the cover you'd get today -- never wrong art, never an error into the download. - Connection-gated: a source is only tried when the user is connected to it (free sources CAA/Deezer/iTunes/AudioDB always; Spotify only when authenticated). Tidal/Qobuz/HiFi deferred (cover-URL construction + no clean core accessor -- not shipping unverified extraction). - Album-match validated: a source's art is used only when the album it returns matches the requested artist+album (significant-token subset, tolerant of Deluxe/Remastered/articles/feat./multi-artist). A loose top search hit for a different record is treated as a miss -> guarantees no wrong-album art. - The list supersedes the legacy prefer_caa_art toggle: when album_art_order is non-empty it is the sole authority (add 'caa' to the list to use Cover Art Archive), and prefer_caa_art is neutralized for both the embedded-tag art and cover.jpg paths. With an empty list, prefer_caa_art behaves exactly as before. Implementation: - core/metadata/art_sources.py: pure resolver -- effective_art_order (config + legacy back-compat) and resolve_cover_art (ordered walk + fallback, exception-safe per source). No network/config/DB; fully unit-testable. - core/metadata/art_lookup.py: availability gating, per-source lookups against existing clients (Deezer/iTunes/AudioDB/Spotify search + CAA via MBID), album-match validation, per-album caching, and select_preferred_art_url -- the single gate the pipeline calls (no-op unless an explicit list is set). - core/metadata/artwork.py: wired into embed_album_art_metadata and download_cover_art, gated so no configured list == current behavior. - web_server.py: GET /api/metadata/art-sources (connected sources only). - config/settings.py: default album_art_order: []. - webui (index.html + settings.js): reorderable list in Core Features reusing the hybrid-source-list pattern + real service logos (with emoji fallback); load/save wired through the existing metadata_enhancement settings flow. loadArtSourceOrder populates the saved order synchronously (filtered to known sources, not availability) so a save before the availability fetch resolves, or a temporarily-disconnected source, can never wipe the saved order. Tests: 40 unit/seam tests (resolver ordering/fallback/back-compat, availability, per-source extraction, album-match validation incl. wrong-album/wrong-artist rejection, caching, exception-safety, the off-by-default gate). Full metadata suite still green (610 passed) -- the gated integration changes nothing when no list is configured. Note: the settings UI (DOM-heavy, not unit-testable in the JS harness) and the live per-source art-fetch quality are validated by manual testing.
106 lines
4.2 KiB
Python
106 lines
4.2 KiB
Python
"""Cover-art source selection.
|
|
|
|
Picks album cover art from a user-ordered list of sources, falling back down
|
|
the list and finally to the download's own art when nothing in the list
|
|
resolves. This generalizes the legacy single ``prefer_caa_art`` toggle into an
|
|
ordered, mix-and-match preference (Sokhi's request) while preserving today's
|
|
behavior byte-for-byte when no order is configured.
|
|
|
|
The module is deliberately pure and import-light: the ordering + fallback
|
|
contract is unit-testable without network, config, or a DB. The actual
|
|
per-source art lookups are injected as callables (a registry the caller
|
|
builds), so this module never imports a metadata client — that keeps the
|
|
selection logic fast to test and impossible to break with a client-side
|
|
network change.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from typing import Callable, Optional, Sequence, Tuple
|
|
|
|
# Sources we can reliably pull album cover art from, in a sensible default
|
|
# priority. This is the set the UI offers and that ``effective_art_order``
|
|
# accepts — anything else in a saved order is filtered out.
|
|
#
|
|
# Genius (lyrics) and Last.fm (deprecated/unreliable images) are excluded — no
|
|
# dependable album covers. Tidal/Qobuz/HiFi are deferred: their album lookups
|
|
# return IDs that need cover-URL construction and they lack a clean core-side
|
|
# client accessor, so rather than ship extraction that silently yields nothing
|
|
# we add them once that's verified. The current set covers the universally
|
|
# available free sources plus Spotify (the common connected account source).
|
|
ART_CAPABLE_SOURCES: Tuple[str, ...] = (
|
|
"caa", "deezer", "itunes", "spotify", "audiodb",
|
|
)
|
|
|
|
# Minimum byte size for a fetched image to count as a real cover. Mirrors the
|
|
# existing Cover Art Archive guard in ``artwork.py`` (a 1x1 pixel, a
|
|
# placeholder, or a truncated download is a miss, not a hit).
|
|
MIN_VALID_ART_BYTES = 1000
|
|
|
|
|
|
def effective_art_order(
|
|
order,
|
|
*,
|
|
prefer_caa_art: bool = False,
|
|
) -> list:
|
|
"""Resolve the configured art-source order into a concrete priority list.
|
|
|
|
Rules (in priority):
|
|
- A configured non-empty list wins. It's lower-cased, trimmed, filtered to
|
|
known art-capable sources, and de-duplicated (first occurrence kept).
|
|
- An empty / missing / all-invalid list preserves **legacy behavior**:
|
|
``['caa']`` when ``prefer_caa_art`` is on (Cover Art Archive first, then
|
|
the download's own art), else ``[]`` (use the download's own art only).
|
|
|
|
The empty-list case is what makes the feature non-breaking: an install that
|
|
has never touched the new setting resolves to exactly today's logic.
|
|
"""
|
|
if isinstance(order, (list, tuple)):
|
|
seen = set()
|
|
deduped = []
|
|
for raw in order:
|
|
name = str(raw).strip().lower()
|
|
if not name or name not in ART_CAPABLE_SOURCES or name in seen:
|
|
continue
|
|
seen.add(name)
|
|
deduped.append(name)
|
|
if deduped:
|
|
return deduped
|
|
return ["caa"] if prefer_caa_art else []
|
|
|
|
|
|
def resolve_cover_art(
|
|
order: Sequence[str],
|
|
lookup: Callable[[str], Optional[str]],
|
|
*,
|
|
validate: Optional[Callable[[str, str], bool]] = None,
|
|
) -> Tuple[Optional[str], Optional[str]]:
|
|
"""Walk ``order`` and return ``(art_url, source_name)`` for the first source
|
|
whose ``lookup(source)`` yields a URL that passes ``validate`` (if given).
|
|
|
|
Returns ``(None, None)`` when nothing in the list resolves — the caller then
|
|
falls back to the download's own art (today's default), so art is never
|
|
*worse* than before.
|
|
|
|
Robustness contract:
|
|
- ``lookup`` is ``source_name -> url | None``. A source that returns a
|
|
falsy value is skipped.
|
|
- An exception raised by ``lookup`` or ``validate`` for one source is
|
|
swallowed and treated as a miss, so a single flaky source can never
|
|
abort the whole chain (or the download).
|
|
"""
|
|
for source in order:
|
|
try:
|
|
url = lookup(source)
|
|
except Exception:
|
|
url = None
|
|
if not url:
|
|
continue
|
|
if validate is not None:
|
|
try:
|
|
if not validate(source, url):
|
|
continue
|
|
except Exception:
|
|
continue
|
|
return url, source
|
|
return None, None
|