/
/
/
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
307@pytest.mark.asyncio
308async def test_media_manager_get_playlist_applies_can_edit_hint() -> None:
309 """Catalog playlist fetch should honor library editability hint when provided."""
310 provider = _create_provider_mock()
311 provider.api_client = MagicMock()
312 provider.api_client.get_data = AsyncMock(
313 return_value={
314 "data": [
315 {
316 "id": "pl.catalog1",
317 "attributes": {
318 "name": "Catalog Playlist",
319 "playParams": {"globalId": "pl.catalog1"},
320 },
321 }
322 ]
323 }
324 )
325
326 manager = AppleMusicMediaManager(provider)
327
328 playlist = await manager.get_playlist("pl.catalog1", can_edit_hint=True)
329
330 assert playlist.is_editable is True
331
332
333@pytest.mark.asyncio
334async def test_library_playlists_preserve_can_edit_for_catalog_copy() -> None:
335 """Library playlist sync should preserve canEdit when loading via catalog copy."""
336 provider = _create_provider_mock()
337 provider.api_client = MagicMock()
338 provider.api_client.get_all_items = AsyncMock(
339 return_value=[
340 {
341 "id": "p.library1",
342 "attributes": {
343 "hasCatalog": True,
344 "canEdit": True,
345 "playParams": {"globalId": "pl.catalog1"},
346 },
347 }
348 ]
349 )
350 provider.api_client.get_ratings = AsyncMock(return_value={})
351 provider.media_manager = MagicMock()
352 provider.media_manager.get_playlist = AsyncMock(return_value=MagicMock())
353
354 manager = AppleMusicLibraryManager(provider)
355
356 _ = [playlist async for playlist in manager.get_library_playlists()]
357
358 provider.media_manager.get_playlist.assert_called_once_with(
359 "pl.catalog1",
360 ANY,
361 can_edit_hint=True,
362 library_id_override="p.library1",
363 )
364
365
366@pytest.mark.asyncio
367async def test_library_tracks_request_includes_album_relations() -> None:
368 """Library track sync should request catalog/album/artist relations from Apple API."""
369 provider = _create_provider_mock()
370 provider.api_client = MagicMock()
371 provider.api_client.iter_all_items = _stream([])
372 provider.api_client.get_ratings = AsyncMock(return_value={})
373
374 manager = AppleMusicLibraryManager(provider)
375 _ = [track async for track in manager.get_library_tracks()]
376
377 provider.api_client.iter_all_items.assert_called_once_with(
378 "me/library/songs", include="catalog,albums,artists", page_size=_TRACK_PAGE_SIZE
379 )
380
381
382@pytest.mark.asyncio
383async def test_library_tracks_falls_back_to_library_item_when_catalog_missing() -> None:
384 """Library sync should still parse track if catalog endpoint omits a catalog ID."""
385 provider = _create_provider_mock()
386 provider.api_client = MagicMock()
387 provider.api_client.iter_all_items = _stream(
388 [
389 {
390 "id": "i.librarytrack2",
391 "type": "library-songs",
392 "attributes": {
393 "name": "Missing Catalog Track",
394 "artistName": "Artist 2",
395 "albumName": "Album 2",
396 "playParams": {"catalogId": "999999"},
397 "durationInMillis": 180000,
398 },
399 "relationships": {
400 "catalog": {
401 "data": [
402 {
403 "id": "999999",
404 "attributes": {
405 "name": "Missing Catalog Track",
406 "artistName": "Artist 2",
407 "durationInMillis": 180000,
408 "playParams": {"id": "999999"},
409 },
410 }
411 ]
412 }
413 },
414 }
415 ]
416 )
417 provider.api_client.get_data = AsyncMock(return_value={"data": []})
418 provider.api_client.get_ratings = AsyncMock(return_value={"999999": True})
419
420 manager = AppleMusicLibraryManager(provider)
421 tracks = [track async for track in manager.get_library_tracks()]
422
423 assert len(tracks) == 1
424 assert tracks[0].name == "Missing Catalog Track"
425 assert isinstance(tracks[0].album, ItemMapping)
426 assert tracks[0].album.name == "Album 2"
427
428
429@pytest.mark.asyncio
430async def test_library_tracks_replaces_weak_catalog_album_mapping() -> None:
431 """A weak catalog albumName mapping should be replaced by library album relation."""
432 provider = _create_provider_mock()
433 provider.api_client = MagicMock()
434 provider.api_client.iter_all_items = _stream(
435 [
436 {
437 "id": "i.librarytrack5",
438 "type": "library-songs",
439 "attributes": {
440 "name": "Weak Catalog Album Mapping",
441 "artistName": "Artist 5",
442 "playParams": {"catalogId": "555555"},
443 "durationInMillis": 180000,
444 },
445 "relationships": {
446 "catalog": {
447 "data": [
448 {
449 "id": "555555",
450 "attributes": {
451 "name": "Weak Catalog Album Mapping",
452 "artistName": "Artist 5",
453 "albumName": "Name Only From Catalog",
454 "durationInMillis": 180000,
455 "playParams": {"id": "555555"},
456 },
457 }
458 ]
459 },
460 "albums": {
461 "data": [
462 {
463 "id": "l.album5",
464 "type": "library-albums",
465 "attributes": {
466 "name": "Resolved Library Album",
467 "artistName": "Artist 5",
468 "playParams": {"id": "l.album5"},
469 },
470 }
471 ]
472 },
473 },
474 }
475 ]
476 )
477 provider.api_client.get_data = AsyncMock(
478 return_value={
479 "data": [
480 {
481 "id": "555555",
482 "type": "songs",
483 "attributes": {
484 "name": "Weak Catalog Album Mapping",
485 "artistName": "Artist 5",
486 "albumName": "Name Only From Catalog",
487 "durationInMillis": 180000,
488 "playParams": {"id": "555555"},
489 },
490 "relationships": {},
491 }
492 ]
493 }
494 )
495 provider.api_client.get_ratings = AsyncMock(return_value={"555555": True})
496
497 manager = AppleMusicLibraryManager(provider)
498 tracks = [track async for track in manager.get_library_tracks()]
499
500 assert len(tracks) == 1
501 assert tracks[0].album is not None
502 assert tracks[0].album.item_id == "l.album5"
503 assert tracks[0].album.name == "Resolved Library Album"
504
505
506@pytest.mark.asyncio
507async def test_library_tracks_fetches_detail_when_list_item_has_no_album() -> None:
508 """Library-only tracks should be reparsed from detail endpoint when album is missing."""
509 provider = _create_provider_mock()
510 provider.api_client = MagicMock()
511 provider.api_client.iter_all_items = _stream(
512 [
513 {
514 "id": "i.librarytrack3",
515 "type": "library-songs",
516 "attributes": {
517 "name": "No Album In List",
518 "artistName": "Artist 3",
519 "playParams": {"id": "i.librarytrack3", "isLibrary": True},
520 "durationInMillis": 180000,
521 },
522 "relationships": {},
523 }
524 ]
525 )
526 provider.api_client.get_data = AsyncMock(
527 return_value={
528 "data": [
529 {
530 "id": "i.librarytrack3",
531 "type": "library-songs",
532 "attributes": {
533 "name": "No Album In List",
534 "artistName": "Artist 3",
535 "albumName": "Album From Detail",
536 "playParams": {"id": "i.librarytrack3", "isLibrary": True},
537 "durationInMillis": 180000,
538 },
539 "relationships": {},
540 }
541 ]
542 }
543 )
544 provider.api_client.get_ratings = AsyncMock(return_value={"i.librarytrack3": True})
545
546 manager = AppleMusicLibraryManager(provider)
547 tracks = [track async for track in manager.get_library_tracks()]
548
549 assert len(tracks) == 1
550 assert isinstance(tracks[0].album, ItemMapping)
551 assert tracks[0].album.name == "Album From Detail"
552 provider.api_client.get_data.assert_called_once_with(
553 "me/library/songs", ids="i.librarytrack3", include="catalog,albums,artists"
554 )
555
556
557@pytest.mark.asyncio
558async def test_library_tracks_fetches_detail_for_album_name_only_mapping() -> None:
559 """List items with only albumName fallback should be upgraded to a resolvable album id."""
560 provider = _create_provider_mock()
561 provider.api_client = MagicMock()
562 provider.api_client.iter_all_items = _stream(
563 [
564 {
565 "id": "i.librarytrack4",
566 "type": "library-songs",
567 "attributes": {
568 "name": "Album Name Only",
569 "artistName": "Artist 4",
570 "albumName": "Album Only From List",
571 "playParams": {"id": "i.librarytrack4", "isLibrary": True},
572 "durationInMillis": 180000,
573 },
574 "relationships": {},
575 }
576 ]
577 )
578 provider.api_client.get_data = AsyncMock(
579 return_value={
580 "data": [
581 {
582 "id": "i.librarytrack4",
583 "type": "library-songs",
584 "attributes": {
585 "name": "Album Name Only",
586 "artistName": "Artist 4",
587 "playParams": {"id": "i.librarytrack4", "isLibrary": True},
588 "durationInMillis": 180000,
589 },
590 "relationships": {
591 "albums": {
592 "data": [
593 {
594 "id": "l.album4",
595 "type": "library-albums",
596 "attributes": {
597 "name": "Resolved Album",
598 "artistName": "Artist 4",
599 "playParams": {"id": "l.album4"},
600 },
601 }
602 ]
603 }
604 },
605 }
606 ]
607 }
608 )
609 provider.api_client.get_ratings = AsyncMock(return_value={"i.librarytrack4": True})
610
611 manager = AppleMusicLibraryManager(provider)
612 tracks = [track async for track in manager.get_library_tracks()]
613
614 assert len(tracks) == 1
615 assert tracks[0].album is not None
616 assert tracks[0].album.item_id == "l.album4"
617 assert tracks[0].album.name == "Resolved Album"
618 provider.api_client.get_data.assert_called_once_with(
619 "me/library/songs", ids="i.librarytrack4", include="catalog,albums,artists"
620 )
621
622
623@pytest.mark.asyncio
624async def test_catalog_backed_playlist_uses_library_id_as_item_id() -> None:
625 """
626 Catalog-backed library playlists must use the library ID as item_id.
627
628 When a playlist hasCatalog=True, Apple only accepts write operations
629 (add tracks) against the library endpoint using the library ID (p.XXXXX),
630 not the catalog global ID (pl.u-...).
631 """
632 provider = _create_provider_mock()
633 provider.api_client = MagicMock()
634 provider.api_client.get_all_items = AsyncMock(
635 return_value=[
636 {
637 "id": "p.myLibraryPlaylist",
638 "attributes": {
639 "hasCatalog": True,
640 "canEdit": True,
641 "playParams": {"globalId": "pl.u-abcd1234"},
642 "name": "My Public Playlist",
643 "curatorName": "me",
644 "artwork": None,
645 },
646 }
647 ]
648 )
649 provider.api_client.get_ratings = AsyncMock(return_value={})
650 provider.api_client.get_data = AsyncMock(
651 return_value={
652 "data": [
653 {
654 "id": "pl.u-abcd1234",
655 "attributes": {
656 "name": "My Public Playlist",
657 "curatorName": "me",
658 "playParams": {"globalId": "pl.u-abcd1234"},
659 "canEdit": True,
660 },
661 }
662 ]
663 }
664 )
665
666 manager = AppleMusicLibraryManager(provider)
667 # Exercise parse_playlist through the real media manager.
668 provider.media_manager = AppleMusicMediaManager(provider)
669
670 playlists = [pl async for pl in manager.get_library_playlists()]
671
672 assert len(playlists) == 1
673 playlist = playlists[0]
674 # Must use library ID so add_playlist_tracks targets /me/library/playlists/{id}/tracks.
675 assert playlist.item_id == "p.myLibraryPlaylist"
676 assert all(pm.item_id == "p.myLibraryPlaylist" for pm in playlist.provider_mappings)
677
678
679@pytest.mark.asyncio
680async def test_get_playlist_by_library_id_keeps_library_item_id() -> None:
681 """Direct library playlist fetch must keep the library playlist ID."""
682 provider = _create_provider_mock()
683 provider.api_client = MagicMock()
684 provider.api_client.get_data = AsyncMock(
685 return_value={
686 "data": [
687 {
688 "id": "p.myLibraryPlaylist",
689 "attributes": {
690 "name": "My Public Playlist",
691 "curatorName": "me",
692 "playParams": {"globalId": "pl.u-abcd1234"},
693 "canEdit": True,
694 },
695 }
696 ]
697 }
698 )
699
700 manager = AppleMusicMediaManager(provider)
701 playlist = await manager.get_playlist("p.myLibraryPlaylist")
702
703 provider.api_client.get_data.assert_called_once_with("me/library/playlists/p.myLibraryPlaylist")
704 assert playlist.item_id == "p.myLibraryPlaylist"
705 assert all(pm.item_id == "p.myLibraryPlaylist" for pm in playlist.provider_mappings)
706
707
708@pytest.mark.asyncio
709async def test_get_album_uses_library_endpoint_for_library_id() -> None:
710 """get_album should use me/library/albums/{id} for library IDs (l. prefix)."""
711 provider = _create_provider_mock()
712 provider.api_client = MagicMock()
713 provider.api_client.get_data = AsyncMock(
714 return_value={
715 "data": [
716 {
717 "id": "l.PnamISl",
718 "type": "library-albums",
719 "attributes": {
720 "name": "Uploaded Album",
721 "artistName": "Uploaded Artist",
722 "playParams": {"id": "l.PnamISl", "isLibrary": True},
723 },
724 "relationships": {},
725 }
726 ]
727 }
728 )
729 provider.api_client.get_ratings = AsyncMock(return_value={})
730
731 manager = AppleMusicMediaManager(provider)
732 album = await manager.get_album("l.PnamISl")
733
734 provider.api_client.get_data.assert_called_once_with(
735 "me/library/albums/l.PnamISl", include="catalog,artists"
736 )
737 assert isinstance(album, Album)
738 assert album.name == "Uploaded Album"
739
740
741@pytest.mark.asyncio
742async def test_get_album_tracks_uses_library_endpoint_for_library_id() -> None:
743 """get_album_tracks should use me/library/albums/{id}/tracks for library IDs."""
744 provider = _create_provider_mock()
745 provider.api_client = MagicMock()
746 provider.api_client.get_data = AsyncMock(
747 side_effect=[
748 # First call: get_album_tracks endpoint
749 {
750 "data": [
751 {
752 "id": "i.track1",
753 "type": "library-songs",
754 "attributes": {
755 "name": "Track 1",
756 "artistName": "Uploaded Artist",
757 "durationInMillis": 180000,
758 "playParams": {"id": "i.track1", "isLibrary": True},
759 },
760 "relationships": {},
761 }
762 ]
763 },
764 # Second call: get_album (called internally)
765 {
766 "data": [
767 {
768 "id": "l.PnamISl",
769 "type": "library-albums",
770 "attributes": {
771 "name": "Uploaded Album",
772 "artistName": "Uploaded Artist",
773 "playParams": {"id": "l.PnamISl", "isLibrary": True},
774 },
775 "relationships": {},
776 }
777 ]
778 },
779 ]
780 )
781 provider.api_client.get_ratings = AsyncMock(return_value={})
782
783 manager = AppleMusicMediaManager(provider)
784 tracks = await manager.get_album_tracks("l.PnamISl")
785
786 first_call_args = provider.api_client.get_data.call_args_list[0]
787 assert first_call_args[0][0] == "me/library/albums/l.PnamISl/tracks"
788 assert len(tracks) == 1
789 assert tracks[0].name == "Track 1"
790
791
792@pytest.mark.asyncio
793async def test_get_album_uses_catalog_endpoint_for_catalog_id() -> None:
794 """get_album should continue using the catalog endpoint for numeric catalog IDs."""
795 provider = _create_provider_mock()
796 provider.api_client = MagicMock()
797 provider.api_client.get_data = AsyncMock(
798 return_value={
799 "data": [
800 {
801 "id": "123456789",
802 "type": "albums",
803 "attributes": {
804 "name": "Catalog Album",
805 "artistName": "Catalog Artist",
806 "playParams": {"id": "123456789"},
807 "url": "https://music.apple.com/album/123456789",
808 },
809 "relationships": {},
810 }
811 ]
812 }
813 )
814 provider.api_client.get_ratings = AsyncMock(return_value={})
815
816 manager = AppleMusicMediaManager(provider)
817 await manager.get_album("123456789")
818
819 provider.api_client.get_data.assert_called_once_with(
820 "catalog/us/albums/123456789", include="artists"
821 )
822
823
824def test_parse_artwork_image_stores_stable_token_for_expiring_urls() -> None:
825 """Blobstore artwork (presigned, expiring) is stored as a resolvable token."""
826 provider = _create_provider_mock()
827
828 image = parse_artwork_image(
829 provider, MediaType.ALBUM, "l.album1", {"artwork": BLOBSTORE_ARTWORK}
830 )
831
832 assert image is not None
833 assert image.path == "album/l.album1"
834 assert image.remotely_accessible is False
835 assert image.provider == "apple_music_test"
836
837
838def test_parse_artwork_image_keeps_permanent_cdn_urls() -> None:
839 """Mzstatic artwork is permanent and stored as a directly accessible URL."""
840 provider = _create_provider_mock()
841
842 image = parse_artwork_image(
843 provider, MediaType.ALBUM, "1234567890", {"artwork": CATALOG_ARTWORK}
844 )
845
846 assert image is not None
847 assert image.path == "https://is1-ssl.mzstatic.com/image/thumb/Music/1000x1000bb.jpg"
848 assert image.remotely_accessible is True
849
850
851def test_parse_artwork_image_without_artwork() -> None:
852 """Items without (usable) artwork produce no image."""
853 provider = _create_provider_mock()
854
855 assert parse_artwork_image(provider, MediaType.ALBUM, "x", {}) is None
856 assert parse_artwork_image(provider, MediaType.ALBUM, "x", {"artwork": {"width": 1}}) is None
857
858
859def test_parse_album_stores_artwork_token_for_library_album() -> None:
860 """A library album with blobstore artwork ends up with a token image."""
861 provider = _create_provider_mock()
862 album_obj = {
863 "id": "l.album1",
864 "type": "library-albums",
865 "attributes": {
866 "name": "Uploaded Album",
867 "artistName": "Uploaded Artist",
868 "playParams": {"id": "l.album1"},
869 "artwork": BLOBSTORE_ARTWORK,
870 },
871 "relationships": {},
872 }
873
874 album = parse_album(provider, album_obj)
875
876 assert isinstance(album, Album)
877 assert [(image.path, image.remotely_accessible) for image in album.metadata.images or []] == [
878 ("album/l.album1", False)
879 ]
880
881
882async def test_get_artwork_url_returns_fresh_signed_url() -> None:
883 """The artwork token resolves to the current signed URL from the api."""
884 provider = _create_provider_mock()
885 provider.api_client.get_data = AsyncMock(
886 return_value={"data": [{"id": "l.album1", "attributes": {"artwork": BLOBSTORE_ARTWORK}}]}
887 )
888
889 manager = AppleMusicMediaManager(provider)
890 url = await manager.get_artwork_url("album", "l.album1")
891
892 assert url == BLOBSTORE_ARTWORK["url"]
893 provider.api_client.get_data.assert_called_once_with(
894 "me/library/albums/l.album1", include="catalog"
895 )
896
897
898async def test_get_artwork_url_falls_back_to_catalog_attributes() -> None:
899 """A library item without own artwork resolves via its catalog counterpart."""
900 provider = _create_provider_mock()
901 provider.api_client.get_data = AsyncMock(
902 return_value={
903 "data": [
904 {
905 "id": "l.album1",
906 "attributes": {"name": "Uploaded Album"},
907 "relationships": {
908 "catalog": {
909 "data": [
910 {"id": "123456789", "attributes": {"artwork": CATALOG_ARTWORK}}
911 ]
912 }
913 },
914 }
915 ]
916 }
917 )
918
919 manager = AppleMusicMediaManager(provider)
920 url = await manager.get_artwork_url("album", "l.album1")
921
922 assert url == "https://is1-ssl.mzstatic.com/image/thumb/Music/1000x1000bb.jpg"
923
924
925async def test_get_artwork_url_routes_library_tracks_to_library_endpoint() -> None:
926 """Library song tokens (uploaded music) resolve via me/library, not the catalog."""
927 provider = _create_provider_mock()
928 provider.api_client.get_data = AsyncMock(
929 return_value={"data": [{"id": "i.track1", "attributes": {"artwork": BLOBSTORE_ARTWORK}}]}
930 )
931
932 manager = AppleMusicMediaManager(provider)
933 url = await manager.get_artwork_url("track", "i.track1")
934
935 assert url == BLOBSTORE_ARTWORK["url"]
936 provider.api_client.get_data.assert_called_once_with(
937 "me/library/songs/i.track1", include="catalog"
938 )
939
940
941async def test_get_artwork_url_routes_catalog_tracks_to_catalog_endpoint() -> None:
942 """Catalog song tokens resolve via the catalog endpoint."""
943 provider = _create_provider_mock()
944 provider.api_client.get_data = AsyncMock(
945 return_value={"data": [{"id": "1440783625", "attributes": {"artwork": CATALOG_ARTWORK}}]}
946 )
947
948 manager = AppleMusicMediaManager(provider)
949 await manager.get_artwork_url("track", "1440783625")
950
951 provider.api_client.get_data.assert_called_once_with(
952 "catalog/us/songs/1440783625", include="catalog"
953 )
954
955
956async def test_get_artwork_url_unknown_media_type() -> None:
957 """An unknown token media type resolves to nothing instead of raising."""
958 provider = _create_provider_mock()
959 manager = AppleMusicMediaManager(provider)
960
961 assert await manager.get_artwork_url("bogus", "1") is None
962