/
/
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 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_compilation_uses_album_level_artist_name() -> None:
100 """Compilations must show the album-level artist, not a contributing artist."""
101 provider = _create_provider_mock()
102 album_obj = _make_album_obj(
103 {"artistName": "Various Artists", "isCompilation": True},
104 _artists_relationship("Paul McCartney"),
105 )
106
107 result = parse_album(provider, album_obj)
108
109 assert isinstance(result, Album)
110 assert [artist.name for artist in result.artists] == ["Various Artists"]
111
112
113def test_parse_album_compilation_without_artist_name_keeps_related_artists() -> None:
114 """A compilation without artistName must fall back to the related artists."""
115 provider = _create_provider_mock()
116 album_obj = _make_album_obj(
117 {"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] == ["Paul McCartney"]
125
126
127def test_parse_album_single_artist_compilation_keeps_rich_artist() -> None:
128 """A greatest-hits compilation by one artist must keep the full artist object."""
129 provider = _create_provider_mock()
130 album_obj = _make_album_obj(
131 {"artistName": "Eminem", "isCompilation": True},
132 _artists_relationship("Eminem"),
133 )
134
135 result = parse_album(provider, album_obj)
136
137 assert isinstance(result, Album)
138 assert len(result.artists) == 1
139 assert result.artists[0].name == "Eminem"
140 assert result.artists[0].item_id == "artist0"
141
142
143def test_parse_album_compilation_keeps_catalog_artist_details() -> None:
144 """A compilation must keep complete catalog details for a library artist."""
145 provider = _create_provider_mock()
146 album_obj = _make_album_obj(
147 {"artistName": "Eminem", "isCompilation": True},
148 {
149 "artists": {
150 "data": [
151 {
152 "id": "l.artist0",
153 "type": "library-artists",
154 "relationships": {
155 "catalog": {
156 "data": [
157 {
158 "id": "artist0",
159 "type": "artists",
160 "attributes": {"name": "Eminem"},
161 }
162 ]
163 }
164 },
165 }
166 ]
167 }
168 },
169 )
170
171 result = parse_album(provider, album_obj)
172
173 assert isinstance(result, Album)
174 assert len(result.artists) == 1
175 assert result.artists[0].name == "Eminem"
176 assert result.artists[0].item_id == "artist0"
177
178
179def test_parse_album_dj_mix_compilation_keeps_multiple_artists() -> None:
180 """A DJ-mix compilation with multiple related artists must keep them all."""
181 provider = _create_provider_mock()
182 album_obj = _make_album_obj(
183 {"artistName": "Pete Tong & Boy George", "isCompilation": True},
184 _artists_relationship("Pete Tong", "Boy George"),
185 )
186
187 result = parse_album(provider, album_obj)
188
189 assert isinstance(result, Album)
190 assert [artist.name for artist in result.artists] == ["Pete Tong", "Boy George"]
191
192
193@pytest.mark.parametrize(
194 "artist_obj",
195 [
196 {"id": "80204262", "type": "artists"},
197 {"id": "80204262", "type": "artists", "attributes": {}},
198 {
199 "id": "l.artist.80204262",
200 "type": "library-artists",
201 "relationships": {"catalog": {"data": [{"id": "80204262", "type": "artists"}]}},
202 },
203 ],
204)
205def test_parse_album_compilation_ignores_placeholder_artist_stub(
206 artist_obj: dict[str, Any],
207) -> None:
208 """An unresolvable artist stub must not become the album artist."""
209 provider = _create_provider_mock()
210 album_obj = _make_album_obj(
211 {"artistName": "Verschillende artiesten", "isCompilation": True},
212 {"artists": {"data": [artist_obj]}},
213 )
214
215 result = parse_album(provider, album_obj)
216
217 assert isinstance(result, Album)
218 assert [artist.name for artist in result.artists] == ["Verschillende artiesten"]
219
220
221def test_parse_album_compilation_with_empty_artists_uses_artist_name() -> None:
222 """A compilation without any related artists must use the album-level artist."""
223 provider = _create_provider_mock()
224 album_obj = _make_album_obj(
225 {"artistName": "Various Artists", "isCompilation": True},
226 {"artists": {"data": []}},
227 )
228
229 result = parse_album(provider, album_obj)
230
231 assert isinstance(result, Album)
232 assert [artist.name for artist in result.artists] == ["Various Artists"]
233
234
235def test_parse_album_regular_album_keeps_related_artists() -> None:
236 """A regular album must keep the artists from the relationships."""
237 provider = _create_provider_mock()
238 album_obj = _make_album_obj(
239 {"artistName": "Paul McCartney"},
240 _artists_relationship("Paul McCartney"),
241 )
242
243 result = parse_album(provider, album_obj)
244
245 assert isinstance(result, Album)
246 assert [artist.name for artist in result.artists] == ["Paul McCartney"]
247
248
249def test_parse_track_falls_back_to_album_name_when_relationship_missing() -> None:
250 """Track parsing should keep album info from albumName if no album relation is present."""
251 provider = _create_provider_mock()
252 track_obj = {
253 "id": "track1",
254 "type": "songs",
255 "attributes": {
256 "name": "Track 1",
257 "artistName": "Artist 1",
258 "albumName": "Album 1",
259 "durationInMillis": 180000,
260 "playParams": {"id": "track1"},
261 },
262 "relationships": {},
263 }
264
265 result = parse_track(provider, track_obj)
266
267 assert isinstance(result.album, ItemMapping)
268 assert result.album.name == "Album 1"
269
270
271def test_parse_track_library_song_uses_library_album_name_fallback() -> None:
272 """library-songs should fall back to library albumName when catalog attributes miss it."""
273 provider = _create_provider_mock()
274 track_obj = {
275 "id": "i.librarytrack1",
276 "type": "library-songs",
277 "attributes": {
278 "name": "Track 1 (Library)",
279 "artistName": "Artist 1",
280 "albumName": "Album From Library",
281 "playParams": {"catalogId": "123456789"},
282 "durationInMillis": 180000,
283 },
284 "relationships": {
285 "catalog": {
286 "data": [
287 {
288 "id": "123456789",
289 "attributes": {
290 "name": "Track 1 (Catalog)",
291 "artistName": "Artist 1",
292 "durationInMillis": 180000,
293 "playParams": {"id": "123456789"},
294 },
295 }
296 ]
297 }
298 },
299 }
300
301 result = parse_track(provider, track_obj)
302
303 assert isinstance(result.album, ItemMapping)
304 assert result.album.name == "Album From Library"
305
306
307def test_parse_track_empty_artists_relationship_uses_artist_name() -> None:
308 """An artists relationship without data should fall back to artistName."""
309 provider = _create_provider_mock()
310 track_obj = {
311 "id": "i.librarytrack1",
312 "type": "library-songs",
313 "attributes": {
314 "name": "Mr. Brightside",
315 "artistName": "The Killers",
316 "durationInMillis": 222000,
317 "playParams": {"catalogId": "1526194192"},
318 },
319 "relationships": {
320 "artists": {"data": []},
321 "catalog": {
322 "data": [
323 {
324 "id": "1526194192",
325 "attributes": {
326 "name": "Mr. Brightside",
327 "artistName": "The Killers",
328 "durationInMillis": 222000,
329 "playParams": {"id": "1526194192"},
330 },
331 }
332 ]
333 },
334 },
335 }
336
337 result = parse_track(provider, track_obj)
338
339 assert len(result.artists) == 1
340 assert result.artists[0].name == "The Killers"
341
342
343@pytest.mark.asyncio
344async def test_media_manager_get_playlist_applies_can_edit_hint() -> None:
345 """Catalog playlist fetch should honor library editability hint when provided."""
346 provider = _create_provider_mock()
347 provider.api_client = MagicMock()
348 provider.api_client.get_data = AsyncMock(
349 return_value={
350 "data": [
351 {
352 "id": "pl.catalog1",
353 "attributes": {
354 "name": "Catalog Playlist",
355 "playParams": {"globalId": "pl.catalog1"},
356 },
357 }
358 ]
359 }
360 )
361
362 manager = AppleMusicMediaManager(provider)
363
364 playlist = await manager.get_playlist("pl.catalog1", can_edit_hint=True)
365
366 assert playlist.is_editable is True
367
368
369@pytest.mark.asyncio
370async def test_library_playlists_preserve_can_edit_for_catalog_copy() -> None:
371 """Library playlist sync should preserve canEdit when loading via catalog copy."""
372 provider = _create_provider_mock()
373 provider.api_client = MagicMock()
374 provider.api_client.get_all_items = AsyncMock(
375 return_value=[
376 {
377 "id": "p.library1",
378 "attributes": {
379 "hasCatalog": True,
380 "canEdit": True,
381 "playParams": {"globalId": "pl.catalog1"},
382 },
383 }
384 ]
385 )
386 provider.api_client.get_ratings = AsyncMock(return_value={})
387 provider.media_manager = MagicMock()
388 provider.media_manager.get_playlist = AsyncMock(return_value=MagicMock())
389
390 manager = AppleMusicLibraryManager(provider)
391
392 _ = [playlist async for playlist in manager.get_library_playlists()]
393
394 provider.media_manager.get_playlist.assert_called_once_with(
395 "pl.catalog1",
396 ANY,
397 can_edit_hint=True,
398 library_id_override="p.library1",
399 )
400
401
402@pytest.mark.asyncio
403async def test_library_tracks_request_includes_album_relations() -> None:
404 """Library track sync should request catalog/album/artist relations from Apple API."""
405 provider = _create_provider_mock()
406 provider.api_client = MagicMock()
407 provider.api_client.iter_all_items = _stream([])
408 provider.api_client.get_ratings = AsyncMock(return_value={})
409
410 manager = AppleMusicLibraryManager(provider)
411 _ = [track async for track in manager.get_library_tracks()]
412
413 provider.api_client.iter_all_items.assert_called_once_with(
414 "me/library/songs", include="catalog,albums,artists", page_size=_TRACK_PAGE_SIZE
415 )
416
417
418@pytest.mark.asyncio
419async def test_library_tracks_falls_back_to_library_item_when_catalog_missing() -> None:
420 """Library sync should still parse track if catalog endpoint omits a catalog ID."""
421 provider = _create_provider_mock()
422 provider.api_client = MagicMock()
423 provider.api_client.iter_all_items = _stream(
424 [
425 {
426 "id": "i.librarytrack2",
427 "type": "library-songs",
428 "attributes": {
429 "name": "Missing Catalog Track",
430 "artistName": "Artist 2",
431 "albumName": "Album 2",
432 "playParams": {"catalogId": "999999"},
433 "durationInMillis": 180000,
434 },
435 "relationships": {
436 "catalog": {
437 "data": [
438 {
439 "id": "999999",
440 "attributes": {
441 "name": "Missing Catalog Track",
442 "artistName": "Artist 2",
443 "durationInMillis": 180000,
444 "playParams": {"id": "999999"},
445 },
446 }
447 ]
448 }
449 },
450 }
451 ]
452 )
453 provider.api_client.get_data = AsyncMock(return_value={"data": []})
454 provider.api_client.get_ratings = AsyncMock(return_value={"999999": True})
455
456 manager = AppleMusicLibraryManager(provider)
457 tracks = [track async for track in manager.get_library_tracks()]
458
459 assert len(tracks) == 1
460 assert tracks[0].name == "Missing Catalog Track"
461 assert isinstance(tracks[0].album, ItemMapping)
462 assert tracks[0].album.name == "Album 2"
463
464
465@pytest.mark.asyncio
466async def test_library_tracks_replaces_weak_catalog_album_mapping() -> None:
467 """A weak catalog albumName mapping should be replaced by library album relation."""
468 provider = _create_provider_mock()
469 provider.api_client = MagicMock()
470 provider.api_client.iter_all_items = _stream(
471 [
472 {
473 "id": "i.librarytrack5",
474 "type": "library-songs",
475 "attributes": {
476 "name": "Weak Catalog Album Mapping",
477 "artistName": "Artist 5",
478 "playParams": {"catalogId": "555555"},
479 "durationInMillis": 180000,
480 },
481 "relationships": {
482 "catalog": {
483 "data": [
484 {
485 "id": "555555",
486 "attributes": {
487 "name": "Weak Catalog Album Mapping",
488 "artistName": "Artist 5",
489 "albumName": "Name Only From Catalog",
490 "durationInMillis": 180000,
491 "playParams": {"id": "555555"},
492 },
493 }
494 ]
495 },
496 "albums": {
497 "data": [
498 {
499 "id": "l.album5",
500 "type": "library-albums",
501 "attributes": {
502 "name": "Resolved Library Album",
503 "artistName": "Artist 5",
504 "playParams": {"id": "l.album5"},
505 },
506 }
507 ]
508 },
509 },
510 }
511 ]
512 )
513 provider.api_client.get_data = AsyncMock(
514 return_value={
515 "data": [
516 {
517 "id": "555555",
518 "type": "songs",
519 "attributes": {
520 "name": "Weak Catalog Album Mapping",
521 "artistName": "Artist 5",
522 "albumName": "Name Only From Catalog",
523 "durationInMillis": 180000,
524 "playParams": {"id": "555555"},
525 },
526 "relationships": {},
527 }
528 ]
529 }
530 )
531 provider.api_client.get_ratings = AsyncMock(return_value={"555555": True})
532
533 manager = AppleMusicLibraryManager(provider)
534 tracks = [track async for track in manager.get_library_tracks()]
535
536 assert len(tracks) == 1
537 assert tracks[0].album is not None
538 assert tracks[0].album.item_id == "l.album5"
539 assert tracks[0].album.name == "Resolved Library Album"
540
541
542@pytest.mark.asyncio
543async def test_library_tracks_fetches_detail_when_list_item_has_no_album() -> None:
544 """Library-only tracks should be reparsed from detail endpoint when album is missing."""
545 provider = _create_provider_mock()
546 provider.api_client = MagicMock()
547 provider.api_client.iter_all_items = _stream(
548 [
549 {
550 "id": "i.librarytrack3",
551 "type": "library-songs",
552 "attributes": {
553 "name": "No Album In List",
554 "artistName": "Artist 3",
555 "playParams": {"id": "i.librarytrack3", "isLibrary": True},
556 "durationInMillis": 180000,
557 },
558 "relationships": {},
559 }
560 ]
561 )
562 provider.api_client.get_data = AsyncMock(
563 return_value={
564 "data": [
565 {
566 "id": "i.librarytrack3",
567 "type": "library-songs",
568 "attributes": {
569 "name": "No Album In List",
570 "artistName": "Artist 3",
571 "albumName": "Album From Detail",
572 "playParams": {"id": "i.librarytrack3", "isLibrary": True},
573 "durationInMillis": 180000,
574 },
575 "relationships": {},
576 }
577 ]
578 }
579 )
580 provider.api_client.get_ratings = AsyncMock(return_value={"i.librarytrack3": True})
581
582 manager = AppleMusicLibraryManager(provider)
583 tracks = [track async for track in manager.get_library_tracks()]
584
585 assert len(tracks) == 1
586 assert isinstance(tracks[0].album, ItemMapping)
587 assert tracks[0].album.name == "Album From Detail"
588 provider.api_client.get_data.assert_called_once_with(
589 "me/library/songs", ids="i.librarytrack3", include="catalog,albums,artists"
590 )
591
592
593@pytest.mark.asyncio
594async def test_library_tracks_fetches_detail_for_album_name_only_mapping() -> None:
595 """List items with only albumName fallback should be upgraded to a resolvable album id."""
596 provider = _create_provider_mock()
597 provider.api_client = MagicMock()
598 provider.api_client.iter_all_items = _stream(
599 [
600 {
601 "id": "i.librarytrack4",
602 "type": "library-songs",
603 "attributes": {
604 "name": "Album Name Only",
605 "artistName": "Artist 4",
606 "albumName": "Album Only From List",
607 "playParams": {"id": "i.librarytrack4", "isLibrary": True},
608 "durationInMillis": 180000,
609 },
610 "relationships": {},
611 }
612 ]
613 )
614 provider.api_client.get_data = AsyncMock(
615 return_value={
616 "data": [
617 {
618 "id": "i.librarytrack4",
619 "type": "library-songs",
620 "attributes": {
621 "name": "Album Name Only",
622 "artistName": "Artist 4",
623 "playParams": {"id": "i.librarytrack4", "isLibrary": True},
624 "durationInMillis": 180000,
625 },
626 "relationships": {
627 "albums": {
628 "data": [
629 {
630 "id": "l.album4",
631 "type": "library-albums",
632 "attributes": {
633 "name": "Resolved Album",
634 "artistName": "Artist 4",
635 "playParams": {"id": "l.album4"},
636 },
637 }
638 ]
639 }
640 },
641 }
642 ]
643 }
644 )
645 provider.api_client.get_ratings = AsyncMock(return_value={"i.librarytrack4": True})
646
647 manager = AppleMusicLibraryManager(provider)
648 tracks = [track async for track in manager.get_library_tracks()]
649
650 assert len(tracks) == 1
651 assert tracks[0].album is not None
652 assert tracks[0].album.item_id == "l.album4"
653 assert tracks[0].album.name == "Resolved Album"
654 provider.api_client.get_data.assert_called_once_with(
655 "me/library/songs", ids="i.librarytrack4", include="catalog,albums,artists"
656 )
657
658
659@pytest.mark.asyncio
660async def test_catalog_backed_playlist_uses_library_id_as_item_id() -> None:
661 """
662 Catalog-backed library playlists must use the library ID as item_id.
663
664 When a playlist hasCatalog=True, Apple only accepts write operations
665 (add tracks) against the library endpoint using the library ID (p.XXXXX),
666 not the catalog global ID (pl.u-...).
667 """
668 provider = _create_provider_mock()
669 provider.api_client = MagicMock()
670 provider.api_client.get_all_items = AsyncMock(
671 return_value=[
672 {
673 "id": "p.myLibraryPlaylist",
674 "attributes": {
675 "hasCatalog": True,
676 "canEdit": True,
677 "playParams": {"globalId": "pl.u-abcd1234"},
678 "name": "My Public Playlist",
679 "curatorName": "me",
680 "artwork": None,
681 },
682 }
683 ]
684 )
685 provider.api_client.get_ratings = AsyncMock(return_value={})
686 provider.api_client.get_data = AsyncMock(
687 return_value={
688 "data": [
689 {
690 "id": "pl.u-abcd1234",
691 "attributes": {
692 "name": "My Public Playlist",
693 "curatorName": "me",
694 "playParams": {"globalId": "pl.u-abcd1234"},
695 "canEdit": True,
696 },
697 }
698 ]
699 }
700 )
701
702 manager = AppleMusicLibraryManager(provider)
703 # Exercise parse_playlist through the real media manager.
704 provider.media_manager = AppleMusicMediaManager(provider)
705
706 playlists = [pl async for pl in manager.get_library_playlists()]
707
708 assert len(playlists) == 1
709 playlist = playlists[0]
710 # Must use library ID so add_playlist_tracks targets /me/library/playlists/{id}/tracks.
711 assert playlist.item_id == "p.myLibraryPlaylist"
712 assert all(pm.item_id == "p.myLibraryPlaylist" for pm in playlist.provider_mappings)
713
714
715@pytest.mark.asyncio
716async def test_get_playlist_by_library_id_keeps_library_item_id() -> None:
717 """Direct library playlist fetch must keep the library playlist ID."""
718 provider = _create_provider_mock()
719 provider.api_client = MagicMock()
720 provider.api_client.get_data = AsyncMock(
721 return_value={
722 "data": [
723 {
724 "id": "p.myLibraryPlaylist",
725 "attributes": {
726 "name": "My Public Playlist",
727 "curatorName": "me",
728 "playParams": {"globalId": "pl.u-abcd1234"},
729 "canEdit": True,
730 },
731 }
732 ]
733 }
734 )
735
736 manager = AppleMusicMediaManager(provider)
737 playlist = await manager.get_playlist("p.myLibraryPlaylist")
738
739 provider.api_client.get_data.assert_called_once_with("me/library/playlists/p.myLibraryPlaylist")
740 assert playlist.item_id == "p.myLibraryPlaylist"
741 assert all(pm.item_id == "p.myLibraryPlaylist" for pm in playlist.provider_mappings)
742
743
744@pytest.mark.asyncio
745async def test_get_album_uses_library_endpoint_for_library_id() -> None:
746 """get_album should use me/library/albums/{id} for library IDs (l. prefix)."""
747 provider = _create_provider_mock()
748 provider.api_client = MagicMock()
749 provider.api_client.get_data = AsyncMock(
750 return_value={
751 "data": [
752 {
753 "id": "l.PnamISl",
754 "type": "library-albums",
755 "attributes": {
756 "name": "Uploaded Album",
757 "artistName": "Uploaded Artist",
758 "playParams": {"id": "l.PnamISl", "isLibrary": True},
759 },
760 "relationships": {},
761 }
762 ]
763 }
764 )
765 provider.api_client.get_ratings = AsyncMock(return_value={})
766
767 manager = AppleMusicMediaManager(provider)
768 album = await manager.get_album("l.PnamISl")
769
770 provider.api_client.get_data.assert_called_once_with(
771 "me/library/albums/l.PnamISl", include="catalog,artists"
772 )
773 assert isinstance(album, Album)
774 assert album.name == "Uploaded Album"
775
776
777@pytest.mark.asyncio
778async def test_get_album_tracks_uses_library_endpoint_for_library_id() -> None:
779 """get_album_tracks should use me/library/albums/{id}/tracks for library IDs."""
780 provider = _create_provider_mock()
781 provider.api_client = MagicMock()
782 provider.api_client.get_data = AsyncMock(
783 side_effect=[
784 # First call: get_album_tracks endpoint
785 {
786 "data": [
787 {
788 "id": "i.track1",
789 "type": "library-songs",
790 "attributes": {
791 "name": "Track 1",
792 "artistName": "Uploaded Artist",
793 "durationInMillis": 180000,
794 "playParams": {"id": "i.track1", "isLibrary": True},
795 },
796 "relationships": {},
797 }
798 ]
799 },
800 # Second call: get_album (called internally)
801 {
802 "data": [
803 {
804 "id": "l.PnamISl",
805 "type": "library-albums",
806 "attributes": {
807 "name": "Uploaded Album",
808 "artistName": "Uploaded Artist",
809 "playParams": {"id": "l.PnamISl", "isLibrary": True},
810 },
811 "relationships": {},
812 }
813 ]
814 },
815 ]
816 )
817 provider.api_client.get_ratings = AsyncMock(return_value={})
818
819 manager = AppleMusicMediaManager(provider)
820 tracks = await manager.get_album_tracks("l.PnamISl")
821
822 first_call_args = provider.api_client.get_data.call_args_list[0]
823 assert first_call_args[0][0] == "me/library/albums/l.PnamISl/tracks"
824 assert len(tracks) == 1
825 assert tracks[0].name == "Track 1"
826
827
828@pytest.mark.asyncio
829async def test_get_album_uses_catalog_endpoint_for_catalog_id() -> None:
830 """get_album should continue using the catalog endpoint for numeric catalog IDs."""
831 provider = _create_provider_mock()
832 provider.api_client = MagicMock()
833 provider.api_client.get_data = AsyncMock(
834 return_value={
835 "data": [
836 {
837 "id": "123456789",
838 "type": "albums",
839 "attributes": {
840 "name": "Catalog Album",
841 "artistName": "Catalog Artist",
842 "playParams": {"id": "123456789"},
843 "url": "https://music.apple.com/album/123456789",
844 },
845 "relationships": {},
846 }
847 ]
848 }
849 )
850 provider.api_client.get_ratings = AsyncMock(return_value={})
851
852 manager = AppleMusicMediaManager(provider)
853 await manager.get_album("123456789")
854
855 provider.api_client.get_data.assert_called_once_with(
856 "catalog/us/albums/123456789", include="artists"
857 )
858
859
860def test_parse_artwork_image_stores_stable_token_for_expiring_urls() -> None:
861 """Blobstore artwork (presigned, expiring) is stored as a resolvable token."""
862 provider = _create_provider_mock()
863
864 image = parse_artwork_image(
865 provider, MediaType.ALBUM, "l.album1", {"artwork": BLOBSTORE_ARTWORK}
866 )
867
868 assert image is not None
869 assert image.path == "album/l.album1"
870 assert image.remotely_accessible is False
871 assert image.provider == "apple_music_test"
872
873
874def test_parse_artwork_image_keeps_permanent_cdn_urls() -> None:
875 """Mzstatic artwork is permanent and stored as a directly accessible URL."""
876 provider = _create_provider_mock()
877
878 image = parse_artwork_image(
879 provider, MediaType.ALBUM, "1234567890", {"artwork": CATALOG_ARTWORK}
880 )
881
882 assert image is not None
883 assert image.path == "https://is1-ssl.mzstatic.com/image/thumb/Music/1000x1000bb.jpg"
884 assert image.remotely_accessible is True
885
886
887def test_parse_artwork_image_without_artwork() -> None:
888 """Items without (usable) artwork produce no image."""
889 provider = _create_provider_mock()
890
891 assert parse_artwork_image(provider, MediaType.ALBUM, "x", {}) is None
892 assert parse_artwork_image(provider, MediaType.ALBUM, "x", {"artwork": {"width": 1}}) is None
893
894
895def test_parse_album_stores_artwork_token_for_library_album() -> None:
896 """A library album with blobstore artwork ends up with a token image."""
897 provider = _create_provider_mock()
898 album_obj = {
899 "id": "l.album1",
900 "type": "library-albums",
901 "attributes": {
902 "name": "Uploaded Album",
903 "artistName": "Uploaded Artist",
904 "playParams": {"id": "l.album1"},
905 "artwork": BLOBSTORE_ARTWORK,
906 },
907 "relationships": {},
908 }
909
910 album = parse_album(provider, album_obj)
911
912 assert isinstance(album, Album)
913 assert [(image.path, image.remotely_accessible) for image in album.metadata.images or []] == [
914 ("album/l.album1", False)
915 ]
916
917
918async def test_get_artwork_url_returns_fresh_signed_url() -> None:
919 """The artwork token resolves to the current signed URL from the api."""
920 provider = _create_provider_mock()
921 provider.api_client.get_data = AsyncMock(
922 return_value={"data": [{"id": "l.album1", "attributes": {"artwork": BLOBSTORE_ARTWORK}}]}
923 )
924
925 manager = AppleMusicMediaManager(provider)
926 url = await manager.get_artwork_url("album", "l.album1")
927
928 assert url == BLOBSTORE_ARTWORK["url"]
929 provider.api_client.get_data.assert_called_once_with(
930 "me/library/albums/l.album1", include="catalog"
931 )
932
933
934async def test_get_artwork_url_falls_back_to_catalog_attributes() -> None:
935 """A library item without own artwork resolves via its catalog counterpart."""
936 provider = _create_provider_mock()
937 provider.api_client.get_data = AsyncMock(
938 return_value={
939 "data": [
940 {
941 "id": "l.album1",
942 "attributes": {"name": "Uploaded Album"},
943 "relationships": {
944 "catalog": {
945 "data": [
946 {"id": "123456789", "attributes": {"artwork": CATALOG_ARTWORK}}
947 ]
948 }
949 },
950 }
951 ]
952 }
953 )
954
955 manager = AppleMusicMediaManager(provider)
956 url = await manager.get_artwork_url("album", "l.album1")
957
958 assert url == "https://is1-ssl.mzstatic.com/image/thumb/Music/1000x1000bb.jpg"
959
960
961async def test_get_artwork_url_routes_library_tracks_to_library_endpoint() -> None:
962 """Library song tokens (uploaded music) resolve via me/library, not the catalog."""
963 provider = _create_provider_mock()
964 provider.api_client.get_data = AsyncMock(
965 return_value={"data": [{"id": "i.track1", "attributes": {"artwork": BLOBSTORE_ARTWORK}}]}
966 )
967
968 manager = AppleMusicMediaManager(provider)
969 url = await manager.get_artwork_url("track", "i.track1")
970
971 assert url == BLOBSTORE_ARTWORK["url"]
972 provider.api_client.get_data.assert_called_once_with(
973 "me/library/songs/i.track1", include="catalog"
974 )
975
976
977async def test_get_artwork_url_routes_catalog_tracks_to_catalog_endpoint() -> None:
978 """Catalog song tokens resolve via the catalog endpoint."""
979 provider = _create_provider_mock()
980 provider.api_client.get_data = AsyncMock(
981 return_value={"data": [{"id": "1440783625", "attributes": {"artwork": CATALOG_ARTWORK}}]}
982 )
983
984 manager = AppleMusicMediaManager(provider)
985 await manager.get_artwork_url("track", "1440783625")
986
987 provider.api_client.get_data.assert_called_once_with(
988 "catalog/us/songs/1440783625", include="catalog"
989 )
990
991
992async def test_get_artwork_url_unknown_media_type() -> None:
993 """An unknown token media type resolves to nothing instead of raising."""
994 provider = _create_provider_mock()
995 manager = AppleMusicMediaManager(provider)
996
997 assert await manager.get_artwork_url("bogus", "1") is None
998