/
/
/
1"""Regression tests for Apple Music parser and library fallbacks."""
2
3from typing import Any
4from unittest.mock import ANY, AsyncMock, MagicMock
5
6import pytest
7from music_assistant_models.enums import ExternalID, MediaType
8from music_assistant_models.media_items import Album, ItemMapping
9
10from music_assistant.providers.apple_music.library import _TRACK_PAGE_SIZE, AppleMusicLibraryManager
11from music_assistant.providers.apple_music.media import AppleMusicMediaManager
12from music_assistant.providers.apple_music.parsers import (
13 parse_album,
14 parse_artwork_image,
15 parse_track,
16)
17from tests.common import use_real_create_task
18
19BLOBSTORE_ARTWORK = {
20 "url": "https://store-033.blobstore.apple.com/pic/image?X-Amz-Signature=abc",
21 "width": 1200,
22 "height": 1200,
23}
24CATALOG_ARTWORK = {
25 "url": "https://is1-ssl.mzstatic.com/image/thumb/Music/{w}x{h}bb.jpg",
26 "width": 3000,
27 "height": 3000,
28}
29
30
31def _stream(items: list[dict[str, Any]]) -> MagicMock:
32 """Mock api.iter_all_items: a sync callable returning a fresh async iterator over items."""
33
34 async def _gen(*_args: Any, **_kwargs: Any) -> Any:
35 for item in items:
36 yield item
37
38 return MagicMock(side_effect=_gen)
39
40
41def _create_provider_mock() -> MagicMock:
42 """Create a provider mock with the minimum shape expected by parser functions."""
43 provider = MagicMock()
44 provider.instance_id = "apple_music_test"
45 provider.domain = "apple_music"
46 provider.logger = MagicMock()
47 provider._storefront = "us"
48 provider.mass.cache.get = AsyncMock(return_value=None)
49 provider.mass.cache.get_with_freshness = AsyncMock(return_value=(None, False, False))
50 provider.mass.cache.set = AsyncMock()
51 use_real_create_task(provider.mass)
52 return provider
53
54
55def test_parse_album_keeps_album_without_catalog_url() -> None:
56 """Albums without catalog URL should still be parsed for library visibility."""
57 provider = _create_provider_mock()
58 album_obj = {
59 "id": "l.album1",
60 "type": "library-albums",
61 "attributes": {
62 "name": "Uploaded Album",
63 "artistName": "Uploaded Artist",
64 "playParams": {"id": "l.album1"},
65 },
66 "relationships": {},
67 }
68
69 result = parse_album(provider, album_obj)
70
71 assert isinstance(result, Album)
72 assert result.name == "Uploaded Album"
73 mapping = next(iter(result.provider_mappings))
74 assert mapping.url is None
75
76
77def _make_album_obj(attributes: dict[str, Any], relationships: dict[str, Any]) -> dict[str, Any]:
78 """Create a catalog album object for parse_album."""
79 return {
80 "id": "1234567890",
81 "type": "albums",
82 "attributes": {"name": "Test Album", **attributes},
83 "relationships": relationships,
84 }
85
86
87def _artists_relationship(*names: str) -> dict[str, Any]:
88 """Create an artists relationship payload with the given artist names."""
89 return {
90 "artists": {
91 "data": [
92 {"id": f"artist{idx}", "type": "artists", "attributes": {"name": name}}
93 for idx, name in enumerate(names)
94 ]
95 }
96 }
97
98
99def test_parse_album_preserves_provider_upc() -> None:
100 """Apple UPC values are not padded before shared identifier normalization."""
101 provider = _create_provider_mock()
102 album_obj = _make_album_obj(
103 {"artistName": "Test Artist", "upc": "00724354283857"},
104 _artists_relationship("Test Artist"),
105 )
106
107 result = parse_album(provider, album_obj)
108
109 assert isinstance(result, Album)
110 assert (ExternalID.BARCODE, "00724354283857") in result.external_ids
111
112
113def test_parse_album_compilation_uses_album_level_artist_name() -> None:
114 """Compilations must show the album-level artist, not a contributing artist."""
115 provider = _create_provider_mock()
116 album_obj = _make_album_obj(
117 {"artistName": "Various Artists", "isCompilation": True},
118 _artists_relationship("Paul McCartney"),
119 )
120
121 result = parse_album(provider, album_obj)
122
123 assert isinstance(result, Album)
124 assert [artist.name for artist in result.artists] == ["Various Artists"]
125
126
127def test_parse_album_compilation_without_artist_name_keeps_related_artists() -> None:
128 """A compilation without artistName must fall back to the related artists."""
129 provider = _create_provider_mock()
130 album_obj = _make_album_obj(
131 {"isCompilation": True},
132 _artists_relationship("Paul McCartney"),
133 )
134
135 result = parse_album(provider, album_obj)
136
137 assert isinstance(result, Album)
138 assert [artist.name for artist in result.artists] == ["Paul McCartney"]
139
140
141def test_parse_album_single_artist_compilation_keeps_rich_artist() -> None:
142 """A greatest-hits compilation by one artist must keep the full artist object."""
143 provider = _create_provider_mock()
144 album_obj = _make_album_obj(
145 {"artistName": "Eminem", "isCompilation": True},
146 _artists_relationship("Eminem"),
147 )
148
149 result = parse_album(provider, album_obj)
150
151 assert isinstance(result, Album)
152 assert len(result.artists) == 1
153 assert result.artists[0].name == "Eminem"
154 assert result.artists[0].item_id == "artist0"
155
156
157def test_parse_album_compilation_keeps_catalog_artist_details() -> None:
158 """A compilation must keep complete catalog details for a library artist."""
159 provider = _create_provider_mock()
160 album_obj = _make_album_obj(
161 {"artistName": "Eminem", "isCompilation": True},
162 {
163 "artists": {
164 "data": [
165 {
166 "id": "l.artist0",
167 "type": "library-artists",
168 "relationships": {
169 "catalog": {
170 "data": [
171 {
172 "id": "artist0",
173 "type": "artists",
174 "attributes": {"name": "Eminem"},
175 }
176 ]
177 }
178 },
179 }
180 ]
181 }
182 },
183 )
184
185 result = parse_album(provider, album_obj)
186
187 assert isinstance(result, Album)
188 assert len(result.artists) == 1
189 assert result.artists[0].name == "Eminem"
190 assert result.artists[0].item_id == "artist0"
191
192
193def test_parse_album_dj_mix_compilation_keeps_multiple_artists() -> None:
194 """A DJ-mix compilation with multiple related artists must keep them all."""
195 provider = _create_provider_mock()
196 album_obj = _make_album_obj(
197 {"artistName": "Pete Tong & Boy George", "isCompilation": True},
198 _artists_relationship("Pete Tong", "Boy George"),
199 )
200
201 result = parse_album(provider, album_obj)
202
203 assert isinstance(result, Album)
204 assert [artist.name for artist in result.artists] == ["Pete Tong", "Boy George"]
205
206
207@pytest.mark.parametrize(
208 "artist_obj",
209 [
210 {"id": "80204262", "type": "artists"},
211 {"id": "80204262", "type": "artists", "attributes": {}},
212 {
213 "id": "l.artist.80204262",
214 "type": "library-artists",
215 "relationships": {"catalog": {"data": [{"id": "80204262", "type": "artists"}]}},
216 },
217 ],
218)
219def test_parse_album_compilation_ignores_placeholder_artist_stub(
220 artist_obj: dict[str, Any],
221) -> None:
222 """An unresolvable artist stub must not become the album artist."""
223 provider = _create_provider_mock()
224 album_obj = _make_album_obj(
225 {"artistName": "Verschillende artiesten", "isCompilation": True},
226 {"artists": {"data": [artist_obj]}},
227 )
228
229 result = parse_album(provider, album_obj)
230
231 assert isinstance(result, Album)
232 assert [artist.name for artist in result.artists] == ["Verschillende artiesten"]
233
234
235def test_parse_album_compilation_with_empty_artists_uses_artist_name() -> None:
236 """A compilation without any related artists must use the album-level artist."""
237 provider = _create_provider_mock()
238 album_obj = _make_album_obj(
239 {"artistName": "Various Artists", "isCompilation": True},
240 {"artists": {"data": []}},
241 )
242
243 result = parse_album(provider, album_obj)
244
245 assert isinstance(result, Album)
246 assert [artist.name for artist in result.artists] == ["Various Artists"]
247
248
249def test_parse_album_regular_album_keeps_related_artists() -> None:
250 """A regular album must keep the artists from the relationships."""
251 provider = _create_provider_mock()
252 album_obj = _make_album_obj(
253 {"artistName": "Paul McCartney"},
254 _artists_relationship("Paul McCartney"),
255 )
256
257 result = parse_album(provider, album_obj)
258
259 assert isinstance(result, Album)
260 assert [artist.name for artist in result.artists] == ["Paul McCartney"]
261
262
263def test_parse_track_falls_back_to_album_name_when_relationship_missing() -> None:
264 """Track parsing should keep album info from albumName if no album relation is present."""
265 provider = _create_provider_mock()
266 track_obj = {
267 "id": "track1",
268 "type": "songs",
269 "attributes": {
270 "name": "Track 1",
271 "artistName": "Artist 1",
272 "albumName": "Album 1",
273 "durationInMillis": 180000,
274 "playParams": {"id": "track1"},
275 },
276 "relationships": {},
277 }
278
279 result = parse_track(provider, track_obj)
280
281 assert isinstance(result.album, ItemMapping)
282 assert result.album.name == "Album 1"
283
284
285def test_parse_track_library_song_uses_library_album_name_fallback() -> None:
286 """library-songs should fall back to library albumName when catalog attributes miss it."""
287 provider = _create_provider_mock()
288 track_obj = {
289 "id": "i.librarytrack1",
290 "type": "library-songs",
291 "attributes": {
292 "name": "Track 1 (Library)",
293 "artistName": "Artist 1",
294 "albumName": "Album From Library",
295 "playParams": {"catalogId": "123456789"},
296 "durationInMillis": 180000,
297 },
298 "relationships": {
299 "catalog": {
300 "data": [
301 {
302 "id": "123456789",
303 "attributes": {
304 "name": "Track 1 (Catalog)",
305 "artistName": "Artist 1",
306 "durationInMillis": 180000,
307 "playParams": {"id": "123456789"},
308 },
309 }
310 ]
311 }
312 },
313 }
314
315 result = parse_track(provider, track_obj)
316
317 assert isinstance(result.album, ItemMapping)
318 assert result.album.name == "Album From Library"
319
320
321def test_parse_track_empty_artists_relationship_uses_artist_name() -> None:
322 """An artists relationship without data should fall back to artistName."""
323 provider = _create_provider_mock()
324 track_obj = {
325 "id": "i.librarytrack1",
326 "type": "library-songs",
327 "attributes": {
328 "name": "Mr. Brightside",
329 "artistName": "The Killers",
330 "durationInMillis": 222000,
331 "playParams": {"catalogId": "1526194192"},
332 },
333 "relationships": {
334 "artists": {"data": []},
335 "catalog": {
336 "data": [
337 {
338 "id": "1526194192",
339 "attributes": {
340 "name": "Mr. Brightside",
341 "artistName": "The Killers",
342 "durationInMillis": 222000,
343 "playParams": {"id": "1526194192"},
344 },
345 }
346 ]
347 },
348 },
349 }
350
351 result = parse_track(provider, track_obj)
352
353 assert len(result.artists) == 1
354 assert result.artists[0].name == "The Killers"
355
356
357@pytest.mark.asyncio
358async def test_media_manager_get_playlist_applies_can_edit_hint() -> None:
359 """Catalog playlist fetch should honor library editability hint when provided."""
360 provider = _create_provider_mock()
361 provider.api_client = MagicMock()
362 provider.api_client.get_data = AsyncMock(
363 return_value={
364 "data": [
365 {
366 "id": "pl.catalog1",
367 "attributes": {
368 "name": "Catalog Playlist",
369 "playParams": {"globalId": "pl.catalog1"},
370 },
371 }
372 ]
373 }
374 )
375
376 manager = AppleMusicMediaManager(provider)
377
378 playlist = await manager.get_playlist("pl.catalog1", can_edit_hint=True)
379
380 assert playlist.is_editable is True
381
382
383@pytest.mark.asyncio
384async def test_library_playlists_preserve_can_edit_for_catalog_copy() -> None:
385 """Library playlist sync should preserve canEdit when loading via catalog copy."""
386 provider = _create_provider_mock()
387 provider.api_client = MagicMock()
388 provider.api_client.get_all_items = AsyncMock(
389 return_value=[
390 {
391 "id": "p.library1",
392 "attributes": {
393 "hasCatalog": True,
394 "canEdit": True,
395 "playParams": {"globalId": "pl.catalog1"},
396 },
397 }
398 ]
399 )
400 provider.api_client.get_ratings = AsyncMock(return_value={})
401 provider.media_manager = MagicMock()
402 provider.media_manager.get_playlist = AsyncMock(return_value=MagicMock())
403
404 manager = AppleMusicLibraryManager(provider)
405
406 _ = [playlist async for playlist in manager.get_library_playlists()]
407
408 provider.media_manager.get_playlist.assert_called_once_with(
409 "pl.catalog1",
410 ANY,
411 can_edit_hint=True,
412 library_id_override="p.library1",
413 )
414
415
416@pytest.mark.asyncio
417async def test_library_tracks_request_includes_album_relations() -> None:
418 """Library track sync should request catalog/album/artist relations from Apple API."""
419 provider = _create_provider_mock()
420 provider.api_client = MagicMock()
421 provider.api_client.iter_all_items = _stream([])
422 provider.api_client.get_ratings = AsyncMock(return_value={})
423
424 manager = AppleMusicLibraryManager(provider)
425 _ = [track async for track in manager.get_library_tracks()]
426
427 provider.api_client.iter_all_items.assert_called_once_with(
428 "me/library/songs", include="catalog,albums,artists", page_size=_TRACK_PAGE_SIZE
429 )
430
431
432@pytest.mark.asyncio
433async def test_library_tracks_falls_back_to_library_item_when_catalog_missing() -> None:
434 """Library sync should still parse track if catalog endpoint omits a catalog ID."""
435 provider = _create_provider_mock()
436 provider.api_client = MagicMock()
437 provider.api_client.iter_all_items = _stream(
438 [
439 {
440 "id": "i.librarytrack2",
441 "type": "library-songs",
442 "attributes": {
443 "name": "Missing Catalog Track",
444 "artistName": "Artist 2",
445 "albumName": "Album 2",
446 "playParams": {"catalogId": "999999"},
447 "durationInMillis": 180000,
448 },
449 "relationships": {
450 "catalog": {
451 "data": [
452 {
453 "id": "999999",
454 "attributes": {
455 "name": "Missing Catalog Track",
456 "artistName": "Artist 2",
457 "durationInMillis": 180000,
458 "playParams": {"id": "999999"},
459 },
460 }
461 ]
462 }
463 },
464 }
465 ]
466 )
467 provider.api_client.get_data = AsyncMock(return_value={"data": []})
468 provider.api_client.get_ratings = AsyncMock(return_value={"999999": True})
469
470 manager = AppleMusicLibraryManager(provider)
471 tracks = [track async for track in manager.get_library_tracks()]
472
473 assert len(tracks) == 1
474 assert tracks[0].name == "Missing Catalog Track"
475 assert isinstance(tracks[0].album, ItemMapping)
476 assert tracks[0].album.name == "Album 2"
477
478
479@pytest.mark.asyncio
480async def test_library_tracks_replaces_weak_catalog_album_mapping() -> None:
481 """A weak catalog albumName mapping should be replaced by library album relation."""
482 provider = _create_provider_mock()
483 provider.api_client = MagicMock()
484 provider.api_client.iter_all_items = _stream(
485 [
486 {
487 "id": "i.librarytrack5",
488 "type": "library-songs",
489 "attributes": {
490 "name": "Weak Catalog Album Mapping",
491 "artistName": "Artist 5",
492 "playParams": {"catalogId": "555555"},
493 "durationInMillis": 180000,
494 },
495 "relationships": {
496 "catalog": {
497 "data": [
498 {
499 "id": "555555",
500 "attributes": {
501 "name": "Weak Catalog Album Mapping",
502 "artistName": "Artist 5",
503 "albumName": "Name Only From Catalog",
504 "durationInMillis": 180000,
505 "playParams": {"id": "555555"},
506 },
507 }
508 ]
509 },
510 "albums": {
511 "data": [
512 {
513 "id": "l.album5",
514 "type": "library-albums",
515 "attributes": {
516 "name": "Resolved Library Album",
517 "artistName": "Artist 5",
518 "playParams": {"id": "l.album5"},
519 },
520 }
521 ]
522 },
523 },
524 }
525 ]
526 )
527 provider.api_client.get_data = AsyncMock(
528 return_value={
529 "data": [
530 {
531 "id": "555555",
532 "type": "songs",
533 "attributes": {
534 "name": "Weak Catalog Album Mapping",
535 "artistName": "Artist 5",
536 "albumName": "Name Only From Catalog",
537 "durationInMillis": 180000,
538 "playParams": {"id": "555555"},
539 },
540 "relationships": {},
541 }
542 ]
543 }
544 )
545 provider.api_client.get_ratings = AsyncMock(return_value={"555555": True})
546
547 manager = AppleMusicLibraryManager(provider)
548 tracks = [track async for track in manager.get_library_tracks()]
549
550 assert len(tracks) == 1
551 assert tracks[0].album is not None
552 assert tracks[0].album.item_id == "l.album5"
553 assert tracks[0].album.name == "Resolved Library Album"
554
555
556@pytest.mark.asyncio
557async def test_library_tracks_fetches_detail_when_list_item_has_no_album() -> None:
558 """Library-only tracks should be reparsed from detail endpoint when album is missing."""
559 provider = _create_provider_mock()
560 provider.api_client = MagicMock()
561 provider.api_client.iter_all_items = _stream(
562 [
563 {
564 "id": "i.librarytrack3",
565 "type": "library-songs",
566 "attributes": {
567 "name": "No Album In List",
568 "artistName": "Artist 3",
569 "playParams": {"id": "i.librarytrack3", "isLibrary": True},
570 "durationInMillis": 180000,
571 },
572 "relationships": {},
573 }
574 ]
575 )
576 provider.api_client.get_data = AsyncMock(
577 return_value={
578 "data": [
579 {
580 "id": "i.librarytrack3",
581 "type": "library-songs",
582 "attributes": {
583 "name": "No Album In List",
584 "artistName": "Artist 3",
585 "albumName": "Album From Detail",
586 "playParams": {"id": "i.librarytrack3", "isLibrary": True},
587 "durationInMillis": 180000,
588 },
589 "relationships": {},
590 }
591 ]
592 }
593 )
594 provider.api_client.get_ratings = AsyncMock(return_value={"i.librarytrack3": True})
595
596 manager = AppleMusicLibraryManager(provider)
597 tracks = [track async for track in manager.get_library_tracks()]
598
599 assert len(tracks) == 1
600 assert isinstance(tracks[0].album, ItemMapping)
601 assert tracks[0].album.name == "Album From Detail"
602 provider.api_client.get_data.assert_called_once_with(
603 "me/library/songs", ids="i.librarytrack3", include="catalog,albums,artists"
604 )
605
606
607@pytest.mark.asyncio
608async def test_library_tracks_fetches_detail_for_album_name_only_mapping() -> None:
609 """List items with only albumName fallback should be upgraded to a resolvable album id."""
610 provider = _create_provider_mock()
611 provider.api_client = MagicMock()
612 provider.api_client.iter_all_items = _stream(
613 [
614 {
615 "id": "i.librarytrack4",
616 "type": "library-songs",
617 "attributes": {
618 "name": "Album Name Only",
619 "artistName": "Artist 4",
620 "albumName": "Album Only From List",
621 "playParams": {"id": "i.librarytrack4", "isLibrary": True},
622 "durationInMillis": 180000,
623 },
624 "relationships": {},
625 }
626 ]
627 )
628 provider.api_client.get_data = AsyncMock(
629 return_value={
630 "data": [
631 {
632 "id": "i.librarytrack4",
633 "type": "library-songs",
634 "attributes": {
635 "name": "Album Name Only",
636 "artistName": "Artist 4",
637 "playParams": {"id": "i.librarytrack4", "isLibrary": True},
638 "durationInMillis": 180000,
639 },
640 "relationships": {
641 "albums": {
642 "data": [
643 {
644 "id": "l.album4",
645 "type": "library-albums",
646 "attributes": {
647 "name": "Resolved Album",
648 "artistName": "Artist 4",
649 "playParams": {"id": "l.album4"},
650 },
651 }
652 ]
653 }
654 },
655 }
656 ]
657 }
658 )
659 provider.api_client.get_ratings = AsyncMock(return_value={"i.librarytrack4": True})
660
661 manager = AppleMusicLibraryManager(provider)
662 tracks = [track async for track in manager.get_library_tracks()]
663
664 assert len(tracks) == 1
665 assert tracks[0].album is not None
666 assert tracks[0].album.item_id == "l.album4"
667 assert tracks[0].album.name == "Resolved Album"
668 provider.api_client.get_data.assert_called_once_with(
669 "me/library/songs", ids="i.librarytrack4", include="catalog,albums,artists"
670 )
671
672
673@pytest.mark.asyncio
674async def test_catalog_backed_playlist_uses_library_id_as_item_id() -> None:
675 """
676 Catalog-backed library playlists must use the library ID as item_id.
677
678 When a playlist hasCatalog=True, Apple only accepts write operations
679 (add tracks) against the library endpoint using the library ID (p.XXXXX),
680 not the catalog global ID (pl.u-...).
681 """
682 provider = _create_provider_mock()
683 provider.api_client = MagicMock()
684 provider.api_client.get_all_items = AsyncMock(
685 return_value=[
686 {
687 "id": "p.myLibraryPlaylist",
688 "attributes": {
689 "hasCatalog": True,
690 "canEdit": True,
691 "playParams": {"globalId": "pl.u-abcd1234"},
692 "name": "My Public Playlist",
693 "curatorName": "me",
694 "artwork": None,
695 },
696 }
697 ]
698 )
699 provider.api_client.get_ratings = AsyncMock(return_value={})
700 provider.api_client.get_data = AsyncMock(
701 return_value={
702 "data": [
703 {
704 "id": "pl.u-abcd1234",
705 "attributes": {
706 "name": "My Public Playlist",
707 "curatorName": "me",
708 "playParams": {"globalId": "pl.u-abcd1234"},
709 "canEdit": True,
710 },
711 }
712 ]
713 }
714 )
715
716 manager = AppleMusicLibraryManager(provider)
717 # Exercise parse_playlist through the real media manager.
718 provider.media_manager = AppleMusicMediaManager(provider)
719
720 playlists = [pl async for pl in manager.get_library_playlists()]
721
722 assert len(playlists) == 1
723 playlist = playlists[0]
724 # Must use library ID so add_playlist_tracks targets /me/library/playlists/{id}/tracks.
725 assert playlist.item_id == "p.myLibraryPlaylist"
726 assert all(pm.item_id == "p.myLibraryPlaylist" for pm in playlist.provider_mappings)
727
728
729@pytest.mark.asyncio
730async def test_get_playlist_by_library_id_keeps_library_item_id() -> None:
731 """Direct library playlist fetch must keep the library playlist ID."""
732 provider = _create_provider_mock()
733 provider.api_client = MagicMock()
734 provider.api_client.get_data = AsyncMock(
735 return_value={
736 "data": [
737 {
738 "id": "p.myLibraryPlaylist",
739 "attributes": {
740 "name": "My Public Playlist",
741 "curatorName": "me",
742 "playParams": {"globalId": "pl.u-abcd1234"},
743 "canEdit": True,
744 },
745 }
746 ]
747 }
748 )
749
750 manager = AppleMusicMediaManager(provider)
751 playlist = await manager.get_playlist("p.myLibraryPlaylist")
752
753 provider.api_client.get_data.assert_called_once_with("me/library/playlists/p.myLibraryPlaylist")
754 assert playlist.item_id == "p.myLibraryPlaylist"
755 assert all(pm.item_id == "p.myLibraryPlaylist" for pm in playlist.provider_mappings)
756
757
758@pytest.mark.asyncio
759async def test_get_album_uses_library_endpoint_for_library_id() -> None:
760 """get_album should use me/library/albums/{id} for library IDs (l. prefix)."""
761 provider = _create_provider_mock()
762 provider.api_client = MagicMock()
763 provider.api_client.get_data = AsyncMock(
764 return_value={
765 "data": [
766 {
767 "id": "l.PnamISl",
768 "type": "library-albums",
769 "attributes": {
770 "name": "Uploaded Album",
771 "artistName": "Uploaded Artist",
772 "playParams": {"id": "l.PnamISl", "isLibrary": True},
773 },
774 "relationships": {},
775 }
776 ]
777 }
778 )
779 provider.api_client.get_ratings = AsyncMock(return_value={})
780
781 manager = AppleMusicMediaManager(provider)
782 album = await manager.get_album("l.PnamISl")
783
784 provider.api_client.get_data.assert_called_once_with(
785 "me/library/albums/l.PnamISl", include="catalog,artists"
786 )
787 assert isinstance(album, Album)
788 assert album.name == "Uploaded Album"
789
790
791@pytest.mark.asyncio
792async def test_get_album_tracks_uses_library_endpoint_for_library_id() -> None:
793 """get_album_tracks should use me/library/albums/{id}/tracks for library IDs."""
794 provider = _create_provider_mock()
795 provider.api_client = MagicMock()
796 provider.api_client.get_data = AsyncMock(
797 side_effect=[
798 # First call: get_album_tracks endpoint
799 {
800 "data": [
801 {
802 "id": "i.track1",
803 "type": "library-songs",
804 "attributes": {
805 "name": "Track 1",
806 "artistName": "Uploaded Artist",
807 "durationInMillis": 180000,
808 "playParams": {"id": "i.track1", "isLibrary": True},
809 },
810 "relationships": {},
811 }
812 ]
813 },
814 # Second call: get_album (called internally)
815 {
816 "data": [
817 {
818 "id": "l.PnamISl",
819 "type": "library-albums",
820 "attributes": {
821 "name": "Uploaded Album",
822 "artistName": "Uploaded Artist",
823 "playParams": {"id": "l.PnamISl", "isLibrary": True},
824 },
825 "relationships": {},
826 }
827 ]
828 },
829 ]
830 )
831 provider.api_client.get_ratings = AsyncMock(return_value={})
832
833 manager = AppleMusicMediaManager(provider)
834 tracks = await manager.get_album_tracks("l.PnamISl")
835
836 first_call_args = provider.api_client.get_data.call_args_list[0]
837 assert first_call_args[0][0] == "me/library/albums/l.PnamISl/tracks"
838 assert len(tracks) == 1
839 assert tracks[0].name == "Track 1"
840
841
842@pytest.mark.asyncio
843async def test_get_album_uses_catalog_endpoint_for_catalog_id() -> None:
844 """get_album should continue using the catalog endpoint for numeric catalog IDs."""
845 provider = _create_provider_mock()
846 provider.api_client = MagicMock()
847 provider.api_client.get_data = AsyncMock(
848 return_value={
849 "data": [
850 {
851 "id": "123456789",
852 "type": "albums",
853 "attributes": {
854 "name": "Catalog Album",
855 "artistName": "Catalog Artist",
856 "playParams": {"id": "123456789"},
857 "url": "https://music.apple.com/album/123456789",
858 },
859 "relationships": {},
860 }
861 ]
862 }
863 )
864 provider.api_client.get_ratings = AsyncMock(return_value={})
865
866 manager = AppleMusicMediaManager(provider)
867 await manager.get_album("123456789")
868
869 provider.api_client.get_data.assert_called_once_with(
870 "catalog/us/albums/123456789", include="artists"
871 )
872
873
874def test_parse_artwork_image_stores_stable_token_for_expiring_urls() -> None:
875 """Blobstore artwork (presigned, expiring) is stored as a resolvable token."""
876 provider = _create_provider_mock()
877
878 image = parse_artwork_image(
879 provider, MediaType.ALBUM, "l.album1", {"artwork": BLOBSTORE_ARTWORK}
880 )
881
882 assert image is not None
883 assert image.path == "album/l.album1"
884 assert image.remotely_accessible is False
885 assert image.provider == "apple_music_test"
886
887
888def test_parse_artwork_image_keeps_permanent_cdn_urls() -> None:
889 """Mzstatic artwork is permanent and stored as a directly accessible URL."""
890 provider = _create_provider_mock()
891
892 image = parse_artwork_image(
893 provider, MediaType.ALBUM, "1234567890", {"artwork": CATALOG_ARTWORK}
894 )
895
896 assert image is not None
897 assert image.path == "https://is1-ssl.mzstatic.com/image/thumb/Music/1000x1000bb.jpg"
898 assert image.remotely_accessible is True
899
900
901def test_parse_artwork_image_without_artwork() -> None:
902 """Items without (usable) artwork produce no image."""
903 provider = _create_provider_mock()
904
905 assert parse_artwork_image(provider, MediaType.ALBUM, "x", {}) is None
906 assert parse_artwork_image(provider, MediaType.ALBUM, "x", {"artwork": {"width": 1}}) is None
907
908
909def test_parse_album_stores_artwork_token_for_library_album() -> None:
910 """A library album with blobstore artwork ends up with a token image."""
911 provider = _create_provider_mock()
912 album_obj = {
913 "id": "l.album1",
914 "type": "library-albums",
915 "attributes": {
916 "name": "Uploaded Album",
917 "artistName": "Uploaded Artist",
918 "playParams": {"id": "l.album1"},
919 "artwork": BLOBSTORE_ARTWORK,
920 },
921 "relationships": {},
922 }
923
924 album = parse_album(provider, album_obj)
925
926 assert isinstance(album, Album)
927 assert [(image.path, image.remotely_accessible) for image in album.metadata.images or []] == [
928 ("album/l.album1", False)
929 ]
930
931
932async def test_get_artwork_url_returns_fresh_signed_url() -> None:
933 """The artwork token resolves to the current signed URL from the api."""
934 provider = _create_provider_mock()
935 provider.api_client.get_data = AsyncMock(
936 return_value={"data": [{"id": "l.album1", "attributes": {"artwork": BLOBSTORE_ARTWORK}}]}
937 )
938
939 manager = AppleMusicMediaManager(provider)
940 url = await manager.get_artwork_url("album", "l.album1")
941
942 assert url == BLOBSTORE_ARTWORK["url"]
943 provider.api_client.get_data.assert_called_once_with(
944 "me/library/albums/l.album1", include="catalog"
945 )
946
947
948async def test_get_artwork_url_falls_back_to_catalog_attributes() -> None:
949 """A library item without own artwork resolves via its catalog counterpart."""
950 provider = _create_provider_mock()
951 provider.api_client.get_data = AsyncMock(
952 return_value={
953 "data": [
954 {
955 "id": "l.album1",
956 "attributes": {"name": "Uploaded Album"},
957 "relationships": {
958 "catalog": {
959 "data": [
960 {"id": "123456789", "attributes": {"artwork": CATALOG_ARTWORK}}
961 ]
962 }
963 },
964 }
965 ]
966 }
967 )
968
969 manager = AppleMusicMediaManager(provider)
970 url = await manager.get_artwork_url("album", "l.album1")
971
972 assert url == "https://is1-ssl.mzstatic.com/image/thumb/Music/1000x1000bb.jpg"
973
974
975async def test_get_artwork_url_routes_library_tracks_to_library_endpoint() -> None:
976 """Library song tokens (uploaded music) resolve via me/library, not the catalog."""
977 provider = _create_provider_mock()
978 provider.api_client.get_data = AsyncMock(
979 return_value={"data": [{"id": "i.track1", "attributes": {"artwork": BLOBSTORE_ARTWORK}}]}
980 )
981
982 manager = AppleMusicMediaManager(provider)
983 url = await manager.get_artwork_url("track", "i.track1")
984
985 assert url == BLOBSTORE_ARTWORK["url"]
986 provider.api_client.get_data.assert_called_once_with(
987 "me/library/songs/i.track1", include="catalog"
988 )
989
990
991async def test_get_artwork_url_routes_catalog_tracks_to_catalog_endpoint() -> None:
992 """Catalog song tokens resolve via the catalog endpoint."""
993 provider = _create_provider_mock()
994 provider.api_client.get_data = AsyncMock(
995 return_value={"data": [{"id": "1440783625", "attributes": {"artwork": CATALOG_ARTWORK}}]}
996 )
997
998 manager = AppleMusicMediaManager(provider)
999 await manager.get_artwork_url("track", "1440783625")
1000
1001 provider.api_client.get_data.assert_called_once_with(
1002 "catalog/us/songs/1440783625", include="catalog"
1003 )
1004
1005
1006async def test_get_artwork_url_unknown_media_type() -> None:
1007 """An unknown token media type resolves to nothing instead of raising."""
1008 provider = _create_provider_mock()
1009 manager = AppleMusicMediaManager(provider)
1010
1011 assert await manager.get_artwork_url("bogus", "1") is None
1012
1013
1014def _availability(parsed: Any) -> bool:
1015 """Return the availability flag off a parsed item's single provider mapping."""
1016 return bool(next(iter(parsed.provider_mappings)).available)
1017
1018
1019def _library_song_obj(play_params: dict[str, Any] | None) -> dict[str, Any]:
1020 """Build a library-songs object with no catalog twin and the given playParams."""
1021 return {
1022 "id": "i.librarysong",
1023 "type": "library-songs",
1024 "attributes": {
1025 "name": "Library Song",
1026 "artistName": "Artist 1",
1027 "albumName": "Album 1",
1028 "durationInMillis": 180000,
1029 **({"playParams": play_params} if play_params is not None else {}),
1030 },
1031 "relationships": {},
1032 }
1033
1034
1035def test_parse_track_purchase_only_is_unavailable() -> None:
1036 """A purchase with no catalog twin has playParams but Apple refuses to stream it."""
1037 provider = _create_provider_mock()
1038 track_obj = _library_song_obj(
1039 {"id": "i.librarysong", "kind": "song", "isLibrary": True, "purchasedId": "397010985"}
1040 )
1041
1042 result = parse_track(provider, track_obj)
1043
1044 assert _availability(result) is False
1045
1046
1047def test_parse_track_without_play_params_is_unavailable() -> None:
1048 """Apple omits a usable playParams entirely for withdrawn catalog items."""
1049 provider = _create_provider_mock()
1050
1051 result = parse_track(provider, _library_song_obj({}))
1052
1053 assert _availability(result) is False
1054
1055
1056def test_parse_track_upload_stays_available() -> None:
1057 """An upload carries neither purchasedId nor catalogId and must stay playable."""
1058 provider = _create_provider_mock()
1059 track_obj = _library_song_obj({"id": "i.librarysong", "kind": "song", "isLibrary": True})
1060
1061 result = parse_track(provider, track_obj)
1062
1063 assert _availability(result) is True
1064
1065
1066def test_parse_track_purchase_with_catalog_twin_is_available() -> None:
1067 """A purchase that also exists in the catalog is playable from the catalog."""
1068 provider = _create_provider_mock()
1069 track_obj = _library_song_obj(
1070 {
1071 "id": "i.librarysong",
1072 "kind": "song",
1073 "isLibrary": True,
1074 "purchasedId": "397010985",
1075 "catalogId": "1440774173",
1076 }
1077 )
1078
1079 result = parse_track(provider, track_obj)
1080
1081 assert _availability(result) is True
1082
1083
1084def test_parse_track_catalog_song_is_available() -> None:
1085 """A plain catalog song keeps the previous behaviour."""
1086 provider = _create_provider_mock()
1087 track_obj = {
1088 "id": "1440774173",
1089 "type": "songs",
1090 "attributes": {
1091 "name": "Catalog Song",
1092 "artistName": "Artist 1",
1093 "durationInMillis": 180000,
1094 "playParams": {"id": "1440774173", "kind": "song"},
1095 },
1096 "relationships": {},
1097 }
1098
1099 result = parse_track(provider, track_obj)
1100
1101 assert _availability(result) is True
1102
1103
1104def test_parse_album_without_play_params_is_unavailable() -> None:
1105 """An album Apple will not serve carries no usable playParams."""
1106 provider = _create_provider_mock()
1107 album_obj = {
1108 "id": "l.libraryalbum",
1109 "type": "library-albums",
1110 "attributes": {"name": "Library Album", "artistName": "Artist 1", "playParams": {}},
1111 "relationships": {},
1112 }
1113
1114 result = parse_album(provider, album_obj)
1115
1116 assert _availability(result) is False
1117
1118
1119def test_parse_album_library_album_is_available() -> None:
1120 """
1121 A library album with usable playParams stays available.
1122
1123 Apple exposes no purchase marker on a library album - its playParams carry only
1124 id/kind/isLibrary - so the purchase-only rule cannot fire here even when every
1125 track on the album is a purchase. See music-assistant/support#6032.
1126 """
1127 provider = _create_provider_mock()
1128 album_obj = {
1129 "id": "l.libraryalbum",
1130 "type": "library-albums",
1131 "attributes": {
1132 "name": "Library Album",
1133 "artistName": "Artist 1",
1134 "playParams": {"id": "l.libraryalbum", "kind": "album", "isLibrary": True},
1135 },
1136 "relationships": {},
1137 }
1138
1139 result = parse_album(provider, album_obj)
1140
1141 assert _availability(result) is True
1142