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.
114 lines
4.3 KiB
Python
114 lines
4.3 KiB
Python
"""Unit tests for the pure cover-art source selection logic.
|
|
|
|
Pins the ordering + fallback + back-compat contract that the artwork
|
|
integration relies on. No network, config, or DB — the per-source lookups are
|
|
injected, so these tests are fast and deterministic.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from core.metadata.art_sources import (
|
|
ART_CAPABLE_SOURCES,
|
|
effective_art_order,
|
|
resolve_cover_art,
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# effective_art_order — config resolution + legacy back-compat
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_configured_order_wins_and_is_normalized():
|
|
assert effective_art_order(["Deezer", " CAA ", "iTunes"]) == ["deezer", "caa", "itunes"]
|
|
|
|
|
|
def test_unknown_sources_filtered_out():
|
|
# 'genius'/'lastfm' aren't art-capable; 'bogus' is unknown.
|
|
assert effective_art_order(["genius", "deezer", "bogus", "lastfm"]) == ["deezer"]
|
|
|
|
|
|
def test_duplicates_collapsed_keeping_first_position():
|
|
assert effective_art_order(["deezer", "caa", "deezer", "CAA"]) == ["deezer", "caa"]
|
|
|
|
|
|
def test_empty_order_with_prefer_caa_is_legacy_caa_first():
|
|
# Back-compat: an un-migrated install with prefer_caa_art on behaves as
|
|
# 'CAA first, then the download's own art' — exactly today's logic.
|
|
assert effective_art_order([], prefer_caa_art=True) == ["caa"]
|
|
assert effective_art_order(None, prefer_caa_art=True) == ["caa"]
|
|
|
|
|
|
def test_empty_order_without_prefer_caa_is_default_only():
|
|
# The critical non-breaking case: no list + no prefer_caa => empty order,
|
|
# so the caller uses the download's own art (today's default).
|
|
assert effective_art_order([], prefer_caa_art=False) == []
|
|
assert effective_art_order(None) == []
|
|
assert effective_art_order("not-a-list") == []
|
|
|
|
|
|
def test_all_invalid_entries_fall_back_to_legacy():
|
|
assert effective_art_order(["genius", "lastfm"], prefer_caa_art=True) == ["caa"]
|
|
assert effective_art_order(["genius", "lastfm"], prefer_caa_art=False) == []
|
|
|
|
|
|
def test_art_capable_sources_excludes_lyrics_only_sources():
|
|
assert "caa" in ART_CAPABLE_SOURCES
|
|
assert "deezer" in ART_CAPABLE_SOURCES
|
|
assert "genius" not in ART_CAPABLE_SOURCES
|
|
assert "lastfm" not in ART_CAPABLE_SOURCES
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# resolve_cover_art — ordered walk + fallback + robustness
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_first_source_with_art_wins():
|
|
art = {"caa": "http://caa/x.jpg", "deezer": "http://dz/y.jpg"}
|
|
url, src = resolve_cover_art(["deezer", "caa"], art.get)
|
|
assert (url, src) == ("http://dz/y.jpg", "deezer")
|
|
|
|
|
|
def test_falls_through_to_next_source_when_missing():
|
|
art = {"deezer": None, "caa": "http://caa/x.jpg"}
|
|
url, src = resolve_cover_art(["deezer", "caa"], art.get)
|
|
assert (url, src) == ("http://caa/x.jpg", "caa")
|
|
|
|
|
|
def test_returns_none_when_nothing_resolves():
|
|
url, src = resolve_cover_art(["deezer", "caa"], lambda _s: None)
|
|
assert (url, src) == (None, None)
|
|
|
|
|
|
def test_empty_order_returns_none_so_caller_uses_default():
|
|
url, src = resolve_cover_art([], lambda _s: "http://should/not/be/called.jpg")
|
|
assert (url, src) == (None, None)
|
|
|
|
|
|
def test_validate_rejection_skips_to_next_source():
|
|
art = {"deezer": "http://dz/tiny.jpg", "caa": "http://caa/big.jpg"}
|
|
# Pretend deezer's image fails validation (e.g. too small / placeholder).
|
|
def _validate(source, url):
|
|
return source != "deezer"
|
|
url, src = resolve_cover_art(["deezer", "caa"], art.get, validate=_validate)
|
|
assert (url, src) == ("http://caa/big.jpg", "caa")
|
|
|
|
|
|
def test_lookup_exception_is_treated_as_miss_not_fatal():
|
|
def _lookup(source):
|
|
if source == "deezer":
|
|
raise RuntimeError("network down")
|
|
return "http://caa/x.jpg"
|
|
url, src = resolve_cover_art(["deezer", "caa"], _lookup)
|
|
assert (url, src) == ("http://caa/x.jpg", "caa")
|
|
|
|
|
|
def test_validate_exception_is_treated_as_miss():
|
|
def _validate(source, url):
|
|
if source == "deezer":
|
|
raise ValueError("bad image header")
|
|
return True
|
|
art = {"deezer": "http://dz/x.jpg", "caa": "http://caa/x.jpg"}
|
|
url, src = resolve_cover_art(["deezer", "caa"], art.get, validate=_validate)
|
|
assert (url, src) == ("http://caa/x.jpg", "caa")
|