1 """Support for Spotify media browsing."""
3 from __future__
import annotations
5 from enum
import StrEnum
7 from typing
import TYPE_CHECKING, Any, TypedDict
9 from spotifyaio
import (
17 from spotifyaio.models
import ItemType, SimplifiedEpisode
29 from .const
import DOMAIN, MEDIA_PLAYER_PREFIX, MEDIA_TYPE_SHOW, PLAYABLE_MEDIA_TYPES
30 from .util
import fetch_image_url
35 _LOGGER = logging.getLogger(__name__)
39 """TypedDict for item payload."""
50 "id": artist.artist_id,
52 "type": MediaType.ARTIST,
62 "type": MediaType.ALBUM,
70 "id": playlist.playlist_id,
71 "name": playlist.name,
72 "type": MediaType.PLAYLIST,
79 track: SimplifiedTrack, show_thumbnails: bool =
True
84 "type": MediaType.TRACK,
88 if show_thumbnails
and isinstance(track, Track)
96 "id": episode.episode_id,
98 "type": MediaType.EPISODE,
105 """Enum of browsable media."""
107 CURRENT_USER_PLAYLISTS =
"current_user_playlists"
108 CURRENT_USER_FOLLOWED_ARTISTS =
"current_user_followed_artists"
109 CURRENT_USER_SAVED_ALBUMS =
"current_user_saved_albums"
110 CURRENT_USER_SAVED_TRACKS =
"current_user_saved_tracks"
111 CURRENT_USER_SAVED_SHOWS =
"current_user_saved_shows"
112 CURRENT_USER_RECENTLY_PLAYED =
"current_user_recently_played"
113 CURRENT_USER_TOP_ARTISTS =
"current_user_top_artists"
114 CURRENT_USER_TOP_TRACKS =
"current_user_top_tracks"
115 NEW_RELEASES =
"new_releases"
119 BrowsableMedia.CURRENT_USER_PLAYLISTS.value:
"Playlists",
120 BrowsableMedia.CURRENT_USER_FOLLOWED_ARTISTS.value:
"Artists",
121 BrowsableMedia.CURRENT_USER_SAVED_ALBUMS.value:
"Albums",
122 BrowsableMedia.CURRENT_USER_SAVED_TRACKS.value:
"Tracks",
123 BrowsableMedia.CURRENT_USER_SAVED_SHOWS.value:
"Podcasts",
124 BrowsableMedia.CURRENT_USER_RECENTLY_PLAYED.value:
"Recently played",
125 BrowsableMedia.CURRENT_USER_TOP_ARTISTS.value:
"Top Artists",
126 BrowsableMedia.CURRENT_USER_TOP_TRACKS.value:
"Top Tracks",
127 BrowsableMedia.NEW_RELEASES.value:
"New Releases",
130 CONTENT_TYPE_MEDIA_CLASS: dict[str, Any] = {
131 BrowsableMedia.CURRENT_USER_PLAYLISTS.value: {
132 "parent": MediaClass.DIRECTORY,
133 "children": MediaClass.PLAYLIST,
135 BrowsableMedia.CURRENT_USER_FOLLOWED_ARTISTS.value: {
136 "parent": MediaClass.DIRECTORY,
137 "children": MediaClass.ARTIST,
139 BrowsableMedia.CURRENT_USER_SAVED_ALBUMS.value: {
140 "parent": MediaClass.DIRECTORY,
141 "children": MediaClass.ALBUM,
143 BrowsableMedia.CURRENT_USER_SAVED_TRACKS.value: {
144 "parent": MediaClass.DIRECTORY,
145 "children": MediaClass.TRACK,
147 BrowsableMedia.CURRENT_USER_SAVED_SHOWS.value: {
148 "parent": MediaClass.DIRECTORY,
149 "children": MediaClass.PODCAST,
151 BrowsableMedia.CURRENT_USER_RECENTLY_PLAYED.value: {
152 "parent": MediaClass.DIRECTORY,
153 "children": MediaClass.TRACK,
155 BrowsableMedia.CURRENT_USER_TOP_ARTISTS.value: {
156 "parent": MediaClass.DIRECTORY,
157 "children": MediaClass.ARTIST,
159 BrowsableMedia.CURRENT_USER_TOP_TRACKS.value: {
160 "parent": MediaClass.DIRECTORY,
161 "children": MediaClass.TRACK,
163 BrowsableMedia.NEW_RELEASES.value: {
164 "parent": MediaClass.DIRECTORY,
165 "children": MediaClass.ALBUM,
167 MediaType.PLAYLIST: {
168 "parent": MediaClass.PLAYLIST,
169 "children": MediaClass.TRACK,
171 MediaType.ALBUM: {
"parent": MediaClass.ALBUM,
"children": MediaClass.TRACK},
172 MediaType.ARTIST: {
"parent": MediaClass.ARTIST,
"children": MediaClass.ALBUM},
173 MediaType.EPISODE: {
"parent": MediaClass.EPISODE,
"children":
None},
174 MEDIA_TYPE_SHOW: {
"parent": MediaClass.PODCAST,
"children": MediaClass.EPISODE},
175 MediaType.TRACK: {
"parent": MediaClass.TRACK,
"children":
None},
180 """Missing media required information."""
183 class UnknownMediaType(BrowseError):
184 """Unknown media type."""
189 media_content_type: str |
None,
190 media_content_id: str |
None,
192 can_play_artist: bool =
True,
194 """Browse Spotify media."""
199 if media_content_type
is None and media_content_id
is None:
200 config_entries = hass.config_entries.async_entries(
201 DOMAIN, include_disabled=
False, include_ignore=
False
205 title=config_entry.title,
206 media_class=MediaClass.APP,
207 media_content_id=f
"{MEDIA_PLAYER_PREFIX}{config_entry.entry_id}",
208 media_content_type=f
"{MEDIA_PLAYER_PREFIX}library",
209 thumbnail=
"https://brands.home-assistant.io/_/spotify/logo.png",
213 for config_entry
in config_entries
217 media_class=MediaClass.APP,
218 media_content_id=MEDIA_PLAYER_PREFIX,
219 media_content_type=
"spotify",
220 thumbnail=
"https://brands.home-assistant.io/_/spotify/logo.png",
226 if media_content_id
is None or not media_content_id.startswith(MEDIA_PLAYER_PREFIX):
227 raise BrowseError(
"Invalid Spotify URL specified")
230 parsed_url = yarl.URL(media_content_id)
231 host = parsed_url.host
238 entry := hass.config_entries.async_get_entry(host)
239 or hass.config_entries.async_get_entry(host.upper())
242 or entry.state
is not ConfigEntryState.LOADED
244 raise BrowseError(
"Invalid Spotify account specified")
245 media_content_id = parsed_url.name
246 info = entry.runtime_data
250 info.coordinator.client,
253 can_play_artist=can_play_artist,
257 result.media_content_id =
str(parsed_url.with_name(result.media_content_id))
259 for child
in result.children:
260 child.media_content_id =
str(parsed_url.with_name(child.media_content_id))
266 spotify: SpotifyClient,
267 media_content_type: str |
None,
268 media_content_id: str |
None,
270 can_play_artist: bool =
True,
272 """Browse spotify media."""
273 if media_content_type
in (
None, f
"{MEDIA_PLAYER_PREFIX}library"):
277 if media_content_type:
278 media_content_type = media_content_type.removeprefix(MEDIA_PLAYER_PREFIX)
281 "media_content_type": media_content_type,
282 "media_content_id": media_content_id,
287 can_play_artist=can_play_artist,
290 raise BrowseError(f
"Media not found: {media_content_type} / {media_content_id}")
295 spotify: SpotifyClient,
296 payload: dict[str, str |
None],
298 can_play_artist: bool,
299 ) -> BrowseMedia |
None:
300 """Create response payload for the provided media query."""
301 media_content_type = payload[
"media_content_type"]
302 media_content_id = payload[
"media_content_id"]
304 if media_content_type
is None or media_content_id
is None:
307 title: str |
None =
None
308 image: str |
None =
None
309 items: list[ItemPayload] = []
311 if media_content_type == BrowsableMedia.CURRENT_USER_PLAYLISTS:
312 if playlists := await spotify.get_playlists_for_current_user():
314 elif media_content_type == BrowsableMedia.CURRENT_USER_FOLLOWED_ARTISTS:
315 if artists := await spotify.get_followed_artists():
317 elif media_content_type == BrowsableMedia.CURRENT_USER_SAVED_ALBUMS:
318 if saved_albums := await spotify.get_saved_albums():
321 for saved_album
in saved_albums
323 elif media_content_type == BrowsableMedia.CURRENT_USER_SAVED_TRACKS:
324 if saved_tracks := await spotify.get_saved_tracks():
327 for saved_track
in saved_tracks
329 elif media_content_type == BrowsableMedia.CURRENT_USER_SAVED_SHOWS:
330 if saved_shows := await spotify.get_saved_shows():
333 "id": saved_show.show.show_id,
334 "name": saved_show.show.name,
335 "type": MEDIA_TYPE_SHOW,
336 "uri": saved_show.show.uri,
339 for saved_show
in saved_shows
341 elif media_content_type == BrowsableMedia.CURRENT_USER_RECENTLY_PLAYED:
342 if recently_played_tracks := await spotify.get_recently_played_tracks():
346 elif media_content_type == BrowsableMedia.CURRENT_USER_TOP_ARTISTS:
347 if top_artists := await spotify.get_top_artists():
349 elif media_content_type == BrowsableMedia.CURRENT_USER_TOP_TRACKS:
350 if top_tracks := await spotify.get_top_tracks():
352 elif media_content_type == BrowsableMedia.NEW_RELEASES:
353 if new_releases := await spotify.get_new_releases():
355 elif media_content_type == MediaType.PLAYLIST:
356 if playlist := await spotify.get_playlist(media_content_id):
357 title = playlist.name
358 image = playlist.images[0].url
if playlist.images
else None
359 for playlist_item
in playlist.tracks.items:
360 if playlist_item.track.type
is ItemType.TRACK:
362 assert isinstance(playlist_item.track, Track)
364 elif playlist_item.track.type
is ItemType.EPISODE:
366 assert isinstance(playlist_item.track, SimplifiedEpisode)
368 elif media_content_type == MediaType.ALBUM:
369 if album := await spotify.get_album(media_content_id):
371 image = album.images[0].url
if album.images
else None
374 for track
in album.tracks
376 elif media_content_type == MediaType.ARTIST:
377 if (artist_albums := await spotify.get_artist_albums(media_content_id))
and (
378 artist := await spotify.get_artist(media_content_id)
381 image = artist.images[0].url
if artist.images
else None
383 elif media_content_type == MEDIA_TYPE_SHOW:
384 if (show_episodes := await spotify.get_show_episodes(media_content_id))
and (
385 show := await spotify.get_show(media_content_id)
388 image = show.images[0].url
if show.images
else None
392 media_class = CONTENT_TYPE_MEDIA_CLASS[media_content_type]
394 _LOGGER.debug(
"Unknown media type received: %s", media_content_type)
398 title = LIBRARY_MAP.get(media_content_id,
"Unknown")
400 can_play = media_content_type
in PLAYABLE_MEDIA_TYPES
and (
401 media_content_type != MediaType.ARTIST
or can_play_artist
409 children_media_class=media_class[
"children"],
410 media_class=media_class[
"parent"],
411 media_content_id=media_content_id,
412 media_content_type=f
"{MEDIA_PLAYER_PREFIX}{media_content_type}",
417 browse_media.children = []
420 browse_media.children.append(
423 except (MissingMediaInformation, UnknownMediaType):
429 def item_payload(item: ItemPayload, *, can_play_artist: bool) -> BrowseMedia:
430 """Create response payload for a single media item.
432 Used by async_browse_media.
434 media_type = item[
"type"]
435 media_id = item[
"uri"]
438 media_class = CONTENT_TYPE_MEDIA_CLASS[media_type]
439 except KeyError
as err:
440 _LOGGER.debug(
"Unknown media type received: %s", media_type)
441 raise UnknownMediaType
from err
443 can_expand = media_type
not in [
448 can_play = media_type
in PLAYABLE_MEDIA_TYPES
and (
449 media_type != MediaType.ARTIST
or can_play_artist
453 can_expand=can_expand,
455 children_media_class=media_class[
"children"],
456 media_class=media_class[
"parent"],
457 media_content_id=media_id,
458 media_content_type=f
"{MEDIA_PLAYER_PREFIX}{media_type}",
460 thumbnail=item[
"thumbnail"],
465 """Create response payload to describe contents of a specific library.
467 Used by async_browse_media.
472 children_media_class=MediaClass.DIRECTORY,
473 media_class=MediaClass.DIRECTORY,
474 media_content_id=
"library",
475 media_content_type=f
"{MEDIA_PLAYER_PREFIX}library",
476 title=
"Media Library",
479 browse_media.children = []
480 for item_type, item_name
in LIBRARY_MAP.items():
481 browse_media.children.append(
490 can_play_artist=can_play_artist,
str|None fetch_image_url(list[Image] images)