9090 ProviderFeature .ARTIST_ALBUMS ,
9191 ProviderFeature .ARTIST_TOPTRACKS ,
9292 ProviderFeature .SIMILAR_TRACKS ,
93+ ProviderFeature .LIBRARY_ALBUMS_EDIT ,
94+ ProviderFeature .LIBRARY_ARTISTS_EDIT ,
95+ ProviderFeature .LIBRARY_PLAYLISTS_EDIT ,
96+ ProviderFeature .LIBRARY_TRACKS_EDIT ,
97+ ProviderFeature .FAVORITE_ALBUMS_EDIT ,
98+ ProviderFeature .FAVORITE_TRACKS_EDIT ,
99+ ProviderFeature .FAVORITE_PLAYLISTS_EDIT ,
93100}
94101
95102MUSIC_APP_TOKEN = app_var (8 )
@@ -354,16 +361,14 @@ async def get_library_tracks(self) -> AsyncGenerator[Track, None]:
354361 """Retrieve library tracks from the provider."""
355362 endpoint = "me/library/songs"
356363 song_catalog_ids = []
364+ library_only_tracks = []
357365 for item in await self ._get_all_items (endpoint ):
358366 catalog_id = item .get ("attributes" , {}).get ("playParams" , {}).get ("catalogId" )
359367 if not catalog_id :
360- self .logger .debug (
361- "Skipping track. No catalog version found for %s - %s" ,
362- item ["attributes" ].get ("artistName" , "" ),
363- item ["attributes" ].get ("name" , "" ),
364- )
365- continue
366- song_catalog_ids .append (catalog_id )
368+ # Track is library-only (private/uploaded), use library ID instead
369+ library_only_tracks .append (item )
370+ else :
371+ song_catalog_ids .append (catalog_id )
367372 # Obtain catalog info per 200 songs, the documented limit of 300 results in a 504 timeout
368373 max_limit = 200
369374 for i in range (0 , len (song_catalog_ids ), max_limit ):
@@ -372,8 +377,15 @@ async def get_library_tracks(self) -> AsyncGenerator[Track, None]:
372377 response = await self ._get_data (
373378 catalog_endpoint , ids = "," .join (catalog_ids ), include = "artists,albums"
374379 )
380+ # Fetch ratings for this batch
381+ rating_response = await self ._get_ratings (catalog_ids , MediaType .TRACK )
375382 for item in response ["data" ]:
376- yield self ._parse_track (item )
383+ is_favourite = rating_response .get (item ["id" ])
384+ track = self ._parse_track (item , is_favourite )
385+ yield track
386+ # Yield library-only tracks using their library metadata
387+ for item in library_only_tracks :
388+ yield self ._parse_track (item )
377389
378390 async def get_library_playlists (self ) -> AsyncGenerator [Playlist , None ]:
379391 """Retrieve playlists from the provider."""
@@ -404,7 +416,9 @@ async def get_track(self, prov_track_id) -> Track:
404416 """Get full track details by id."""
405417 endpoint = f"catalog/{ self ._storefront } /songs/{ prov_track_id } "
406418 response = await self ._get_data (endpoint , include = "artists,albums" )
407- return self ._parse_track (response ["data" ][0 ])
419+ rating_response = await self ._get_ratings ([prov_track_id ], MediaType .TRACK )
420+ is_favourite = rating_response .get (prov_track_id )
421+ return self ._parse_track (response ["data" ][0 ], is_favourite )
408422
409423 @use_cache ()
410424 async def get_playlist (self , prov_playlist_id ) -> Playlist :
@@ -424,11 +438,13 @@ async def get_album_tracks(self, prov_album_id) -> list[Track]:
424438 response = await self ._get_data (endpoint , include = "artists" )
425439 # Including albums results in a 504 error, so we need to fetch the album separately
426440 album = await self .get_album (prov_album_id )
441+ track_ids = [track_obj ["id" ] for track_obj in response ["data" ] if "id" in track_obj ]
442+ rating_response = await self ._get_ratings (track_ids , MediaType .TRACK )
427443 tracks = []
428444 for track_obj in response ["data" ]:
429445 if "id" not in track_obj :
430446 continue
431- track = self ._parse_track (track_obj )
447+ track = self ._parse_track (track_obj , rating_response . get ( track_obj [ "id" ]) )
432448 track .album = album
433449 tracks .append (track )
434450 return tracks
@@ -479,23 +495,43 @@ async def get_artist_toptracks(self, prov_artist_id) -> list[Track]:
479495 return []
480496 return [self ._parse_track (track ) for track in response ["data" ] if track ["id" ]]
481497
482- async def library_add (self , item : MediaItemType ):
498+ async def library_add (self , item : MediaItemType ) -> None :
483499 """Add item to library."""
484- raise NotImplementedError ("Not implemented!" )
500+ item_type = self ._translate_media_type_to_apple_type (item .media_type )
501+ kwargs = {
502+ f"ids[{ item_type } ]" : item .item_id ,
503+ }
504+ await self ._post_data ("me/library/" , ** kwargs )
485505
486- async def library_remove (self , prov_item_id , media_type : MediaType ):
506+ async def library_remove (self , prov_item_id , media_type : MediaType ) -> None :
487507 """Remove item from library."""
488- raise NotImplementedError ("Not implemented!" )
508+ self .logger .warning (
509+ "Deleting items from your library is not yet supported by the Apple Music API. "
510+ f"Skipping deletion of { media_type } - { prov_item_id } ."
511+ )
489512
490513 async def add_playlist_tracks (self , prov_playlist_id : str , prov_track_ids : list [str ]):
491514 """Add track(s) to playlist."""
492- raise NotImplementedError ("Not implemented!" )
515+ endpoint = f"me/library/playlists/{ prov_playlist_id } /tracks"
516+ data = {
517+ "data" : [
518+ {
519+ "id" : track_id ,
520+ "type" : "library-songs" if self .is_library_id (track_id ) else "songs" ,
521+ }
522+ for track_id in prov_track_ids
523+ ]
524+ }
525+ await self ._post_data (endpoint , data = data )
493526
494527 async def remove_playlist_tracks (
495528 self , prov_playlist_id : str , positions_to_remove : tuple [int , ...]
496529 ) -> None :
497530 """Remove track(s) from playlist."""
498- raise NotImplementedError ("Not implemented!" )
531+ self .logger .warning (
532+ "Removing tracks from playlists is not supported by the Apple Music "
533+ "API. Make sure to delete them using the Apple Music app."
534+ )
499535
500536 @use_cache (3600 * 24 ) # cache for 24 hours
501537 async def get_similar_tracks (self , prov_track_id , limit = 25 ) -> list [Track ]:
@@ -520,6 +556,24 @@ async def get_similar_tracks(self, prov_track_id, limit=25) -> list[Track]:
520556 async def get_stream_details (self , item_id : str , media_type : MediaType ) -> StreamDetails :
521557 """Return the content details for the given track when it will be streamed."""
522558 stream_metadata = await self ._fetch_song_stream_metadata (item_id )
559+ if self .is_library_id (item_id ):
560+ # Library items are not encrypted and do not need decryption keys
561+ try :
562+ stream_url = stream_metadata ["assets" ][0 ]["URL" ]
563+ except (KeyError , IndexError , TypeError ) as exc :
564+ raise MediaNotFoundError (
565+ f"Failed to extract stream URL for library track { item_id } : { exc } "
566+ ) from exc
567+ return StreamDetails (
568+ item_id = item_id ,
569+ provider = self .lookup_key ,
570+ path = stream_url ,
571+ stream_type = StreamType .HTTP ,
572+ audio_format = AudioFormat (content_type = ContentType .UNKNOWN ),
573+ can_seek = True ,
574+ allow_seek = True ,
575+ )
576+ # Continue to obtain decryption keys for catalog items
523577 license_url = stream_metadata ["hls-key-server-url" ]
524578 stream_url , uri = await self ._parse_stream_url_and_uri (stream_metadata ["assets" ])
525579 if not stream_url or not uri :
@@ -536,6 +590,21 @@ async def get_stream_details(self, item_id: str, media_type: MediaType) -> Strea
536590 allow_seek = True ,
537591 )
538592
593+ async def set_favorite (self , prov_item_id : str , media_type : MediaType , favorite : bool ) -> None :
594+ """Set the favorite status of an item."""
595+ data = {
596+ "type" : "ratings" ,
597+ "attributes" : {
598+ "value" : 1 if favorite else - 1 ,
599+ },
600+ }
601+ item_type = self ._translate_media_type_to_apple_type (media_type )
602+ if self ._is_catalog_id (prov_item_id ):
603+ endpoint = f"me/ratings/{ item_type } /{ prov_item_id } "
604+ else :
605+ endpoint = f"me/ratings/library-{ item_type } /{ prov_item_id } "
606+ await self ._put_data (endpoint , data = data )
607+
539608 def _parse_artist (self , artist_obj : dict [str , Any ]) -> Artist :
540609 """Parse artist object to generic layout."""
541610 relationships = artist_obj .get ("relationships" , {})
@@ -684,13 +753,19 @@ def _parse_album(self, album_obj: dict) -> Album | ItemMapping | None:
684753 def _parse_track (
685754 self ,
686755 track_obj : dict [str , Any ],
756+ is_favourite : bool | None = None ,
687757 ) -> Track :
688758 """Parse track object to generic layout."""
689759 relationships = track_obj .get ("relationships" , {})
690- if track_obj .get ("type" ) == "library-songs" and relationships ["catalog" ]["data" ] != []:
760+ if (
761+ track_obj .get ("type" ) == "library-songs"
762+ and relationships .get ("catalog" , {}).get ("data" , []) != []
763+ ):
764+ # Library track with catalog version available
691765 track_id = relationships .get ("catalog" , {})["data" ][0 ]["id" ]
692766 attributes = relationships .get ("catalog" , {})["data" ][0 ]["attributes" ]
693767 elif "attributes" in track_obj :
768+ # Catalog track or library-only track
694769 track_id = track_obj ["id" ]
695770 attributes = track_obj ["attributes" ]
696771 else :
@@ -749,6 +824,7 @@ def _parse_track(
749824 track .metadata .performers = set (composers .split (", " ))
750825 if isrc := attributes .get ("isrc" ):
751826 track .external_ids .add ((ExternalID .ISRC , isrc ))
827+ track .favorite = is_favourite or False
752828 return track
753829
754830 def _parse_playlist (self , playlist_obj : dict [str , Any ]) -> Playlist :
@@ -839,13 +915,49 @@ async def _get_data(self, endpoint, **kwargs) -> dict[str, Any]:
839915 response .raise_for_status ()
840916 return await response .json (loads = json_loads )
841917
842- async def _delete_data (self , endpoint , data = None , ** kwargs ) -> str :
918+ @throttle_with_retries
919+ async def _delete_data (self , endpoint , data = None , ** kwargs ) -> None :
843920 """Delete data from api."""
844- raise NotImplementedError ("Not implemented!" )
921+ url = f"https://api.music.apple.com/v1/{ endpoint } "
922+ headers = {"Authorization" : f"Bearer { self ._music_app_token } " }
923+ headers ["Music-User-Token" ] = self ._music_user_token
924+ async with (
925+ self .mass .http_session .delete (
926+ url , headers = headers , params = kwargs , json = data , ssl = True , timeout = 120
927+ ) as response ,
928+ ):
929+ # Convert HTTP errors to exceptions
930+ if response .status == 404 :
931+ raise MediaNotFoundError (f"{ endpoint } not found" )
932+ if response .status == 429 :
933+ # Debug this for now to see if the response headers give us info about the
934+ # backoff time. There is no documentation on this.
935+ self .logger .debug ("Apple Music Rate Limiter. Headers: %s" , response .headers )
936+ raise ResourceTemporarilyUnavailable ("Apple Music Rate Limiter" )
937+ response .raise_for_status ()
845938
846939 async def _put_data (self , endpoint , data = None , ** kwargs ) -> str :
847940 """Put data on api."""
848- raise NotImplementedError ("Not implemented!" )
941+ url = f"https://api.music.apple.com/v1/{ endpoint } "
942+ headers = {"Authorization" : f"Bearer { self ._music_app_token } " }
943+ headers ["Music-User-Token" ] = self ._music_user_token
944+ async with (
945+ self .mass .http_session .put (
946+ url , headers = headers , params = kwargs , json = data , ssl = True , timeout = 120
947+ ) as response ,
948+ ):
949+ # Convert HTTP errors to exceptions
950+ if response .status == 404 :
951+ raise MediaNotFoundError (f"{ endpoint } not found" )
952+ if response .status == 429 :
953+ # Debug this for now to see if the response headers give us info about the
954+ # backoff time. There is no documentation on this.
955+ self .logger .debug ("Apple Music Rate Limiter. Headers: %s" , response .headers )
956+ raise ResourceTemporarilyUnavailable ("Apple Music Rate Limiter" )
957+ response .raise_for_status ()
958+ if response .content_length :
959+ return await response .json (loads = json_loads )
960+ return {}
849961
850962 @throttle_with_retries
851963 async def _post_data (self , endpoint , data = None , ** kwargs ) -> str :
@@ -876,16 +988,64 @@ async def _get_user_storefront(self) -> str:
876988 result = await self ._get_data ("me/storefront" , l = language )
877989 return result ["data" ][0 ]["id" ]
878990
991+ async def _get_ratings (self , item_ids : list [str ], media_type : MediaType ) -> dict [str , bool ]:
992+ """Get ratings (aka favorites) for a list of item ids."""
993+ if media_type == MediaType .ARTIST :
994+ raise NotImplementedError (
995+ "Ratings are not available for artist in the Apple Music API."
996+ )
997+ endpoint = self ._translate_media_type_to_apple_type (media_type )
998+ # Apple Music limits to 200 ids per request
999+ max_ids_per_request = 200
1000+ results = {}
1001+ for i in range (0 , len (item_ids ), max_ids_per_request ):
1002+ batch_ids = item_ids [i : i + max_ids_per_request ]
1003+ response = await self ._get_data (
1004+ f"me/ratings/{ endpoint } " ,
1005+ ids = "," .join (batch_ids ),
1006+ )
1007+ results .update (
1008+ {
1009+ item ["id" ]: bool (item ["attributes" ].get ("value" , False ) == 1 )
1010+ for item in response .get ("data" , [])
1011+ }
1012+ )
1013+ return results
1014+
1015+ def _translate_media_type_to_apple_type (self , media_type : MediaType ) -> str :
1016+ """Translate MediaType to Apple Music endpoint string."""
1017+ match media_type :
1018+ case MediaType .ARTIST :
1019+ return "artists"
1020+ case MediaType .ALBUM :
1021+ return "albums"
1022+ case MediaType .TRACK :
1023+ return "songs"
1024+ case MediaType .PLAYLIST :
1025+ return "playlists"
1026+ raise MusicAssistantError (f"Unsupported media type: { media_type } " )
1027+
1028+ def is_library_id (self , library_id ) -> bool :
1029+ """Check a library ID matches known format."""
1030+ if not isinstance (library_id , str ):
1031+ return False
1032+ valid = re .findall (r"^(?:[a|i|l|p]{1}\.|pl\.u\-)[a-zA-Z0-9]+$" , library_id )
1033+ return bool (valid )
1034+
8791035 def _is_catalog_id (self , catalog_id : str ) -> bool :
8801036 """Check if input is a catalog id, or a library id."""
8811037 return catalog_id .isnumeric () or catalog_id .startswith ("pl." )
8821038
8831039 async def _fetch_song_stream_metadata (self , song_id : str ) -> str :
8841040 """Get the stream URL for a song from Apple Music."""
8851041 playback_url = "https://play.music.apple.com/WebObjects/MZPlay.woa/wa/webPlayback"
886- data = {
887- "salableAdamId" : song_id ,
888- }
1042+ data = {}
1043+ self .logger .debug ("_fetch_song_stream_metadata: Check if Library ID: %s" , song_id )
1044+ if self .is_library_id (song_id ):
1045+ data ["universalLibraryId" ] = song_id
1046+ data ["isLibrary" ] = True
1047+ else :
1048+ data ["salableAdamId" ] = song_id
8891049 for retry in (True , False ):
8901050 try :
8911051 async with self .mass .http_session .post (
0 commit comments