df779c9e6d
* fix: Sync issues - AudioDB warmig + automatic sync skips * progress ui/ux + discovery and album fixes * artist fixes * several request level fixes and improvements * handle request fails + artist refresh + resilience fixes * fix format * fix stop sync fail + last.fn mbid issues + failures/validation reworks
489 lines
19 KiB
Python
489 lines
19 KiB
Python
import pytest
|
|
from unittest.mock import AsyncMock, MagicMock, PropertyMock
|
|
|
|
from repositories.lastfm_models import LastFmAlbum, LastFmSimilarArtist, LastFmTrack
|
|
from repositories.listenbrainz_models import ListenBrainzRecording, ListenBrainzReleaseGroup
|
|
from services.artist_discovery_service import ArtistDiscoveryService
|
|
|
|
|
|
def _make_lb_repo(configured: bool = True) -> MagicMock:
|
|
repo = MagicMock()
|
|
repo.is_configured.return_value = configured
|
|
repo.get_similar_artists = AsyncMock(return_value=[])
|
|
repo.get_artist_top_recordings = AsyncMock(return_value=[])
|
|
repo.get_artist_top_release_groups = AsyncMock(return_value=[])
|
|
return repo
|
|
|
|
|
|
def _make_lastfm_repo(enabled: bool = True) -> AsyncMock:
|
|
repo = AsyncMock()
|
|
repo.get_similar_artists = AsyncMock(return_value=[])
|
|
repo.get_artist_top_tracks = AsyncMock(return_value=[])
|
|
repo.get_artist_top_albums = AsyncMock(return_value=[])
|
|
return repo
|
|
|
|
|
|
def _make_prefs(enabled: bool = True) -> MagicMock:
|
|
prefs = MagicMock()
|
|
prefs.is_lastfm_enabled.return_value = enabled
|
|
return prefs
|
|
|
|
|
|
def _make_library_db() -> AsyncMock:
|
|
cache = AsyncMock()
|
|
cache.get_all_artist_mbids = AsyncMock(return_value=set())
|
|
return cache
|
|
|
|
|
|
def _make_memory_cache() -> AsyncMock:
|
|
cache = AsyncMock()
|
|
cache.get = AsyncMock(return_value=None)
|
|
cache.set = AsyncMock()
|
|
return cache
|
|
|
|
|
|
def _make_service(
|
|
lb_configured: bool = True,
|
|
lastfm_enabled: bool = True,
|
|
) -> tuple[ArtistDiscoveryService, MagicMock, AsyncMock, MagicMock]:
|
|
lb_repo = _make_lb_repo(configured=lb_configured)
|
|
lastfm_repo = _make_lastfm_repo(enabled=lastfm_enabled)
|
|
prefs = _make_prefs(enabled=lastfm_enabled)
|
|
library_db = _make_library_db()
|
|
memory_cache = _make_memory_cache()
|
|
mb_repo = AsyncMock()
|
|
lidarr_repo = AsyncMock()
|
|
|
|
svc = ArtistDiscoveryService(
|
|
listenbrainz_repo=lb_repo,
|
|
musicbrainz_repo=mb_repo,
|
|
library_db=library_db,
|
|
lidarr_repo=lidarr_repo,
|
|
memory_cache=memory_cache,
|
|
lastfm_repo=lastfm_repo,
|
|
preferences_service=prefs,
|
|
)
|
|
return svc, lb_repo, lastfm_repo, prefs
|
|
|
|
|
|
class TestGetSimilarArtistsSource:
|
|
@pytest.mark.asyncio
|
|
async def test_default_source_uses_listenbrainz(self):
|
|
svc, lb_repo, lastfm_repo, _ = _make_service()
|
|
|
|
result = await svc.get_similar_artists("mbid-123", count=5)
|
|
|
|
assert result.source == "listenbrainz"
|
|
lb_repo.get_similar_artists.assert_called_once()
|
|
lastfm_repo.get_similar_artists.assert_not_called()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_source_lastfm_calls_lastfm(self):
|
|
lastfm_similar = [
|
|
LastFmSimilarArtist(name="Artist A", mbid="mbid-a", match=0.9, url=""),
|
|
LastFmSimilarArtist(name="Artist B", mbid="mbid-b", match=0.8, url=""),
|
|
]
|
|
svc, lb_repo, lastfm_repo, _ = _make_service()
|
|
lastfm_repo.get_similar_artists.return_value = lastfm_similar
|
|
|
|
result = await svc.get_similar_artists("mbid-123", count=5, source="lastfm")
|
|
|
|
assert result.source == "lastfm"
|
|
lastfm_repo.get_similar_artists.assert_called_once()
|
|
lb_repo.get_similar_artists.assert_not_called()
|
|
assert len(result.similar_artists) == 2
|
|
assert result.similar_artists[0].name == "Artist A"
|
|
assert result.similar_artists[0].musicbrainz_id == "mbid-a"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_source_lastfm_filters_artists_without_mbid(self):
|
|
lastfm_similar = [
|
|
LastFmSimilarArtist(name="Has MBID", mbid="mbid-a", match=0.9, url=""),
|
|
LastFmSimilarArtist(name="No MBID", mbid=None, match=0.8, url=""),
|
|
LastFmSimilarArtist(name="Empty MBID", mbid="", match=0.7, url=""),
|
|
]
|
|
svc, _, lastfm_repo, _ = _make_service()
|
|
lastfm_repo.get_similar_artists.return_value = lastfm_similar
|
|
|
|
result = await svc.get_similar_artists("mbid-123", count=10, source="lastfm")
|
|
|
|
assert len(result.similar_artists) == 1
|
|
assert result.similar_artists[0].name == "Has MBID"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_source_lastfm_disabled_returns_not_configured(self):
|
|
svc, _, _, _ = _make_service(lastfm_enabled=False)
|
|
|
|
result = await svc.get_similar_artists("mbid-123", count=5, source="lastfm")
|
|
|
|
assert result.source == "lastfm"
|
|
assert result.configured is False
|
|
assert result.similar_artists == []
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_source_lastfm_handles_exception(self):
|
|
svc, _, lastfm_repo, _ = _make_service()
|
|
lastfm_repo.get_similar_artists.side_effect = Exception("API error")
|
|
|
|
result = await svc.get_similar_artists("mbid-123", count=5, source="lastfm")
|
|
|
|
assert result.source == "lastfm"
|
|
assert result.similar_artists == []
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_lastfm_exception_result_is_cached(self):
|
|
svc, _, lastfm_repo, _ = _make_service()
|
|
lastfm_repo.get_similar_artists.side_effect = Exception("API error")
|
|
|
|
await svc.get_similar_artists("mbid-123", count=5, source="lastfm")
|
|
|
|
assert svc._cache.set.await_count == 1
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_lb_exception_result_is_cached(self):
|
|
svc, lb_repo, _, _ = _make_service()
|
|
lb_repo.get_similar_artists.side_effect = Exception("LB error")
|
|
|
|
await svc.get_similar_artists("mbid-123", count=5)
|
|
|
|
assert svc._cache.set.await_count == 1
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_lb_not_configured_returns_not_configured(self):
|
|
svc, _, _, _ = _make_service(lb_configured=False)
|
|
|
|
result = await svc.get_similar_artists("mbid-123", count=5)
|
|
|
|
assert result.configured is False
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_source_lastfm_marks_in_library(self):
|
|
lastfm_similar = [
|
|
LastFmSimilarArtist(name="In Lib", mbid="lib-mbid", match=0.9, url=""),
|
|
LastFmSimilarArtist(name="Not In Lib", mbid="other-mbid", match=0.8, url=""),
|
|
]
|
|
svc, _, lastfm_repo, _ = _make_service()
|
|
lastfm_repo.get_similar_artists.return_value = lastfm_similar
|
|
svc._library_db.get_all_artist_mbids.return_value = {"lib-mbid"}
|
|
|
|
result = await svc.get_similar_artists("mbid-123", count=10, source="lastfm")
|
|
|
|
assert result.similar_artists[0].in_library is True
|
|
assert result.similar_artists[1].in_library is False
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_cache_key_includes_count_for_similar(self):
|
|
svc, lb_repo, _, _ = _make_service()
|
|
|
|
await svc.get_similar_artists("mbid-123", count=5)
|
|
await svc.get_similar_artists("mbid-123", count=10)
|
|
|
|
assert lb_repo.get_similar_artists.await_count == 2
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_same_count_hits_cache_for_similar(self):
|
|
svc, lb_repo, _, _ = _make_service()
|
|
svc._cache.get.side_effect = [
|
|
None,
|
|
MagicMock(similar_artists=[]),
|
|
]
|
|
|
|
await svc.get_similar_artists("mbid-123", count=5)
|
|
await svc.get_similar_artists("mbid-123", count=5)
|
|
|
|
assert lb_repo.get_similar_artists.await_count == 1
|
|
|
|
|
|
class TestGetTopSongsSource:
|
|
@pytest.mark.asyncio
|
|
async def test_source_lastfm_returns_tracks(self):
|
|
lastfm_tracks = [
|
|
LastFmTrack(name="Song A", artist_name="Artist", mbid="rec-a", playcount=5000),
|
|
LastFmTrack(name="Song B", artist_name="Artist", mbid="rec-b", playcount=3000),
|
|
]
|
|
svc, lb_repo, lastfm_repo, _ = _make_service()
|
|
lastfm_repo.get_artist_top_tracks.return_value = lastfm_tracks
|
|
|
|
result = await svc.get_top_songs("mbid-123", count=10, source="lastfm")
|
|
|
|
assert result.source == "lastfm"
|
|
assert result.configured is True
|
|
assert len(result.songs) == 2
|
|
assert result.songs[0].title == "Song A"
|
|
assert result.songs[0].listen_count == 5000
|
|
assert result.songs[1].title == "Song B"
|
|
lastfm_repo.get_artist_top_tracks.assert_called_once()
|
|
lb_repo.get_artist_top_recordings.assert_not_called()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_source_lastfm_disabled_returns_not_configured(self):
|
|
svc, _, _, _ = _make_service(lastfm_enabled=False)
|
|
|
|
result = await svc.get_top_songs("mbid-123", count=10, source="lastfm")
|
|
|
|
assert result.source == "lastfm"
|
|
assert result.songs == []
|
|
assert result.configured is False
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_source_lastfm_handles_exception(self):
|
|
svc, _, lastfm_repo, _ = _make_service()
|
|
lastfm_repo.get_artist_top_tracks.side_effect = Exception("API error")
|
|
|
|
result = await svc.get_top_songs("mbid-123", count=10, source="lastfm")
|
|
|
|
assert result.source == "lastfm"
|
|
assert result.songs == []
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_lastfm_exception_result_is_cached(self):
|
|
svc, _, lastfm_repo, _ = _make_service()
|
|
lastfm_repo.get_artist_top_tracks.side_effect = Exception("API error")
|
|
|
|
await svc.get_top_songs("mbid-123", count=10, source="lastfm")
|
|
|
|
assert svc._cache.set.await_count == 1
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_lb_exception_result_is_cached(self):
|
|
svc, lb_repo, _, _ = _make_service()
|
|
lb_repo.get_artist_top_recordings.side_effect = Exception("LB error")
|
|
|
|
await svc.get_top_songs("mbid-123", count=10)
|
|
|
|
assert svc._cache.set.await_count == 1
|
|
|
|
|
|
class TestGetTopAlbumsSource:
|
|
@pytest.mark.asyncio
|
|
async def test_source_lastfm_returns_albums(self):
|
|
lastfm_albums = [
|
|
LastFmAlbum(name="Album X", artist_name="Artist", mbid="alb-x", playcount=8000),
|
|
LastFmAlbum(name="Album Y", artist_name="Artist", mbid="alb-y", playcount=4000),
|
|
]
|
|
svc, lb_repo, lastfm_repo, _ = _make_service()
|
|
lastfm_repo.get_artist_top_albums.return_value = lastfm_albums
|
|
svc._lidarr_repo.get_library_mbids = AsyncMock(return_value={"alb-x"})
|
|
svc._lidarr_repo.get_requested_mbids = AsyncMock(return_value=set())
|
|
|
|
result = await svc.get_top_albums("mbid-123", count=10, source="lastfm")
|
|
|
|
assert result.source == "lastfm"
|
|
assert result.configured is True
|
|
assert len(result.albums) == 2
|
|
assert result.albums[0].title == "Album X"
|
|
assert result.albums[0].listen_count == 8000
|
|
assert result.albums[0].in_library is True
|
|
assert result.albums[1].in_library is False
|
|
lastfm_repo.get_artist_top_albums.assert_called_once()
|
|
lb_repo.get_artist_top_release_groups.assert_not_called()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_source_lastfm_disabled_returns_not_configured(self):
|
|
svc, _, _, _ = _make_service(lastfm_enabled=False)
|
|
|
|
result = await svc.get_top_albums("mbid-123", count=10, source="lastfm")
|
|
|
|
assert result.source == "lastfm"
|
|
assert result.albums == []
|
|
assert result.configured is False
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_source_lastfm_handles_exception(self):
|
|
svc, _, lastfm_repo, _ = _make_service()
|
|
lastfm_repo.get_artist_top_albums.side_effect = Exception("API error")
|
|
|
|
result = await svc.get_top_albums("mbid-123", count=10, source="lastfm")
|
|
|
|
assert result.source == "lastfm"
|
|
assert result.albums == []
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_lastfm_exception_result_is_cached(self):
|
|
svc, _, lastfm_repo, _ = _make_service()
|
|
lastfm_repo.get_artist_top_albums.side_effect = Exception("API error")
|
|
|
|
await svc.get_top_albums("mbid-123", count=10, source="lastfm")
|
|
|
|
assert svc._cache.set.await_count == 1
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_lb_exception_result_is_cached(self):
|
|
svc, lb_repo, _, _ = _make_service()
|
|
lb_repo.get_artist_top_release_groups.side_effect = Exception("LB error")
|
|
|
|
await svc.get_top_albums("mbid-123", count=10)
|
|
|
|
assert svc._cache.set.await_count == 1
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_lb_empty_result_is_cached(self):
|
|
svc, lb_repo, _, _ = _make_service()
|
|
lb_repo.get_artist_top_release_groups.return_value = []
|
|
|
|
await svc.get_top_albums("mbid-123", count=10)
|
|
|
|
assert svc._cache.set.await_count == 1
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_lb_empty_release_groups_falls_back_to_recordings(self):
|
|
svc, lb_repo, _, _ = _make_service()
|
|
lb_repo.get_artist_top_release_groups.return_value = []
|
|
lb_repo.get_artist_top_recordings.return_value = [
|
|
ListenBrainzRecording(
|
|
track_name="Track A1",
|
|
artist_name="Artist",
|
|
listen_count=12,
|
|
release_name="Album A",
|
|
release_mbid="rel-a",
|
|
),
|
|
ListenBrainzRecording(
|
|
track_name="Track A2",
|
|
artist_name="Artist",
|
|
listen_count=9,
|
|
release_name="Album A",
|
|
release_mbid="rel-a",
|
|
),
|
|
ListenBrainzRecording(
|
|
track_name="Track B1",
|
|
artist_name="Artist",
|
|
listen_count=7,
|
|
release_name="Album B",
|
|
release_mbid="rel-b",
|
|
),
|
|
]
|
|
|
|
svc._lidarr_repo.get_library_mbids = AsyncMock(return_value={"rg-a"})
|
|
svc._lidarr_repo.get_requested_mbids = AsyncMock(return_value={"rg-b"})
|
|
|
|
async def _resolve_release_group(release_mbid: str):
|
|
return {"rel-a": "rg-a", "rel-b": "rg-b"}.get(release_mbid)
|
|
|
|
svc._mb_repo.get_release_group_id_from_release = _resolve_release_group
|
|
|
|
result = await svc.get_top_albums("mbid-123", count=10)
|
|
|
|
assert len(result.albums) == 2
|
|
assert result.albums[0].title == "Album A"
|
|
assert result.albums[0].listen_count == 21
|
|
assert result.albums[0].release_group_mbid == "rg-a"
|
|
assert result.albums[0].in_library is True
|
|
assert result.albums[1].title == "Album B"
|
|
assert result.albums[1].release_group_mbid == "rg-b"
|
|
assert result.albums[1].requested is True
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_lb_top_albums_survive_lidarr_lookup_failure(self):
|
|
svc, lb_repo, _, _ = _make_service()
|
|
lb_repo.get_artist_top_release_groups.return_value = [
|
|
ListenBrainzReleaseGroup(
|
|
release_group_name="Album 1",
|
|
artist_name="Artist",
|
|
listen_count=42,
|
|
release_group_mbid="rg-1",
|
|
)
|
|
]
|
|
svc._lidarr_repo.get_library_mbids = AsyncMock(side_effect=Exception("lidarr down"))
|
|
svc._lidarr_repo.get_requested_mbids = AsyncMock(return_value=set())
|
|
|
|
result = await svc.get_top_albums("mbid-123", count=10)
|
|
|
|
assert len(result.albums) == 1
|
|
assert result.albums[0].title == "Album 1"
|
|
assert result.albums[0].in_library is False
|
|
assert result.albums[0].requested is False
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_source_lastfm_normalizes_mbids(self):
|
|
lastfm_albums = [
|
|
LastFmAlbum(name="Upper", artist_name="Artist", mbid="ALB-UPPER", playcount=100),
|
|
LastFmAlbum(name="Spaced", artist_name="Artist", mbid=" alb-spaced ", playcount=50),
|
|
LastFmAlbum(name="No MBID", artist_name="Artist", mbid=None, playcount=10),
|
|
]
|
|
svc, _, lastfm_repo, _ = _make_service()
|
|
lastfm_repo.get_artist_top_albums.return_value = lastfm_albums
|
|
svc._lidarr_repo.get_library_mbids = AsyncMock(return_value={"alb-upper"})
|
|
svc._lidarr_repo.get_requested_mbids = AsyncMock(return_value={"alb-spaced"})
|
|
|
|
result = await svc.get_top_albums("mbid-123", count=10, source="lastfm")
|
|
|
|
assert result.albums[0].release_group_mbid == "alb-upper"
|
|
assert result.albums[0].in_library is True
|
|
assert result.albums[1].release_group_mbid == "alb-spaced"
|
|
assert result.albums[1].requested is True
|
|
assert result.albums[2].release_group_mbid is None
|
|
assert result.albums[2].in_library is False
|
|
assert result.albums[2].requested is False
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_source_lastfm_uses_raw_mbids_without_resolution(self):
|
|
lastfm_albums = [
|
|
LastFmAlbum(name="Album A", artist_name="Artist", mbid="release-mbid-a", playcount=100),
|
|
LastFmAlbum(name="Album B", artist_name="Artist", mbid="release-mbid-b", playcount=50),
|
|
]
|
|
svc, _, lastfm_repo, _ = _make_service()
|
|
lastfm_repo.get_artist_top_albums.return_value = lastfm_albums
|
|
svc._lidarr_repo.get_library_mbids = AsyncMock(return_value={"release-mbid-a"})
|
|
svc._lidarr_repo.get_requested_mbids = AsyncMock(return_value=set())
|
|
|
|
svc._mb_repo.get_release_group_id_from_release = AsyncMock(
|
|
side_effect=AssertionError("Resolution should not be called")
|
|
)
|
|
|
|
result = await svc.get_top_albums("mbid-123", count=10, source="lastfm")
|
|
|
|
assert result.albums[0].release_group_mbid == "release-mbid-a"
|
|
assert result.albums[0].in_library is True
|
|
assert result.albums[1].release_group_mbid == "release-mbid-b"
|
|
assert result.albums[1].in_library is False
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_source_lastfm_keeps_raw_mbid_directly(self):
|
|
lastfm_albums = [
|
|
LastFmAlbum(name="Album A", artist_name="Artist", mbid="already-rg-mbid", playcount=100),
|
|
]
|
|
svc, _, lastfm_repo, _ = _make_service()
|
|
lastfm_repo.get_artist_top_albums.return_value = lastfm_albums
|
|
svc._lidarr_repo.get_library_mbids = AsyncMock(return_value=set())
|
|
svc._lidarr_repo.get_requested_mbids = AsyncMock(return_value=set())
|
|
|
|
result = await svc.get_top_albums("mbid-123", count=10, source="lastfm")
|
|
|
|
assert result.albums[0].release_group_mbid == "already-rg-mbid"
|
|
|
|
|
|
class TestGetTopSongsLastFmNoAlbumResolution:
|
|
@pytest.mark.asyncio
|
|
async def test_source_lastfm_returns_null_album_fields(self):
|
|
lastfm_tracks = [
|
|
LastFmTrack(name="Song A", artist_name="Artist", mbid="rec-a", playcount=5000),
|
|
LastFmTrack(name="Song B", artist_name="Artist", mbid="rec-b", playcount=3000),
|
|
]
|
|
svc, _, lastfm_repo, _ = _make_service()
|
|
lastfm_repo.get_artist_top_tracks.return_value = lastfm_tracks
|
|
|
|
result = await svc.get_top_songs("mbid-123", count=10, source="lastfm")
|
|
|
|
assert len(result.songs) == 2
|
|
assert result.source == "lastfm"
|
|
for song in result.songs:
|
|
assert song.release_group_mbid is None
|
|
assert song.release_name is None
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_source_lastfm_preserves_track_metadata(self):
|
|
lastfm_tracks = [
|
|
LastFmTrack(name="Song A", artist_name="Artist", mbid="rec-a", playcount=5000),
|
|
LastFmTrack(name="Song B", artist_name="Artist", mbid=None, playcount=3000),
|
|
]
|
|
svc, _, lastfm_repo, _ = _make_service()
|
|
lastfm_repo.get_artist_top_tracks.return_value = lastfm_tracks
|
|
|
|
result = await svc.get_top_songs("mbid-123", count=10, source="lastfm")
|
|
|
|
assert result.songs[0].title == "Song A"
|
|
assert result.songs[0].recording_mbid == "rec-a"
|
|
assert result.songs[0].listen_count == 5000
|
|
assert result.songs[1].title == "Song B"
|
|
assert result.songs[1].recording_mbid is None
|
|
assert result.songs[1].listen_count == 3000 |