Skip to content

Commit 8361ceb

Browse files
Apple music improvements (#2607)
1 parent 64583a4 commit 8361ceb

File tree

1 file changed

+183
-23
lines changed

1 file changed

+183
-23
lines changed

music_assistant/providers/apple_music/__init__.py

Lines changed: 183 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,13 @@
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

95102
MUSIC_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

Comments
 (0)