You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
SoulSync/tests/metadata/test_metadata_discography.py

757 lines
28 KiB

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