/
/
/
1"""Tests for MusicBrainz recommendations (birthdays and memorials)."""
2
3from __future__ import annotations
4
5from collections.abc import AsyncIterator, Sequence
6from datetime import UTC, datetime, timedelta
7from unittest.mock import AsyncMock, Mock
8
9import pytest
10from music_assistant_models.enums import ArtistEntityType, ExternalID, RecommendationFolderType
11from music_assistant_models.media_items import (
12 Artist,
13 MediaItemMetadata,
14 ProviderMapping,
15 RecommendationFolder,
16)
17from music_assistant_models.media_items.metadata import LifeSpan
18
19from music_assistant.providers.musicbrainz.models import (
20 MusicBrainzArtist,
21 MusicBrainzLifeSpan,
22)
23from music_assistant.providers.musicbrainz.recommendations import (
24 RECOMMENDATIONS_CACHE_KEY,
25 MusicBrainzRecommendationManager,
26)
27
28# ---------------------------------------------------------------------------
29# helpers
30# ---------------------------------------------------------------------------
31
32
33def _make_artist(
34 item_id: str,
35 name: str,
36 mbid: str | None = None,
37 life_span: LifeSpan | None = None,
38 artist_entity_type: ArtistEntityType | None = None,
39) -> Artist:
40 """Return a minimal library Artist, optionally with an MB external-ID and life_span."""
41 pm = ProviderMapping(
42 item_id=item_id,
43 provider_domain="test",
44 provider_instance="test",
45 )
46 artist = Artist(item_id=item_id, provider="test", name=name, provider_mappings={pm})
47 if mbid:
48 artist.add_external_id(ExternalID.MB_ARTIST, mbid)
49 if life_span is not None or artist_entity_type is not None:
50 artist.metadata = MediaItemMetadata(
51 life_span=life_span, artist_entity_type=artist_entity_type, last_refresh=1
52 )
53 return artist
54
55
56def _make_mb_artist(
57 mbid: str,
58 begin: str | None,
59 end: str | None = None,
60 ended: bool = False,
61 artist_type: str | None = None,
62) -> MusicBrainzArtist:
63 """Return a MusicBrainzArtist with the given life-span and optional type."""
64 life_span = MusicBrainzLifeSpan(begin=begin, end=end, ended=ended) if (begin or end) else None
65 return MusicBrainzArtist(
66 id=mbid, name="stub", sort_name="stub", life_span=life_span, type=artist_type
67 )
68
69
70async def _async_iter(items: Sequence[object]) -> AsyncIterator[object]:
71 """Yield items from a list as an async iterator."""
72 for item in items:
73 yield item
74
75
76def _today_mmdd() -> str:
77 """Return today's MM-DD string in UTC."""
78 return _mmdd_for_offset(0)
79
80
81def _mmdd_for_offset(offset: int) -> str:
82 """Return the MM-DD string for today + offset days (UTC)."""
83 target = datetime.now(UTC).date() + timedelta(days=offset)
84 return f"{target.month:02d}-{target.day:02d}"
85
86
87def _set_library(provider_mock: Mock, artists: list[Artist]) -> None:
88 """Wire the library artist iterator on the provider mock."""
89 provider_mock.mass.music.artists.iter_library_items = Mock(return_value=_async_iter(artists))
90
91
92def _get_timeline_folder(folders: list[RecommendationFolder]) -> RecommendationFolder:
93 """Extract and validate the single timeline folder."""
94 assert len(folders) == 1, "Expected single timeline folder"
95 folder = folders[0]
96 assert folder.type == RecommendationFolderType.TIMELINE
97 assert folder.item_id == "musicbrainz_timeline"
98 assert folder.translation_key == "artist_timeline"
99 return folder
100
101
102# ---------------------------------------------------------------------------
103# fixtures
104# ---------------------------------------------------------------------------
105
106
107@pytest.fixture
108def provider_mock() -> Mock:
109 """Return a minimal MusicbrainzProvider mock."""
110 provider = Mock()
111 provider.instance_id = "musicbrainz"
112 provider.logger = Mock()
113 provider.config.get_value = Mock(return_value=3)
114 provider.mass = Mock()
115 provider.mass.create_task = Mock()
116 provider.mass.call_later = Mock()
117 provider.mass.metadata.schedule_update_metadata = Mock()
118 return provider
119
120
121@pytest.fixture
122def manager(provider_mock: Mock) -> MusicBrainzRecommendationManager:
123 """Return a MusicBrainzRecommendationManager backed by the mock provider."""
124 return MusicBrainzRecommendationManager(provider_mock)
125
126
127# ---------------------------------------------------------------------------
128# _scan_matches
129# ---------------------------------------------------------------------------
130
131
132async def test_scan_matches_birthday_in_window(
133 manager: MusicBrainzRecommendationManager,
134 provider_mock: Mock,
135) -> None:
136 """Artists whose birth date falls within the window are returned."""
137 today_mmdd = _today_mmdd()
138 mbid = "20ff3303-4fe2-4a47-a1b6-291e26aa3438"
139 life_span = LifeSpan(begin=f"1980-{today_mmdd}")
140 _set_library(
141 provider_mock,
142 [
143 _make_artist(
144 "1",
145 "Birthday Artist",
146 mbid=mbid,
147 life_span=life_span,
148 artist_entity_type=ArtistEntityType.PERSON,
149 )
150 ],
151 )
152
153 artists = await manager._scan_matches()
154 assert [a.name for a in artists] == ["Birthday Artist"]
155
156
157async def test_scan_matches_memoriam_in_window(
158 manager: MusicBrainzRecommendationManager,
159 provider_mock: Mock,
160) -> None:
161 """Artists who passed away on a window date (ended=True) are returned."""
162 today_mmdd = _today_mmdd()
163 mbid = "20ff3303-4fe2-4a47-a1b6-291e26aa3438"
164 life_span = LifeSpan(begin="1933-01-01", end=f"2006-{today_mmdd}", ended=True)
165 _set_library(
166 provider_mock,
167 [
168 _make_artist(
169 "1",
170 "Late Artist",
171 mbid=mbid,
172 life_span=life_span,
173 artist_entity_type=ArtistEntityType.PERSON,
174 )
175 ],
176 )
177
178 artists = await manager._scan_matches()
179 assert [a.name for a in artists] == ["Late Artist"]
180
181
182async def test_scan_matches_out_of_window(
183 manager: MusicBrainzRecommendationManager,
184 provider_mock: Mock,
185) -> None:
186 """Artists whose event date is outside the window are not returned."""
187 mbid = "c3c82bdc-d9e7-4836-9746-c24ead47ca19"
188 _set_library(provider_mock, [_make_artist("1", "Wrong Date", mbid=mbid)])
189 provider_mock.get_artist_details = AsyncMock(
190 return_value=_make_mb_artist(mbid, f"1985-{_mmdd_for_offset(180)}")
191 )
192
193 assert await manager._scan_matches() == []
194
195
196async def test_scan_matches_no_life_span(
197 manager: MusicBrainzRecommendationManager,
198 provider_mock: Mock,
199) -> None:
200 """Artists with no life_span are silently skipped."""
201 mbid = "89ad4ac3-39f7-470e-963a-56509c546377"
202 _set_library(provider_mock, [_make_artist("1", "No Lifespan", mbid=mbid)])
203 provider_mock.get_artist_details = AsyncMock(return_value=_make_mb_artist(mbid, None))
204
205 assert await manager._scan_matches() == []
206
207
208async def test_scan_matches_partial_date_skipped(
209 manager: MusicBrainzRecommendationManager,
210 provider_mock: Mock,
211) -> None:
212 """Partial dates like '1990' (no MM-DD) are ignored."""
213 mbid = "c3c82bdc-d9e7-4836-9746-c24ead47ca19"
214 _set_library(provider_mock, [_make_artist("1", "Partial Date", mbid=mbid)])
215 provider_mock.get_artist_details = AsyncMock(return_value=_make_mb_artist(mbid, "1990"))
216
217 assert await manager._scan_matches() == []
218
219
220async def test_scan_matches_living_artist_not_in_memoriam(
221 manager: MusicBrainzRecommendationManager,
222 provider_mock: Mock,
223) -> None:
224 """Artists where ended=False are not included even if end date matches."""
225 today_mmdd = _today_mmdd()
226 mbid = "f59c5520-5f46-4d2c-b2c4-822eabf53419"
227 _set_library(provider_mock, [_make_artist("1", "Living Artist", mbid=mbid)])
228 provider_mock.get_artist_details = AsyncMock(
229 return_value=_make_mb_artist(
230 mbid, begin="1996-01-01", end=f"2023-{today_mmdd}", ended=False
231 )
232 )
233
234 assert await manager._scan_matches() == []
235
236
237async def test_scan_matches_excludes_character_and_other(
238 manager: MusicBrainzRecommendationManager,
239 provider_mock: Mock,
240) -> None:
241 """Character and Other artist types are excluded."""
242 today_mmdd = _today_mmdd()
243 mbid_char = "aaaaaaaa-0000-0000-0000-000000000001"
244 mbid_other = "aaaaaaaa-0000-0000-0000-000000000002"
245 mbid_person = "aaaaaaaa-0000-0000-0000-000000000003"
246 life_span = LifeSpan(begin=f"1980-{today_mmdd}")
247 _set_library(
248 provider_mock,
249 [
250 _make_artist(
251 "1",
252 "Character",
253 mbid=mbid_char,
254 life_span=life_span,
255 artist_entity_type=ArtistEntityType.CHARACTER,
256 ),
257 _make_artist(
258 "2",
259 "Other",
260 mbid=mbid_other,
261 life_span=life_span,
262 artist_entity_type=ArtistEntityType.OTHER,
263 ),
264 _make_artist(
265 "3",
266 "Real Person",
267 mbid=mbid_person,
268 life_span=life_span,
269 artist_entity_type=ArtistEntityType.PERSON,
270 ),
271 ],
272 )
273
274 artists = await manager._scan_matches()
275 assert [a.name for a in artists] == ["Real Person"]
276
277
278async def test_scan_matches_excludes_unknown_and_null_types(
279 manager: MusicBrainzRecommendationManager,
280 provider_mock: Mock,
281) -> None:
282 """Artists with UNKNOWN or None entity_type are excluded."""
283 today_mmdd = _today_mmdd()
284 mbid_unknown = "aaaaaaaa-0000-0000-0000-000000000001"
285 mbid_null = "aaaaaaaa-0000-0000-0000-000000000002"
286 mbid_person = "aaaaaaaa-0000-0000-0000-000000000003"
287 life_span = LifeSpan(begin=f"1980-{today_mmdd}")
288 artist_unknown = _make_artist(
289 "1",
290 "Unknown Type",
291 mbid=mbid_unknown,
292 life_span=life_span,
293 artist_entity_type=ArtistEntityType.UNKNOWN,
294 )
295 artist_null = _make_artist("2", "Null Type", mbid=mbid_null, life_span=life_span)
296 artist_null.metadata.last_refresh = 1
297 artist_null.metadata.artist_entity_type = None
298 artist_person = _make_artist(
299 "3",
300 "Real Person",
301 mbid=mbid_person,
302 life_span=life_span,
303 artist_entity_type=ArtistEntityType.PERSON,
304 )
305 _set_library(provider_mock, [artist_unknown, artist_null, artist_person])
306
307 artists = await manager._scan_matches()
308 assert [a.name for a in artists] == ["Real Person"]
309
310
311async def test_scan_matches_no_mbid_skipped(
312 manager: MusicBrainzRecommendationManager,
313 provider_mock: Mock,
314) -> None:
315 """Artists without an MBID are not looked up."""
316 _set_library(provider_mock, [_make_artist("1", "No MBID Artist")])
317 provider_mock.get_artist_details = AsyncMock(side_effect=AssertionError("should not call"))
318
319 assert await manager._scan_matches() == []
320
321
322async def test_scan_matches_no_enrichment_schedules_metadata_update(
323 manager: MusicBrainzRecommendationManager,
324 provider_mock: Mock,
325) -> None:
326 """Artists without metadata enrichment (last_refresh=None) are skipped and enrichment is scheduled."""
327 today_mmdd = _today_mmdd()
328 mbid_not_enriched = "20ff3303-4fe2-4a47-a1b6-291e26aa3438"
329 mbid_enriched = "f59c5520-5f46-4d2c-b2c4-822eabf53419"
330 artist_not_enriched = _make_artist("1", "Not Enriched", mbid=mbid_not_enriched)
331 artist_not_enriched.metadata = MediaItemMetadata()
332 life_span = LifeSpan(begin=f"1985-{today_mmdd}")
333 artist_enriched = _make_artist(
334 "2",
335 "Enriched Artist",
336 mbid=mbid_enriched,
337 life_span=life_span,
338 artist_entity_type=ArtistEntityType.PERSON,
339 )
340 _set_library(provider_mock, [artist_not_enriched, artist_enriched])
341
342 artists = await manager._scan_matches()
343 assert [a.name for a in artists] == ["Enriched Artist"]
344 provider_mock.mass.metadata.schedule_update_metadata.assert_called_once_with(
345 artist_not_enriched
346 )
347
348
349async def test_scan_matches_uses_metadata_when_available(
350 manager: MusicBrainzRecommendationManager,
351 provider_mock: Mock,
352) -> None:
353 """Artists with enriched metadata (last_refresh set) are processed directly."""
354 today_mmdd = _today_mmdd()
355 mbid = "20ff3303-4fe2-4a47-a1b6-291e26aa3438"
356 life_span = LifeSpan(begin=f"1980-{today_mmdd}")
357 _set_library(
358 provider_mock,
359 [
360 _make_artist(
361 "1",
362 "Cached Artist",
363 mbid=mbid,
364 life_span=life_span,
365 artist_entity_type=ArtistEntityType.PERSON,
366 )
367 ],
368 )
369
370 artists = await manager._scan_matches()
371 assert [a.name for a in artists] == ["Cached Artist"]
372
373
374# ---------------------------------------------------------------------------
375# get_recommendations / _build_folder
376# ---------------------------------------------------------------------------
377
378
379async def test_get_recommendations_returns_flat_timeline_folder(
380 manager: MusicBrainzRecommendationManager,
381 provider_mock: Mock,
382) -> None:
383 """A fresh cache hit returns folder metadata without items."""
384 today_mmdd = _today_mmdd()
385 artist = _make_artist("1", "Cached Artist", life_span=LifeSpan(begin=f"1980-{today_mmdd}"))
386 provider_mock.mass.cache.get = AsyncMock(return_value=[artist.to_dict()])
387
388 result = await manager.get_recommendations()
389
390 timeline = _get_timeline_folder(result)
391 assert len(timeline.items) == 0, "get_recommendations should return empty items"
392 provider_mock.mass.create_task.assert_not_called()
393
394
395async def test_get_recommendations_serves_stale_and_schedules(
396 manager: MusicBrainzRecommendationManager,
397 provider_mock: Mock,
398) -> None:
399 """When nothing fresh is cached, folder metadata is served and a refresh is scheduled."""
400 artist = _make_artist("1", "Stale Artist")
401 provider_mock.mass.cache.get = AsyncMock(side_effect=[None, [artist.to_dict()]])
402
403 result = await manager.get_recommendations()
404
405 timeline = _get_timeline_folder(result)
406 assert len(timeline.items) == 0, "get_recommendations should return empty items"
407 provider_mock.mass.create_task.assert_called_once()
408
409
410async def test_get_recommendations_empty_when_no_cache(
411 manager: MusicBrainzRecommendationManager,
412 provider_mock: Mock,
413) -> None:
414 """With no cached data at all, returns empty and schedules a refresh."""
415 provider_mock.mass.cache.get = AsyncMock(return_value=None)
416
417 result = await manager.get_recommendations()
418
419 assert result == []
420 provider_mock.mass.create_task.assert_called_once()
421
422
423async def test_get_recommendation_items_returns_cached_artists(
424 manager: MusicBrainzRecommendationManager,
425 provider_mock: Mock,
426) -> None:
427 """get_recommendation_items returns artists from cache."""
428 today_mmdd = _today_mmdd()
429 artist = _make_artist("1", "Cached Artist", life_span=LifeSpan(begin=f"1980-{today_mmdd}"))
430 provider_mock.mass.cache.get = AsyncMock(return_value=[artist.to_dict()])
431
432 result = await manager.get_recommendation_items("musicbrainz_timeline")
433
434 assert len(result) == 1
435 assert result[0].name == "Cached Artist"
436
437
438async def test_get_recommendation_items_returns_empty_for_wrong_id(
439 manager: MusicBrainzRecommendationManager,
440) -> None:
441 """get_recommendation_items returns empty list for unknown folder ID."""
442 result = await manager.get_recommendation_items("unknown_folder")
443 assert len(result) == 0
444
445
446async def test_get_recommendation_items_returns_empty_when_no_cache(
447 manager: MusicBrainzRecommendationManager,
448 provider_mock: Mock,
449) -> None:
450 """get_recommendation_items returns empty when no cache exists."""
451 provider_mock.mass.cache.get = AsyncMock(return_value=None)
452
453 result = await manager.get_recommendation_items("musicbrainz_timeline")
454
455 assert len(result) == 0
456
457
458# ---------------------------------------------------------------------------
459# _refresh
460# ---------------------------------------------------------------------------
461
462
463async def test_refresh_caches_artist_dicts(
464 manager: MusicBrainzRecommendationManager,
465 provider_mock: Mock,
466) -> None:
467 """_refresh caches a list of serialized artist dicts."""
468 today_mmdd = _today_mmdd()
469 mbid = "20ff3303-4fe2-4a47-a1b6-291e26aa3438"
470 life_span = LifeSpan(begin=f"1990-{today_mmdd}")
471 _set_library(
472 provider_mock,
473 [
474 _make_artist(
475 "1",
476 "Birthday Star",
477 mbid=mbid,
478 life_span=life_span,
479 artist_entity_type=ArtistEntityType.PERSON,
480 )
481 ],
482 )
483 provider_mock.mass.cache.set = AsyncMock()
484
485 await manager._refresh()
486
487 provider_mock.mass.cache.set.assert_awaited_once()
488 args, kwargs = provider_mock.mass.cache.set.call_args
489 assert args[0] == RECOMMENDATIONS_CACHE_KEY
490 stored = args[1]
491 assert isinstance(stored, list)
492 assert stored[0]["name"] == "Birthday Star"
493 # Verify life_span round-trips through the cache (frontend relies on it for event derivation).
494 cached_artist = Artist.from_dict(stored[0])
495 assert cached_artist.metadata is not None
496 assert cached_artist.metadata.life_span is not None
497 assert cached_artist.metadata.life_span.begin == f"1990-{today_mmdd}"
498 assert kwargs["provider"] == "musicbrainz"
499 assert kwargs["allow_expired_cache"] is True
500
501
502async def test_refresh_swallows_errors(
503 manager: MusicBrainzRecommendationManager,
504 provider_mock: Mock,
505) -> None:
506 """A failure during scan does not write the cache or raise."""
507 provider_mock.mass.music.artists.iter_library_items = Mock(
508 side_effect=RuntimeError("library unavailable")
509 )
510 provider_mock.mass.cache.set = AsyncMock()
511
512 await manager._refresh()
513
514 provider_mock.mass.cache.set.assert_not_awaited()
515
516
517# ---------------------------------------------------------------------------
518# End of tests
519