soulsync/tests/metadata/test_metadata_discography.py
BoulderBadgeDad e7814e0acf #877: Download Discography filters mirror Artist Detail (fix dead EPs + add Live/Comp/Featured)
The Download Discography modal exposed only Albums/EPs/Singles, its EPs toggle did
nothing, and Live/Compilations/Featured were missing — so you couldn't fine-filter
a bulk download the way Artist Detail lets you browse.

Root cause: the modal's endpoint (/api/artist/<id>/discography) used the base
get_artist_discography, which lumps EPs into singles, and the modal only read
{albums, singles} — so the EPs bucket was always empty (dead toggle). It also had
no content-type (Live/Compilation/Featured) classification at all.

- Backend: the endpoint now uses get_artist_detail_discography — the SAME split
  Artist Detail uses — and returns a separate `eps` list.
- Frontend: read `eps`; tag each card with data-is-live/compilation/featured via a
  new shared _classifyReleaseContent() (also adopted by the Artist Detail cards so
  the two can't drift); add Live/Compilations/Featured filter buttons; combined
  category+content filtering. The download payload is built from VISIBLE checked
  cards, so every toggle now actually changes what downloads.
- Regression test: get_artist_detail_discography splits an EP into the eps bucket.
2026-06-15 21:30:55 -07:00

756 lines
28 KiB
Python

import sys
import types
import sqlite3
import pytest
if "spotipy" not in sys.modules:
spotipy = types.ModuleType("spotipy")
class _DummySpotify:
def __init__(self, *args, **kwargs):
pass
oauth2 = types.ModuleType("spotipy.oauth2")
class _DummyOAuth:
def __init__(self, *args, **kwargs):
pass
spotipy.Spotify = _DummySpotify
oauth2.SpotifyOAuth = _DummyOAuth
oauth2.SpotifyClientCredentials = _DummyOAuth
spotipy.oauth2 = oauth2
sys.modules["spotipy"] = spotipy
sys.modules["spotipy.oauth2"] = oauth2
if "config.settings" not in sys.modules:
config_pkg = types.ModuleType("config")
settings_mod = types.ModuleType("config.settings")
class _DummyConfigManager:
def get(self, key, default=None):
return default
def get_active_media_server(self):
return "primary"
settings_mod.config_manager = _DummyConfigManager()
config_pkg.settings = settings_mod
sys.modules["config"] = config_pkg
sys.modules["config.settings"] = settings_mod
from core.metadata import registry as metadata_registry
from core.metadata import completion as metadata_completion
from core.metadata import discography as metadata_discography
from core.metadata.lookup import MetadataLookupOptions
from database.music_database import MusicDatabase
@pytest.fixture(autouse=True)
def _clear_metadata_client_cache():
metadata_registry.clear_cached_metadata_clients()
yield
metadata_registry.clear_cached_metadata_clients()
class _FakeSourceClient:
def __init__(self, album_results=None, artist_search_results=None, discography_results=None):
self.album_results = list(album_results or [])
self.artist_search_results = list(artist_search_results or [])
self.discography_results = list(discography_results or [])
self.album_calls = []
self.artist_search_calls = []
self.discography_calls = []
self.track_search_calls = []
def get_artist_albums(self, artist_id, **kwargs):
self.album_calls.append((artist_id, dict(kwargs)))
return list(self.album_results)
def search_artists(self, query, **kwargs):
self.artist_search_calls.append((query, dict(kwargs)))
return list(self.artist_search_results)
def search_discography(self, query, **kwargs):
self.discography_calls.append((query, dict(kwargs)))
return list(self.discography_results)
def search_tracks(self, query, **kwargs):
self.track_search_calls.append((query, dict(kwargs)))
return []
def get_album_tracks(self, album_id, **kwargs):
self.album_calls.append((album_id, dict(kwargs)))
return {"items": list(self.album_results)}
def _album(album_id, name, release_date, album_type="album"):
return types.SimpleNamespace(
id=album_id,
name=name,
release_date=release_date,
album_type=album_type,
image_url=f"https://img.example/{album_id}.jpg",
total_tracks=12,
external_urls={"spotify": f"https://example/{album_id}"},
artist_ids=["artist-1"],
)
def _artist(artist_id, name):
return types.SimpleNamespace(id=artist_id, name=name)
def test_get_artist_discography_uses_primary_then_fallback(monkeypatch):
deezer = _FakeSourceClient()
spotify = _FakeSourceClient(
album_results=[
_album("album-old", "Older Album", "2022-01-01"),
_album("single-one", "Single One", "2024-06-01", album_type="single"),
_album("album-new", "New Album", "2024-08-01"),
]
)
itunes = _FakeSourceClient()
clients = {
"deezer": deezer,
"spotify": spotify,
"itunes": itunes,
}
monkeypatch.setattr(metadata_registry, "get_primary_source", lambda spotify_client_factory=None: "deezer")
monkeypatch.setattr(metadata_registry, "get_source_priority", lambda primary: [primary, "spotify", "itunes"])
monkeypatch.setattr(metadata_registry, "get_client_for_source", lambda source, **kwargs: clients.get(source))
result = metadata_discography.get_artist_discography("artist-1", "Artist One", MetadataLookupOptions())
assert result["source"] == "spotify"
assert result["source_priority"] == ["deezer", "spotify", "itunes"]
assert [album["id"] for album in result["albums"]] == ["album-new", "album-old"]
assert [single["id"] for single in result["singles"]] == ["single-one"]
assert spotify.album_calls == [(
"artist-1",
{
"album_type": "album,single",
"limit": 50,
"allow_fallback": False,
"skip_cache": False,
"max_pages": 0,
},
)]
def test_get_artist_discography_uses_name_search_when_direct_lookup_missing(monkeypatch):
class _SearchThenAlbumClient(_FakeSourceClient):
def get_artist_albums(self, artist_id, **kwargs):
self.album_calls.append((artist_id, dict(kwargs)))
if artist_id == "deezer-artist-1":
return [_album("deezer-album-1", "Deezer Album", "2023-05-01")]
return []
deezer = _SearchThenAlbumClient(
artist_search_results=[_artist("deezer-artist-1", "Artist One")],
)
clients = {"deezer": deezer}
monkeypatch.setattr(metadata_registry, "get_primary_source", lambda spotify_client_factory=None: "deezer")
monkeypatch.setattr(metadata_registry, "get_source_priority", lambda primary: [primary])
monkeypatch.setattr(metadata_registry, "get_client_for_source", lambda source, **kwargs: clients.get(source))
result = metadata_discography.get_artist_discography("artist-1", "Artist One", MetadataLookupOptions())
assert result["source"] == "deezer"
assert [album["id"] for album in result["albums"]] == ["deezer-album-1"]
assert deezer.artist_search_calls == [("Artist One", {"limit": 5})]
assert deezer.album_calls == [
(
"artist-1",
{
"album_type": "album,single",
"limit": 50,
},
),
(
"deezer-artist-1",
{
"album_type": "album,single",
"limit": 50,
},
),
]
def test_get_artist_discography_respects_source_override_without_fallback(monkeypatch):
deezer = _FakeSourceClient()
itunes = _FakeSourceClient(album_results=[_album("itunes-album-1", "iTunes Album", "2024-02-01")])
spotify = _FakeSourceClient()
clients = {
"deezer": deezer,
"itunes": itunes,
"spotify": spotify,
}
monkeypatch.setattr(metadata_registry, "get_primary_source", lambda spotify_client_factory=None: "deezer")
monkeypatch.setattr(metadata_registry, "get_source_priority", lambda primary: [primary, "spotify", "itunes"])
monkeypatch.setattr(metadata_registry, "get_client_for_source", lambda source, **kwargs: clients.get(source))
result = metadata_discography.get_artist_discography(
"artist-1",
"Artist One",
MetadataLookupOptions(source_override="itunes", allow_fallback=False),
)
assert result["source"] == "itunes"
assert result["source_priority"] == ["itunes"]
assert [album["id"] for album in result["albums"]] == ["itunes-album-1"]
assert deezer.album_calls == []
assert spotify.album_calls == []
assert itunes.album_calls == [
(
"artist-1",
{
"album_type": "album,single",
"limit": 50,
},
)
]
def test_get_artist_discography_uses_hydrabase_fast_path_when_active(monkeypatch):
class _HydrabaseLikeClient(_FakeSourceClient):
def get_artist_albums(self, artist_id, **kwargs):
self.album_calls.append((artist_id, dict(kwargs)))
if artist_id == "hydrabase-artist-1":
return [
_album("hydrabase-album-1", "Hydra Album", "2024-03-01"),
_album("hydrabase-single-1", "Hydra Single", "2024-04-01", album_type="single"),
]
return []
hydrabase = _HydrabaseLikeClient(
artist_search_results=[_artist("hydrabase-artist-1", "Artist One")],
)
clients = {"deezer": None, "spotify": None, "itunes": None}
monkeypatch.setattr(metadata_registry, "get_primary_source", lambda spotify_client_factory=None: "deezer")
monkeypatch.setattr(metadata_registry, "get_source_priority", lambda primary: [primary, "spotify", "itunes", "hydrabase"])
def fake_get_client_for_source(source):
if source == "hydrabase":
return hydrabase
return clients.get(source)
monkeypatch.setattr(metadata_registry, "get_client_for_source", fake_get_client_for_source)
result = metadata_discography.get_artist_discography("artist-1", "Artist One", MetadataLookupOptions())
assert result["source"] == "hydrabase"
assert [album["id"] for album in result["albums"]] == ["hydrabase-album-1"]
assert [single["id"] for single in result["singles"]] == ["hydrabase-single-1"]
assert hydrabase.album_calls == [
(
"artist-1",
{
"album_type": "album,single",
"limit": 50,
},
),
(
"hydrabase-artist-1",
{
"album_type": "album,single",
"limit": 50,
},
)
]
assert hydrabase.artist_search_calls == [("Artist One", {"limit": 5})]
class _CompletionFakeDB:
def __init__(self, owned_tracks=1, expected_tracks=3, is_track=False):
self.owned_tracks = owned_tracks
self.expected_tracks = expected_tracks
self.is_track = is_track
self.album_calls = []
self.track_calls = []
def check_album_exists_with_completeness(self, **kwargs):
self.album_calls.append(dict(kwargs))
return (True, 0.9, self.owned_tracks, self.expected_tracks, self.owned_tracks >= self.expected_tracks, [])
def check_track_exists(self, **kwargs):
self.track_calls.append(dict(kwargs))
if self.is_track:
return (object(), 0.9)
return (None, 0.0)
def test_iter_artist_discography_completion_uses_primary_source_first(monkeypatch):
deezer = _FakeSourceClient()
spotify = _FakeSourceClient()
itunes = _FakeSourceClient()
deezer.album_results = [{"id": "release-1-track-1"}, {"id": "release-1-track-2"}]
spotify.album_results = [{"id": "release-1-track-1"}, {"id": "release-1-track-2"}, {"id": "release-1-track-3"}]
itunes.album_results = [{"id": "release-1-track-1"}]
clients = {
"deezer": deezer,
"spotify": spotify,
"itunes": itunes,
}
monkeypatch.setattr(metadata_registry, "get_primary_source", lambda spotify_client_factory=None: "deezer")
monkeypatch.setattr(metadata_registry, "get_source_priority", lambda primary: [primary, "spotify", "itunes"])
monkeypatch.setattr(metadata_registry, "get_client_for_source", lambda source, **kwargs: clients.get(source))
db = _CompletionFakeDB(owned_tracks=1, expected_tracks=2)
events = list(metadata_completion.iter_artist_discography_completion_events(
{
"albums": [{"id": "release-1", "name": "Album One", "total_tracks": 0}],
"singles": [],
},
artist_name="Artist One",
db=db,
))
assert events[0]["type"] == "start"
assert events[-1]["type"] == "complete"
assert events[1]["expected_tracks"] == 2
assert events[1]["status"] == "partial"
assert deezer.album_calls == [("release-1", {})]
assert spotify.album_calls == []
assert itunes.album_calls == []
assert db.album_calls and db.album_calls[0]["expected_track_count"] == 2
assert db.album_calls[0]["strict_discography_match"] is True
def test_iter_artist_discography_completion_respects_source_override(monkeypatch):
deezer = _FakeSourceClient()
spotify = _FakeSourceClient()
itunes = _FakeSourceClient()
deezer.album_results = [{"id": "release-2-track-1"}]
spotify.album_results = [{"id": "release-2-track-1"}, {"id": "release-2-track-2"}]
itunes.album_results = [{"id": "release-2-track-1"}, {"id": "release-2-track-2"}, {"id": "release-2-track-3"}]
clients = {
"deezer": deezer,
"spotify": spotify,
"itunes": itunes,
}
monkeypatch.setattr(metadata_registry, "get_primary_source", lambda spotify_client_factory=None: "deezer")
monkeypatch.setattr(metadata_registry, "get_source_priority", lambda primary: [primary, "spotify", "itunes"])
monkeypatch.setattr(metadata_registry, "get_client_for_source", lambda source, **kwargs: clients.get(source))
db = _CompletionFakeDB(owned_tracks=1, expected_tracks=3)
events = list(metadata_completion.iter_artist_discography_completion_events(
{
"albums": [{"id": "release-2", "name": "Album Two", "total_tracks": 0}],
"singles": [],
},
artist_name="Artist Two",
source_override="itunes",
db=db,
))
assert events[1]["expected_tracks"] == 3
assert itunes.album_calls == [("release-2", {})]
assert deezer.album_calls == []
assert spotify.album_calls == []
def test_artist_discography_completion_uses_strict_matching_for_eps(monkeypatch):
monkeypatch.setattr(metadata_registry, "get_primary_source", lambda spotify_client_factory=None: "deezer")
monkeypatch.setattr(metadata_registry, "get_source_priority", lambda primary: [primary])
monkeypatch.setattr(metadata_registry, "get_client_for_source", lambda source_name, **kwargs: None)
db = _CompletionFakeDB(owned_tracks=1, expected_tracks=2)
events = list(metadata_completion.iter_artist_discography_completion_events(
{
"albums": [],
"singles": [{
"id": "ep-1",
"name": "Original Motion Picture Soundtrack EP",
"album_type": "ep",
"total_tracks": 2,
}],
},
artist_name="Composer One",
db=db,
))
assert events[1]["type"] == "single_completion"
assert db.album_calls[0]["strict_discography_match"] is True
def test_strict_discography_matching_rejects_distinct_soundtrack_siblings():
db = object.__new__(MusicDatabase)
album = types.SimpleNamespace(
title="Star Wars: Episode I - The Phantom Menace (Original Motion Picture Soundtrack)",
artist_name="John Williams",
track_count=17,
)
confidence = db._calculate_album_confidence(
"Star Wars: Episode II - Attack of the Clones (Original Motion Picture Soundtrack)",
"John Williams",
album,
expected_track_count=13,
strict_discography_match=True,
)
assert confidence == 0.0
def test_strict_discography_matching_allows_same_soundtrack_title():
db = object.__new__(MusicDatabase)
album = types.SimpleNamespace(
title="Star Wars: Episode I - The Phantom Menace (Original Motion Picture Soundtrack)",
artist_name="John Williams",
track_count=17,
)
confidence = db._calculate_album_confidence(
"Star Wars: Episode I - The Phantom Menace (Original Motion Picture Soundtrack)",
"John Williams",
album,
expected_track_count=17,
strict_discography_match=True,
)
assert confidence >= 0.9
def test_non_strict_album_matching_keeps_edition_behavior():
db = object.__new__(MusicDatabase)
album = types.SimpleNamespace(
title="DAMN. (Deluxe Edition)",
artist_name="Kendrick Lamar",
track_count=14,
)
confidence = db._calculate_album_confidence(
"DAMN.",
"Kendrick Lamar",
album,
expected_track_count=14,
strict_discography_match=False,
)
assert confidence >= 0.9
def test_strict_discography_matching_does_not_change_normal_albums():
db = object.__new__(MusicDatabase)
album = types.SimpleNamespace(
title="DAMN. (Deluxe Edition)",
artist_name="Kendrick Lamar",
track_count=14,
)
confidence = db._calculate_album_confidence(
"DAMN.",
"Kendrick Lamar",
album,
expected_track_count=14,
strict_discography_match=True,
)
assert confidence >= 0.9
def test_iter_artist_discography_completion_uses_release_artist_metadata(monkeypatch):
source = _FakeSourceClient()
clients = {"deezer": source}
monkeypatch.setattr(metadata_registry, "get_primary_source", lambda spotify_client_factory=None: "deezer")
monkeypatch.setattr(metadata_registry, "get_source_priority", lambda primary: [primary])
monkeypatch.setattr(metadata_registry, "get_client_for_source", lambda source_name, **kwargs: clients.get(source_name))
db = _CompletionFakeDB(owned_tracks=1, expected_tracks=2)
events = list(metadata_completion.iter_artist_discography_completion_events(
{
"albums": [{
"id": "release-3",
"name": "Album Three",
"artist_name": "Explicit Artist",
"total_tracks": 2,
}],
"singles": [],
},
artist_name="Unknown Artist",
db=db,
))
assert events[0]["artist_name"] == "Explicit Artist"
assert events[1]["name"] == "Album Three"
assert db.album_calls[0]["artist"] == "Explicit Artist"
assert source.track_search_calls == []
def test_get_artist_detail_discography_classifies_release_types(monkeypatch):
monkeypatch.setattr(
"core.metadata.discography.get_artist_discography",
lambda artist_id, artist_name='', options=None: {
"albums": [
{
"id": "album-1",
"name": "Album One",
"album_type": "album",
"image_url": "https://img.example/album-1.jpg",
"release_date": "2024-01-05",
"total_tracks": 10,
}
],
"singles": [
{
"id": "ep-1",
"name": "EP One",
"album_type": "ep",
"image_url": "https://img.example/ep-1.jpg",
"release_date": "2023-06-01",
"total_tracks": 6,
},
{
"id": "single-1",
"name": "Single One",
"album_type": "single",
"image_url": "https://img.example/single-1.jpg",
"release_date": "2022-03-10",
"total_tracks": 1,
},
],
"source": "deezer",
"source_priority": ["deezer", "spotify"],
},
)
result = metadata_discography.get_artist_detail_discography("artist-1", "Artist One", MetadataLookupOptions())
assert result["success"] is True
assert result["source"] == "deezer"
assert result["source_priority"] == ["deezer", "spotify"]
assert [album["id"] for album in result["albums"]] == ["album-1"]
assert [ep["id"] for ep in result["eps"]] == ["ep-1"]
assert [single["id"] for single in result["singles"]] == ["single-1"]
assert result["albums"][0]["title"] == "Album One"
assert result["albums"][0]["owned"] is None
assert result["albums"][0]["track_completion"] == "checking"
def test_get_artist_detail_discography_dedups_variant_releases(monkeypatch):
monkeypatch.setattr(
"core.metadata.discography.get_artist_discography",
lambda artist_id, artist_name='', options=None: {
"albums": [
{
"id": "album-standard",
"name": "Variant Album",
"album_type": "album",
"image_url": "https://img.example/standard.jpg",
"release_date": "2024-01-05",
"total_tracks": 10,
},
{
"id": "album-swedish",
"name": "Variant Album (Swedish Edition)",
"album_type": "album",
"image_url": "https://img.example/swedish.jpg",
"release_date": "2024-01-05",
"total_tracks": 12,
},
{
"id": "album-remaster",
"name": "Variant Album (2023 Abbey Road Remaster)",
"album_type": "album",
"image_url": "https://img.example/remaster.jpg",
"release_date": "2024-01-05",
"total_tracks": 10,
},
],
"singles": [],
"source": "deezer",
"source_priority": ["deezer", "spotify"],
},
)
result = metadata_discography.get_artist_detail_discography("artist-1", "Artist One", MetadataLookupOptions())
assert result["success"] is True
assert [album["id"] for album in result["albums"]] == ["album-standard"]
assert result["albums"][0]["title"] == "Variant Album"
assert result["albums"][0]["track_count"] == 10
def test_get_artist_detail_discography_keeps_variants_when_dedup_disabled(monkeypatch):
"""MetadataLookupOptions.dedup_variants=False is the source-only artist
detail code path — used so the standalone /artist-detail page can show
every release the source returns (matching the retired inline Artists
page behaviour)."""
monkeypatch.setattr(
"core.metadata.discography.get_artist_discography",
lambda artist_id, artist_name='', options=None: {
"albums": [
{
"id": "album-standard",
"name": "Variant Album",
"album_type": "album",
"image_url": "https://img.example/standard.jpg",
"release_date": "2024-01-05",
"total_tracks": 10,
},
{
"id": "album-swedish",
"name": "Variant Album (Swedish Edition)",
"album_type": "album",
"image_url": "https://img.example/swedish.jpg",
"release_date": "2024-01-05",
"total_tracks": 12,
},
{
"id": "album-remaster",
"name": "Variant Album (2023 Abbey Road Remaster)",
"album_type": "album",
"image_url": "https://img.example/remaster.jpg",
"release_date": "2024-01-05",
"total_tracks": 10,
},
],
"singles": [],
"source": "deezer",
"source_priority": ["deezer", "spotify"],
},
)
result = metadata_discography.get_artist_detail_discography(
"artist-1",
"Artist One",
MetadataLookupOptions(dedup_variants=False),
)
assert result["success"] is True
assert [album["id"] for album in result["albums"]] == [
"album-standard",
"album-swedish",
"album-remaster",
]
def test_get_artist_discography_keeps_provider_artist_ids(monkeypatch):
class _SpotifyArtistIdClient(_FakeSourceClient):
def get_artist_albums(self, artist_id, **kwargs):
self.album_calls.append((artist_id, dict(kwargs)))
return [
types.SimpleNamespace(
id="spotify-release-1",
name="Spotify Album",
release_date="2024-01-01",
album_type="album",
image_url="https://img.example/spotify-release-1.jpg",
total_tracks=9,
external_urls={"spotify": "https://example/spotify-release-1"},
artist_ids=["7wzRaLHNSWIG8ZHK2hQljt"],
)
]
spotify = _SpotifyArtistIdClient()
clients = {"spotify": spotify}
monkeypatch.setattr(metadata_registry, "get_primary_source", lambda spotify_client_factory=None: "spotify")
monkeypatch.setattr(metadata_registry, "get_source_priority", lambda primary: [primary])
monkeypatch.setattr(metadata_registry, "get_client_for_source", lambda source, **kwargs: clients.get(source))
result = metadata_discography.get_artist_discography("364555966", "Amarok", MetadataLookupOptions())
assert result["source"] == "spotify"
assert [album["id"] for album in result["albums"]] == ["spotify-release-1"]
assert spotify.album_calls == [
(
"364555966",
{
"album_type": "album,single",
"limit": 50,
"allow_fallback": False,
"skip_cache": False,
"max_pages": 0,
},
),
]
def test_get_artist_discography_prefers_source_specific_artist_ids(monkeypatch):
class _SourceIdClient(_FakeSourceClient):
def __init__(self, source_id, *args, **kwargs):
super().__init__(*args, **kwargs)
self.source_id = source_id
def get_artist_albums(self, artist_id, **kwargs):
self.album_calls.append((artist_id, dict(kwargs)))
if artist_id == self.source_id:
return [
_album(f"{self.source_id}-album-1", f"{self.source_id} Album", "2024-01-01")
]
return []
spotify = _SourceIdClient("spotify-artist-1")
deezer = _SourceIdClient("deezer-artist-1")
clients = {
"spotify": spotify,
"deezer": deezer,
}
monkeypatch.setattr(metadata_registry, "get_primary_source", lambda spotify_client_factory=None: "spotify")
monkeypatch.setattr(metadata_registry, "get_source_priority", lambda primary: [primary, "deezer"])
monkeypatch.setattr(metadata_registry, "get_client_for_source", lambda source, **kwargs: clients.get(source))
result = metadata_discography.get_artist_discography(
"artist-1",
"Artist One",
MetadataLookupOptions(
artist_source_ids={
"spotify": "spotify-artist-1",
"deezer": "deezer-artist-1",
}
),
)
assert result["source"] == "spotify"
assert [album["id"] for album in result["albums"]] == ["spotify-artist-1-album-1"]
assert spotify.album_calls == [
(
"spotify-artist-1",
{
"album_type": "album,single",
"limit": 50,
"allow_fallback": False,
"skip_cache": False,
"max_pages": 0,
},
)
]
assert deezer.album_calls == []
def test_get_artist_detail_discography_splits_eps_from_singles(monkeypatch):
"""#877: get_artist_detail_discography must put EPs in their OWN bucket (not
lumped into singles). The Download Discography modal now reads this split, so
its EPs filter has cards to act on and stays in sync with Artist Detail."""
spotify = _FakeSourceClient(
album_results=[
_album("a1", "An Album", "2024-01-01", album_type="album"),
_album("e1", "An EP", "2024-02-01", album_type="ep"),
_album("s1", "A Single", "2024-03-01", album_type="single"),
]
)
clients = {"deezer": _FakeSourceClient(), "spotify": spotify, "itunes": _FakeSourceClient()}
monkeypatch.setattr(metadata_registry, "get_primary_source", lambda spotify_client_factory=None: "deezer")
monkeypatch.setattr(metadata_registry, "get_source_priority", lambda primary: [primary, "spotify", "itunes"])
monkeypatch.setattr(metadata_registry, "get_client_for_source", lambda source, **kwargs: clients.get(source))
result = metadata_discography.get_artist_detail_discography("artist-1", "Artist One", MetadataLookupOptions())
assert [r["id"] for r in result["albums"]] == ["a1"]
assert [r["id"] for r in result["eps"]] == ["e1"]
assert [r["id"] for r in result["singles"]] == ["s1"]