423 lines
17 KiB
Python
423 lines
17 KiB
Python
from __future__ import annotations
|
|
|
|
from unittest.mock import AsyncMock, MagicMock
|
|
|
|
import pytest
|
|
|
|
from repositories.navidrome_models import (
|
|
SubsonicAlbum,
|
|
SubsonicArtist,
|
|
SubsonicGenre,
|
|
SubsonicSearchResult,
|
|
SubsonicSong,
|
|
)
|
|
from services.navidrome_library_service import NavidromeLibraryService, _normalize, _clean_album_name
|
|
|
|
|
|
def _make_service() -> tuple[NavidromeLibraryService, MagicMock]:
|
|
repo = MagicMock()
|
|
repo.get_album_list = AsyncMock(return_value=[])
|
|
repo.get_album = AsyncMock()
|
|
repo.get_artists = AsyncMock(return_value=[])
|
|
repo.get_artist = AsyncMock()
|
|
repo.get_starred = AsyncMock(return_value=SubsonicSearchResult())
|
|
repo.get_genres = AsyncMock(return_value=[])
|
|
repo.search = AsyncMock(return_value=SubsonicSearchResult())
|
|
prefs = MagicMock()
|
|
prefs.get_advanced_settings.return_value = MagicMock()
|
|
service = NavidromeLibraryService(navidrome_repo=repo, preferences_service=prefs)
|
|
return service, repo
|
|
|
|
|
|
def _album(id: str = "al1", name: str = "Album", artist: str = "Artist",
|
|
year: int = 2020, song_count: int = 10, mbid: str = "") -> SubsonicAlbum:
|
|
return SubsonicAlbum(
|
|
id=id, name=name, artist=artist, year=year,
|
|
songCount=song_count, musicBrainzId=mbid,
|
|
)
|
|
|
|
|
|
def _artist(id: str = "ar1", name: str = "Artist", album_count: int = 3,
|
|
mbid: str = "") -> SubsonicArtist:
|
|
return SubsonicArtist(
|
|
id=id, name=name, albumCount=album_count, musicBrainzId=mbid,
|
|
)
|
|
|
|
|
|
def _song(id: str = "s1", title: str = "Song", album: str = "Album",
|
|
artist: str = "Artist", track: int = 1, duration: int = 200,
|
|
suffix: str = "mp3", bit_rate: int = 320) -> SubsonicSong:
|
|
return SubsonicSong(
|
|
id=id, title=title, album=album, artist=artist,
|
|
track=track, duration=duration, suffix=suffix, bitRate=bit_rate,
|
|
)
|
|
|
|
|
|
class TestGetAlbums:
|
|
@pytest.mark.asyncio
|
|
async def test_returns_mapped_summaries(self):
|
|
service, repo = _make_service()
|
|
repo.get_album_list = AsyncMock(return_value=[_album(id="a1", name="OK Computer")])
|
|
result = await service.get_albums()
|
|
assert len(result) == 1
|
|
assert result[0].navidrome_id == "a1"
|
|
assert result[0].name == "OK Computer"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_empty_list(self):
|
|
service, repo = _make_service()
|
|
result = await service.get_albums()
|
|
assert result == []
|
|
|
|
|
|
class TestGetAlbumDetail:
|
|
@pytest.mark.asyncio
|
|
async def test_maps_tracks(self):
|
|
service, repo = _make_service()
|
|
album = _album(id="a1", name="The Wall")
|
|
album = SubsonicAlbum(
|
|
id="a1", name="The Wall", artist="Pink Floyd",
|
|
year=1979, songCount=2, musicBrainzId="mb-a1",
|
|
song=[_song(id="s1", title="Comfortably Numb", track=1),
|
|
_song(id="s2", title="Another Brick", track=2)],
|
|
)
|
|
repo.get_album = AsyncMock(return_value=album)
|
|
result = await service.get_album_detail("a1")
|
|
assert result is not None
|
|
assert result.navidrome_id == "a1"
|
|
assert result.track_count == 2
|
|
assert result.tracks[0].title == "Comfortably Numb"
|
|
# Navidrome-native MBID is NOT exposed; only Lidarr-resolved MBIDs are canonical
|
|
assert result.musicbrainz_id is None
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_returns_none_on_exception(self):
|
|
service, repo = _make_service()
|
|
repo.get_album = AsyncMock(side_effect=RuntimeError("fail"))
|
|
result = await service.get_album_detail("a1")
|
|
assert result is None
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_fix_missing_track_numbers(self):
|
|
service, repo = _make_service()
|
|
album = SubsonicAlbum(
|
|
id="a1", name="Album",
|
|
song=[_song(id="s1", title="A", track=0), _song(id="s2", title="B", track=0)],
|
|
)
|
|
repo.get_album = AsyncMock(return_value=album)
|
|
result = await service.get_album_detail("a1")
|
|
assert result is not None
|
|
assert result.tracks[0].track_number == 1
|
|
assert result.tracks[1].track_number == 2
|
|
|
|
|
|
class TestGetArtists:
|
|
@pytest.mark.asyncio
|
|
async def test_maps_artist_summaries(self):
|
|
service, repo = _make_service()
|
|
repo.get_artists = AsyncMock(return_value=[_artist(id="ar1", name="Radiohead", mbid="mb-ar1")])
|
|
result = await service.get_artists()
|
|
assert len(result) == 1
|
|
assert result[0].navidrome_id == "ar1"
|
|
assert result[0].name == "Radiohead"
|
|
assert result[0].musicbrainz_id == "mb-ar1"
|
|
|
|
|
|
class TestGetArtistDetail:
|
|
@pytest.mark.asyncio
|
|
async def test_returns_artist_and_albums(self):
|
|
service, repo = _make_service()
|
|
repo.get_artist = AsyncMock(return_value=_artist(id="ar1", name="Muse"))
|
|
search_result = SubsonicSearchResult(
|
|
album=[SubsonicAlbum(id="al1", name="Absolution", artistId="ar1")],
|
|
)
|
|
repo.search = AsyncMock(return_value=search_result)
|
|
repo.get_album = AsyncMock(return_value=_album(id="al1", name="Absolution"))
|
|
result = await service.get_artist_detail("ar1")
|
|
assert result is not None
|
|
assert result["artist"].name == "Muse"
|
|
assert len(result["albums"]) == 1
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_returns_none_on_exception(self):
|
|
service, repo = _make_service()
|
|
repo.get_artist = AsyncMock(side_effect=RuntimeError("fail"))
|
|
result = await service.get_artist_detail("ar1")
|
|
assert result is None
|
|
|
|
|
|
class TestSearch:
|
|
@pytest.mark.asyncio
|
|
async def test_maps_search_results(self):
|
|
service, repo = _make_service()
|
|
repo.search = AsyncMock(return_value=SubsonicSearchResult(
|
|
artist=[_artist(id="ar1", name="Beatles")],
|
|
album=[_album(id="al1", name="Abbey Road")],
|
|
song=[_song(id="s1", title="Come Together")],
|
|
))
|
|
result = await service.search("beatles")
|
|
assert len(result.artists) == 1
|
|
assert result.artists[0].name == "Beatles"
|
|
assert len(result.albums) == 1
|
|
assert result.albums[0].name == "Abbey Road"
|
|
assert len(result.tracks) == 1
|
|
assert result.tracks[0].title == "Come Together"
|
|
|
|
|
|
class TestGetRecent:
|
|
@pytest.mark.asyncio
|
|
async def test_delegates_to_repo(self):
|
|
service, repo = _make_service()
|
|
repo.get_album_list = AsyncMock(return_value=[_album(id="al1")])
|
|
result = await service.get_recent(limit=5)
|
|
assert len(result) == 1
|
|
repo.get_album_list.assert_awaited_once_with(type="recent", size=5, offset=0)
|
|
|
|
|
|
class TestGetFavorites:
|
|
@pytest.mark.asyncio
|
|
async def test_maps_starred(self):
|
|
service, repo = _make_service()
|
|
repo.get_starred = AsyncMock(return_value=SubsonicSearchResult(
|
|
artist=[_artist()], album=[_album()], song=[_song()],
|
|
))
|
|
result = await service.get_favorites()
|
|
assert len(result.artists) == 1
|
|
assert len(result.albums) == 1
|
|
assert len(result.tracks) == 1
|
|
|
|
|
|
class TestGetGenres:
|
|
@pytest.mark.asyncio
|
|
async def test_returns_genre_names(self):
|
|
service, repo = _make_service()
|
|
repo.get_genres = AsyncMock(return_value=[
|
|
SubsonicGenre(name="Rock", songCount=100),
|
|
SubsonicGenre(name="Jazz"),
|
|
SubsonicGenre(name=""),
|
|
])
|
|
result = await service.get_genres()
|
|
assert result == ["Rock", "Jazz"]
|
|
|
|
|
|
class TestGetStats:
|
|
@pytest.mark.asyncio
|
|
async def test_aggregates_counts(self):
|
|
service, repo = _make_service()
|
|
repo.get_artists = AsyncMock(return_value=[_artist(), _artist(id="ar2")])
|
|
repo.get_album_list = AsyncMock(return_value=[_album()])
|
|
repo.get_genres = AsyncMock(return_value=[
|
|
SubsonicGenre(name="Rock", songCount=50),
|
|
SubsonicGenre(name="Pop", songCount=30),
|
|
])
|
|
result = await service.get_stats()
|
|
assert result.total_artists == 2
|
|
assert result.total_albums == 1
|
|
assert result.total_tracks == 80
|
|
|
|
|
|
class TestAlbumMatch:
|
|
@pytest.mark.asyncio
|
|
async def test_mbid_match(self):
|
|
service, repo = _make_service()
|
|
candidate = SubsonicAlbum(id="nd-1", name="Album", musicBrainzId="mb-target")
|
|
repo.search = AsyncMock(return_value=SubsonicSearchResult(album=[candidate]))
|
|
repo.get_album = AsyncMock(return_value=SubsonicAlbum(
|
|
id="nd-1", name="Album", musicBrainzId="mb-target",
|
|
song=[_song(id="s1", title="Track 1")],
|
|
))
|
|
result = await service.get_album_match("mb-target", "Album", "Artist")
|
|
assert result.found is True
|
|
assert result.navidrome_album_id == "nd-1"
|
|
assert len(result.tracks) == 1
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_fuzzy_name_match(self):
|
|
service, repo = _make_service()
|
|
candidate = SubsonicAlbum(id="nd-2", name="OK Computer", artist="Radiohead")
|
|
repo.search = AsyncMock(return_value=SubsonicSearchResult(album=[candidate]))
|
|
repo.get_album = AsyncMock(return_value=SubsonicAlbum(
|
|
id="nd-2", name="OK Computer", artist="Radiohead",
|
|
song=[_song()],
|
|
))
|
|
result = await service.get_album_match("", "OK Computer", "Radiohead")
|
|
assert result.found is True
|
|
assert result.navidrome_album_id == "nd-2"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_no_match(self):
|
|
service, repo = _make_service()
|
|
repo.search = AsyncMock(return_value=SubsonicSearchResult(album=[]))
|
|
result = await service.get_album_match("mb-none", "Nonexistent", "Nobody")
|
|
assert result.found is False
|
|
assert result.navidrome_album_id is None
|
|
|
|
|
|
class TestNormalize:
|
|
def test_strips_accents(self):
|
|
assert _normalize("Café") == "cafe"
|
|
|
|
def test_lowercases(self):
|
|
assert _normalize("HELLO") == "hello"
|
|
|
|
def test_strips_non_alphanumeric(self):
|
|
assert _normalize("OK Computer!") == "okcomputer"
|
|
|
|
|
|
class TestLidarrAlbumMatching:
|
|
@pytest.mark.asyncio
|
|
async def test_exact_match(self):
|
|
service, _ = _make_service()
|
|
service._lidarr_album_index = {
|
|
f"{_normalize('Buzz')}:{_normalize('NIKI')}": ("mbid-buzz", "mbid-niki"),
|
|
}
|
|
result = await service._resolve_album_mbid("Buzz", "NIKI")
|
|
assert result == "mbid-buzz"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_cleaned_name_match(self):
|
|
service, _ = _make_service()
|
|
service._lidarr_album_index = {
|
|
f"{_normalize('OK Computer')}:{_normalize('Radiohead')}": ("mbid-okc", "mbid-rh"),
|
|
}
|
|
result = await service._resolve_album_mbid("OK Computer (Remastered 2017)", "Radiohead")
|
|
assert result == "mbid-okc"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_no_match_returns_none(self):
|
|
service, _ = _make_service()
|
|
service._lidarr_album_index = {}
|
|
result = await service._resolve_album_mbid("Nonexistent", "Nobody")
|
|
assert result is None
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_negative_cache_prevents_re_lookup(self):
|
|
service, _ = _make_service()
|
|
service._lidarr_album_index = {}
|
|
result1 = await service._resolve_album_mbid("Missing", "Artist")
|
|
assert result1 is None
|
|
# Second call should hit negative cache
|
|
service._lidarr_album_index = {
|
|
f"{_normalize('Missing')}:{_normalize('Artist')}": ("mbid-late", "mbid-a"),
|
|
}
|
|
result2 = await service._resolve_album_mbid("Missing", "Artist")
|
|
assert result2 is None # Still negative-cached
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_empty_name_returns_none(self):
|
|
service, _ = _make_service()
|
|
result = await service._resolve_album_mbid("", "Artist")
|
|
assert result is None
|
|
|
|
|
|
class TestLidarrArtistMatching:
|
|
@pytest.mark.asyncio
|
|
async def test_exact_match(self):
|
|
service, _ = _make_service()
|
|
service._lidarr_artist_index = {
|
|
_normalize("Radiohead"): "mbid-radiohead",
|
|
}
|
|
result = await service._resolve_artist_mbid("Radiohead")
|
|
assert result == "mbid-radiohead"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_no_match_returns_none(self):
|
|
service, _ = _make_service()
|
|
service._lidarr_artist_index = {}
|
|
result = await service._resolve_artist_mbid("Unknown Artist")
|
|
assert result is None
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_empty_name_returns_none(self):
|
|
service, _ = _make_service()
|
|
result = await service._resolve_artist_mbid("")
|
|
assert result is None
|
|
|
|
|
|
def _make_service_with_cache() -> tuple[NavidromeLibraryService, MagicMock, MagicMock]:
|
|
repo = MagicMock()
|
|
repo.get_album_list = AsyncMock(return_value=[])
|
|
repo.get_album = AsyncMock()
|
|
repo.get_artists = AsyncMock(return_value=[])
|
|
repo.get_artist = AsyncMock()
|
|
repo.get_starred = AsyncMock(return_value=SubsonicSearchResult())
|
|
repo.get_genres = AsyncMock(return_value=[])
|
|
repo.search = AsyncMock(return_value=SubsonicSearchResult())
|
|
prefs = MagicMock()
|
|
prefs.get_advanced_settings.return_value = MagicMock()
|
|
cache = MagicMock()
|
|
cache.get_all_albums_for_matching = AsyncMock(return_value=[])
|
|
cache.load_navidrome_album_mbid_index = AsyncMock(return_value={})
|
|
cache.load_navidrome_artist_mbid_index = AsyncMock(return_value={})
|
|
cache.save_navidrome_album_mbid_index = AsyncMock()
|
|
cache.save_navidrome_artist_mbid_index = AsyncMock()
|
|
service = NavidromeLibraryService(navidrome_repo=repo, preferences_service=prefs, library_db=cache, mbid_store=cache)
|
|
return service, repo, cache
|
|
|
|
|
|
class TestWarmMbidCacheLifecycle:
|
|
@pytest.mark.asyncio
|
|
async def test_builds_lidarr_index_resolves_albums_and_persists(self):
|
|
service, repo, cache = _make_service_with_cache()
|
|
cache.get_all_albums_for_matching = AsyncMock(return_value=[
|
|
("OK Computer", "Radiohead", "mbid-okc", "mbid-rh"),
|
|
])
|
|
repo.get_album_list = AsyncMock(return_value=[
|
|
_album(id="a1", name="OK Computer", artist="Radiohead"),
|
|
])
|
|
await service.warm_mbid_cache()
|
|
key = f"{_normalize('OK Computer')}:{_normalize('Radiohead')}"
|
|
assert service._album_mbid_cache[key] == "mbid-okc"
|
|
assert service._artist_mbid_cache[_normalize("Radiohead")] == "mbid-rh"
|
|
cache.save_navidrome_album_mbid_index.assert_awaited_once()
|
|
cache.save_navidrome_artist_mbid_index.assert_awaited_once()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_negative_cache_overridden_when_lidarr_match_exists(self):
|
|
service, repo, cache = _make_service_with_cache()
|
|
# Seed a negative cache entry
|
|
key = f"{_normalize('Buzz')}:{_normalize('NIKI')}"
|
|
service._album_mbid_cache[key] = (None, 0.0)
|
|
# Lidarr now has a match
|
|
cache.get_all_albums_for_matching = AsyncMock(return_value=[
|
|
("Buzz", "NIKI", "mbid-buzz", "mbid-niki"),
|
|
])
|
|
repo.get_album_list = AsyncMock(return_value=[
|
|
_album(id="a1", name="Buzz", artist="NIKI"),
|
|
])
|
|
await service.warm_mbid_cache()
|
|
assert service._album_mbid_cache[key] == "mbid-buzz"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_persist_if_dirty_round_trip(self):
|
|
service, repo, cache = _make_service_with_cache()
|
|
service._lidarr_album_index = {
|
|
f"{_normalize('Album')}:{_normalize('Artist')}": ("mbid-a", "mbid-ar"),
|
|
}
|
|
service._lidarr_artist_index = {_normalize("Artist"): "mbid-ar"}
|
|
await service._resolve_album_mbid("Album", "Artist")
|
|
await service._resolve_artist_mbid("Artist")
|
|
assert service._dirty is True
|
|
await service.persist_if_dirty()
|
|
assert service._dirty is False
|
|
saved_albums = cache.save_navidrome_album_mbid_index.call_args[0][0]
|
|
saved_artists = cache.save_navidrome_artist_mbid_index.call_args[0][0]
|
|
assert saved_albums[f"{_normalize('Album')}:{_normalize('Artist')}"] == "mbid-a"
|
|
assert saved_artists[_normalize("Artist")] == "mbid-ar"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_disk_cache_loaded_when_lidarr_unavailable(self):
|
|
service, repo, cache = _make_service_with_cache()
|
|
cache.get_all_albums_for_matching = AsyncMock(return_value=[])
|
|
key = f"{_normalize('Album')}:{_normalize('Artist')}"
|
|
cache.load_navidrome_album_mbid_index = AsyncMock(return_value={key: "mbid-disk"})
|
|
cache.load_navidrome_artist_mbid_index = AsyncMock(return_value={_normalize("Artist"): "mbid-ar-disk"})
|
|
# Provide Navidrome albums so reconciliation keeps disk entries and reverse index is built
|
|
repo.get_album_list = AsyncMock(return_value=[_album(id="nd-1", name="Album", artist="Artist")])
|
|
await service.warm_mbid_cache()
|
|
# Disk cache should be loaded even though Lidarr index is empty
|
|
assert service._album_mbid_cache[key] == "mbid-disk"
|
|
assert service._artist_mbid_cache[_normalize("Artist")] == "mbid-ar-disk"
|
|
# Reverse index should be built from disk cache (M2 fix)
|
|
assert service._mbid_to_navidrome_id.get("mbid-disk") == "nd-1"
|