Skip to content

Commit 154b911

Browse files
Apple Music: Add remaining favourite parsing + custom music token config (#2609)
1 parent ce2b2f1 commit 154b911

File tree

1 file changed

+104
-21
lines changed

1 file changed

+104
-21
lines changed

music_assistant/providers/apple_music/__init__.py

Lines changed: 104 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,7 @@
107107

108108
CONF_MUSIC_APP_TOKEN = "music_app_token"
109109
CONF_MUSIC_USER_TOKEN = "music_user_token"
110+
CONF_MUSIC_USER_MANUAL_TOKEN = "music_user_manual_token"
110111
CONF_MUSIC_USER_TOKEN_TIMESTAMP = "music_user_token_timestamp"
111112
CACHE_CATEGORY_DECRYPT_KEY = 1
112113

@@ -236,7 +237,7 @@ async def serve_mk_glue(request: web.Request) -> web.Response:
236237
key=CONF_MUSIC_USER_TOKEN,
237238
type=ConfigEntryType.SECURE_STRING,
238239
label="Music User Token",
239-
required=True,
240+
required=False,
240241
action="CONF_ACTION_AUTH",
241242
description="Authenticate with Apple Music to retrieve a valid music user token.",
242243
action_label="Authenticate with Apple Music",
@@ -250,6 +251,19 @@ async def serve_mk_glue(request: web.Request) -> web.Response:
250251
)
251252
else None,
252253
),
254+
ConfigEntry(
255+
key=CONF_MUSIC_USER_MANUAL_TOKEN,
256+
type=ConfigEntryType.SECURE_STRING,
257+
label="Manual Music User Token",
258+
required=False,
259+
category="advanced",
260+
description=(
261+
"Authenticate with a manual Music User Token in case the Authentication flow"
262+
" is unsupported (e.g. when using child accounts)."
263+
),
264+
help_link="https://www.music-assistant.io/music-providers/apple-music/",
265+
value=values.get(CONF_MUSIC_USER_MANUAL_TOKEN),
266+
),
253267
ConfigEntry(
254268
key=CONF_MUSIC_USER_TOKEN_TIMESTAMP,
255269
type=ConfigEntryType.INTEGER,
@@ -278,7 +292,9 @@ class AppleMusicProvider(MusicProvider):
278292

279293
async def handle_async_init(self) -> None:
280294
"""Handle async initialization of the provider."""
281-
self._music_user_token = self.config.get_value(CONF_MUSIC_USER_TOKEN)
295+
self._music_user_token = self.config.get_value(
296+
CONF_MUSIC_USER_MANUAL_TOKEN
297+
) or self.config.get_value(CONF_MUSIC_USER_TOKEN)
282298
self._music_app_token = self.config.get_value(CONF_MUSIC_APP_TOKEN)
283299
self._storefront = await self._get_user_storefront()
284300
# create random session id to use for decryption keys
@@ -349,11 +365,29 @@ async def get_library_artists(self) -> AsyncGenerator[Artist, None]:
349365
async def get_library_albums(self) -> AsyncGenerator[Album, None]:
350366
"""Retrieve library albums from the provider."""
351367
endpoint = "me/library/albums"
352-
for item in await self._get_all_items(
368+
album_items = await self._get_all_items(
353369
endpoint, include="catalog,artists", extend="editorialNotes"
354-
):
370+
)
371+
album_catalog_item_ids = [
372+
item["id"]
373+
for item in album_items
374+
if item and item["id"] and not self.is_library_id(item["id"])
375+
]
376+
album_library_item_ids = [
377+
item["id"]
378+
for item in album_items
379+
if item and item["id"] and self.is_library_id(item["id"])
380+
]
381+
rating_catalog_response = await self._get_ratings(album_catalog_item_ids, MediaType.ALBUM)
382+
rating_library_response = await self._get_ratings(album_library_item_ids, MediaType.ALBUM)
383+
for item in album_items:
355384
if item and item["id"]:
356-
album = self._parse_album(item)
385+
is_favourite = (
386+
rating_catalog_response.get(item["id"])
387+
if not self.is_library_id(item["id"])
388+
else rating_library_response.get(item["id"])
389+
)
390+
album = self._parse_album(item, is_favourite)
357391
if album:
358392
yield album
359393

@@ -384,18 +418,33 @@ async def get_library_tracks(self) -> AsyncGenerator[Track, None]:
384418
track = self._parse_track(item, is_favourite)
385419
yield track
386420
# Yield library-only tracks using their library metadata
421+
library_ids = [item["id"] for item in library_only_tracks if item and item["id"]]
422+
library_rating_response = await self._get_ratings(library_ids, MediaType.TRACK)
387423
for item in library_only_tracks:
388-
yield self._parse_track(item)
424+
is_favourite = library_rating_response.get(item["id"])
425+
yield self._parse_track(item, is_favourite)
389426

390427
async def get_library_playlists(self) -> AsyncGenerator[Playlist, None]:
391428
"""Retrieve playlists from the provider."""
392429
endpoint = "me/library/playlists"
393-
for item in await self._get_all_items(endpoint):
430+
playlist_items = await self._get_all_items(endpoint)
431+
playlist_library_item_ids = [
432+
item["id"]
433+
for item in playlist_items
434+
if item and item["id"] and self.is_library_id(item["id"])
435+
]
436+
rating_library_response = await self._get_ratings(
437+
playlist_library_item_ids, MediaType.PLAYLIST
438+
)
439+
for item in playlist_items:
440+
is_favourite = rating_library_response.get(item["id"])
394441
# Prefer catalog information over library information in case of public playlists
395442
if item["attributes"]["hasCatalog"]:
396-
yield await self.get_playlist(item["attributes"]["playParams"]["globalId"])
443+
yield await self.get_playlist(
444+
item["attributes"]["playParams"]["globalId"], is_favourite
445+
)
397446
elif item and item["id"]:
398-
yield self._parse_playlist(item)
447+
yield self._parse_playlist(item, is_favourite)
399448

400449
@use_cache()
401450
async def get_artist(self, prov_artist_id) -> Artist:
@@ -409,7 +458,9 @@ async def get_album(self, prov_album_id) -> Album:
409458
"""Get full album details by id."""
410459
endpoint = f"catalog/{self._storefront}/albums/{prov_album_id}"
411460
response = await self._get_data(endpoint, include="artists")
412-
return self._parse_album(response["data"][0])
461+
rating_response = await self._get_ratings([prov_album_id], MediaType.ALBUM)
462+
is_favourite = rating_response.get(prov_album_id)
463+
return self._parse_album(response["data"][0], is_favourite)
413464

414465
@use_cache()
415466
async def get_track(self, prov_track_id) -> Track:
@@ -421,15 +472,15 @@ async def get_track(self, prov_track_id) -> Track:
421472
return self._parse_track(response["data"][0], is_favourite)
422473

423474
@use_cache()
424-
async def get_playlist(self, prov_playlist_id) -> Playlist:
475+
async def get_playlist(self, prov_playlist_id, is_favourite: bool = False) -> Playlist:
425476
"""Get full playlist details by id."""
426-
if self._is_catalog_id(prov_playlist_id):
477+
if not self.is_library_id(prov_playlist_id):
427478
endpoint = f"catalog/{self._storefront}/playlists/{prov_playlist_id}"
428479
else:
429480
endpoint = f"me/library/playlists/{prov_playlist_id}"
430481
endpoint = f"catalog/{self._storefront}/playlists/{prov_playlist_id}"
431482
response = await self._get_data(endpoint)
432-
return self._parse_playlist(response["data"][0])
483+
return self._parse_playlist(response["data"][0], is_favourite)
433484

434485
@use_cache()
435486
async def get_album_tracks(self, prov_album_id) -> list[Track]:
@@ -464,9 +515,12 @@ async def get_playlist_tracks(self, prov_playlist_id, page: int = 0) -> list[Tra
464515
)
465516
if not response or "data" not in response:
466517
return result
518+
playlist_track_ids = [track["id"] for track in response["data"] if track and track["id"]]
519+
rating_response = await self._get_ratings(playlist_track_ids, MediaType.TRACK)
467520
for index, track in enumerate(response["data"]):
468521
if track and track["id"]:
469-
parsed_track = self._parse_track(track)
522+
is_favourite = rating_response.get(track["id"])
523+
parsed_track = self._parse_track(track, is_favourite)
470524
parsed_track.position = offset + index + 1
471525
result.append(parsed_track)
472526
return result
@@ -481,7 +535,17 @@ async def get_artist_albums(self, prov_artist_id) -> list[Album]:
481535
# Some artists do not have albums, return empty list
482536
self.logger.info("No albums found for artist %s", prov_artist_id)
483537
return []
484-
return [self._parse_album(album) for album in response if album["id"]]
538+
album_ids = [album["id"] for album in response if album["id"]]
539+
rating_response = await self._get_ratings(album_ids, MediaType.ALBUM)
540+
albums = []
541+
for album in response:
542+
if not album["id"]:
543+
continue
544+
is_favourite = rating_response.get(album["id"])
545+
parsed_album = self._parse_album(album, is_favourite)
546+
if parsed_album:
547+
albums.append(parsed_album)
548+
return albums
485549

486550
@use_cache(3600 * 24 * 7) # cache for 7 days
487551
async def get_artist_toptracks(self, prov_artist_id) -> list[Track]:
@@ -493,7 +557,15 @@ async def get_artist_toptracks(self, prov_artist_id) -> list[Track]:
493557
# Some artists do not have top tracks, return empty list
494558
self.logger.info("No top tracks found for artist %s", prov_artist_id)
495559
return []
496-
return [self._parse_track(track) for track in response["data"] if track["id"]]
560+
track_ids = [track["id"] for track in response["data"] if track["id"]]
561+
rating_response = await self._get_ratings(track_ids, MediaType.TRACK)
562+
tracks = []
563+
for track in response["data"]:
564+
if not track["id"]:
565+
continue
566+
is_favourite = rating_response.get(track["id"])
567+
tracks.append(self._parse_track(track, is_favourite))
568+
return tracks
497569

498570
async def library_add(self, item: MediaItemType) -> None:
499571
"""Add item to library."""
@@ -548,9 +620,12 @@ async def get_similar_tracks(self, prov_track_id, limit=25) -> list[Track]:
548620
response = await self._post_data(endpoint, include="artists")
549621
if not response or "data" not in response:
550622
break
623+
track_ids = [track["id"] for track in response["data"] if track and track["id"]]
624+
rating_response = await self._get_ratings(track_ids, MediaType.TRACK)
551625
for track in response["data"]:
552626
if track and track["id"]:
553-
found_tracks.append(self._parse_track(track))
627+
is_favourite = rating_response.get(track["id"])
628+
found_tracks.append(self._parse_track(track, is_favourite))
554629
return found_tracks
555630

556631
async def get_stream_details(self, item_id: str, media_type: MediaType) -> StreamDetails:
@@ -655,7 +730,9 @@ def _parse_artist(self, artist_obj: dict[str, Any]) -> Artist:
655730
artist.metadata.description = notes.get("standard") or notes.get("short")
656731
return artist
657732

658-
def _parse_album(self, album_obj: dict) -> Album | ItemMapping | None:
733+
def _parse_album(
734+
self, album_obj: dict, is_favourite: bool | None = None
735+
) -> Album | ItemMapping | None:
659736
"""Parse album object to generic layout."""
660737
relationships = album_obj.get("relationships", {})
661738
response_type = album_obj.get("type")
@@ -747,7 +824,7 @@ def _parse_album(self, album_obj: dict) -> Album | ItemMapping | None:
747824
inferred_type = infer_album_type(album.name, "")
748825
if inferred_type in (AlbumType.SOUNDTRACK, AlbumType.LIVE):
749826
album.album_type = inferred_type
750-
827+
album.favorite = is_favourite or False
751828
return album
752829

753830
def _parse_track(
@@ -827,7 +904,9 @@ def _parse_track(
827904
track.favorite = is_favourite or False
828905
return track
829906

830-
def _parse_playlist(self, playlist_obj: dict[str, Any]) -> Playlist:
907+
def _parse_playlist(
908+
self, playlist_obj: dict[str, Any], is_favourite: bool | None = None
909+
) -> Playlist:
831910
"""Parse Apple Music playlist object to generic layout."""
832911
attributes = playlist_obj["attributes"]
833912
playlist_id = attributes["playParams"].get("globalId") or playlist_obj["id"]
@@ -861,6 +940,7 @@ def _parse_playlist(self, playlist_obj: dict[str, Any]) -> Playlist:
861940
)
862941
if description := attributes.get("description"):
863942
playlist.metadata.description = description.get("standard")
943+
playlist.favorite = is_favourite or False
864944
return playlist
865945

866946
async def _get_all_items(self, endpoint, key="data", **kwargs) -> list[dict]:
@@ -994,7 +1074,10 @@ async def _get_ratings(self, item_ids: list[str], media_type: MediaType) -> dict
9941074
raise NotImplementedError(
9951075
"Ratings are not available for artist in the Apple Music API."
9961076
)
997-
endpoint = self._translate_media_type_to_apple_type(media_type)
1077+
if len(item_ids) == 0:
1078+
return {}
1079+
apple_type = self._translate_media_type_to_apple_type(media_type)
1080+
endpoint = apple_type if not self.is_library_id(item_ids[0]) else f"library-{apple_type}"
9981081
# Apple Music limits to 200 ids per request
9991082
max_ids_per_request = 200
10001083
results = {}

0 commit comments

Comments
 (0)