soulsync/tests/wishlist/test_image_enrichment.py
BoulderBadgeDad b683c7fb50 wishlist: render library-sourced art (album + artist) — fix blank wishlist images
root cause: the library stores album/artist art as media-server RELATIVE paths (Plex
/library/metadata/.., Jellyfin /Items/.., Navidrome /rest/..), which don't render in a browser
<img>. normal wishlist items carry Spotify CDN urls so they show fine, but LIBRARY-sourced
items — dead-file re-downloads and preview-clip re-fetches — carry the raw relative path, so
their album art came up blank. and the nebula only had artist photos for WATCHLISTED artists,
so non-watchlist orbs showed initials.

fix on READ in the wishlist tracks endpoint (so it also repairs items already in the wishlist,
no re-run needed), using the library data we already have:
  - normalize each track's album.images url that needs it — relative/internal only, via the
    canonical normalize_image_url; CDN urls are left untouched so already-rendering items can't
    regress.
  - build an artist-name -> normalized library-photo map and return it; the nebula seeds its
    artist-image map from it (every wishlist artist), with curated watchlist photos overriding.

8 tests (predicate: relative/internal fixed, CDN untouched; album normalize in-place; artist
map build/skip-empty/idempotent/graceful). 237 wishlist+repair+JS tests green, ruff clean.
2026-06-27 17:35:28 -07:00

91 lines
4 KiB
Python

"""Wishlist art enrichment (read-path): library-sourced items (re-downloads, preview-clip
re-fetches) store media-server RELATIVE thumb paths that don't render in a browser, and the
nebula only has artist photos for watchlisted artists. _enrich_wishlist_images fixes both on
read — normalizing relative/internal image URLs (leaving CDN URLs untouched) and building an
artist-name -> library-photo map — so even items already sitting in the wishlist get fixed."""
from __future__ import annotations
import sqlite3
import pytest
from core.wishlist import routes
class _DB:
def __init__(self, artists):
self._conn = sqlite3.connect(":memory:")
self._conn.execute("CREATE TABLE artists (name TEXT, thumb_url TEXT)")
self._conn.executemany("INSERT INTO artists VALUES (?, ?)", artists)
self._conn.commit()
def _get_connection(self):
return self._conn
@pytest.fixture(autouse=True)
def _stub_normalize(monkeypatch):
# Deterministic stand-in for the real Plex/Jellyfin URL rebuild.
monkeypatch.setattr(routes, "normalize_image_url", lambda u: f"PROXY({u})")
def test_needs_image_fix_predicate():
assert routes._needs_image_fix("/library/metadata/1/thumb/2") is True
assert routes._needs_image_fix("/Items/x/Images/Primary") is True
assert routes._needs_image_fix("http://localhost:32400/library/x") is True
assert routes._needs_image_fix("https://i.scdn.co/image/ab") is False
assert routes._needs_image_fix("https://is1.mzstatic.com/600x600bb.jpg") is False
assert routes._needs_image_fix("") is False
assert routes._needs_image_fix(None) is False
def test_relative_album_image_is_normalized():
tracks = [{"artist_name": "A",
"spotify_data": {"album": {"images": [{"url": "/library/metadata/9/thumb/1"}]}}}]
routes._enrich_wishlist_images(tracks, _DB([]))
assert tracks[0]["spotify_data"]["album"]["images"][0]["url"] == "PROXY(/library/metadata/9/thumb/1)"
def test_cdn_album_image_is_left_untouched():
"""Items that already render must not change — guards against regressing normal wishlist art."""
url = "https://i.scdn.co/image/ab67616d"
tracks = [{"artist_name": "A", "spotify_data": {"album": {"images": [{"url": url}]}}}]
routes._enrich_wishlist_images(tracks, _DB([]))
assert tracks[0]["spotify_data"]["album"]["images"][0]["url"] == url
def test_builds_artist_image_map_from_library():
tracks = [
{"artist_name": "Modest Mouse", "spotify_data": {"album": {"images": []}}},
{"artist_name": "Unknown Artist", "spotify_data": {}}, # skipped
]
db = _DB([("Modest Mouse", "/library/metadata/111/thumb/9"),
("Other Band", "/library/metadata/222/thumb/9")]) # not in wishlist → not returned
amap = routes._enrich_wishlist_images(tracks, db)
assert amap == {"modest mouse": "PROXY(/library/metadata/111/thumb/9)"}
def test_artist_with_empty_thumb_is_omitted():
tracks = [{"artist_name": "NoArt", "spotify_data": {"album": {"images": []}}}]
amap = routes._enrich_wishlist_images(tracks, _DB([("NoArt", "")]))
assert amap == {}
def test_already_proxied_artist_thumb_not_double_wrapped():
"""A library thumb that's already a CDN/proxy URL passes through unchanged (idempotent)."""
tracks = [{"artist_name": "B", "spotify_data": {"album": {"images": []}}}]
amap = routes._enrich_wishlist_images(tracks, _DB([("B", "https://i.scdn.co/image/cdn")]))
assert amap == {"b": "https://i.scdn.co/image/cdn"}
def test_handles_string_or_missing_spotify_data_gracefully():
tracks = [
{"artist_name": "A", "spotify_data": "not-a-dict"},
{"artist_name": "B"},
{"spotify_data": {"album": {"images": [{"url": "/library/x"}]}}}, # no artist_name
]
amap = routes._enrich_wishlist_images(tracks, _DB([("A", "/library/a")]))
# third track's relative album image still gets fixed
assert tracks[2]["spotify_data"]["album"]["images"][0]["url"] == "PROXY(/library/x)"
assert amap == {"a": "PROXY(/library/a)"}