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"]