Home Assistant Unofficial Reference 2024.12.1
browse_media.py
Go to the documentation of this file.
1 """Support for Spotify media browsing."""
2 
3 from __future__ import annotations
4 
5 from enum import StrEnum
6 import logging
7 from typing import TYPE_CHECKING, Any, TypedDict
8 
9 from spotifyaio import (
10  Artist,
11  BasePlaylist,
12  SimplifiedAlbum,
13  SimplifiedTrack,
14  SpotifyClient,
15  Track,
16 )
17 from spotifyaio.models import ItemType, SimplifiedEpisode
18 import yarl
19 
21  BrowseError,
22  BrowseMedia,
23  MediaClass,
24  MediaType,
25 )
26 from homeassistant.config_entries import ConfigEntryState
27 from homeassistant.core import HomeAssistant
28 
29 from .const import DOMAIN, MEDIA_PLAYER_PREFIX, MEDIA_TYPE_SHOW, PLAYABLE_MEDIA_TYPES
30 from .util import fetch_image_url
31 
32 BROWSE_LIMIT = 48
33 
34 
35 _LOGGER = logging.getLogger(__name__)
36 
37 
38 class ItemPayload(TypedDict):
39  """TypedDict for item payload."""
40 
41  name: str
42  type: str
43  uri: str
44  id: str | None
45  thumbnail: str | None
46 
47 
48 def _get_artist_item_payload(artist: Artist) -> ItemPayload:
49  return {
50  "id": artist.artist_id,
51  "name": artist.name,
52  "type": MediaType.ARTIST,
53  "uri": artist.uri,
54  "thumbnail": fetch_image_url(artist.images),
55  }
56 
57 
58 def _get_album_item_payload(album: SimplifiedAlbum) -> ItemPayload:
59  return {
60  "id": album.album_id,
61  "name": album.name,
62  "type": MediaType.ALBUM,
63  "uri": album.uri,
64  "thumbnail": fetch_image_url(album.images),
65  }
66 
67 
68 def _get_playlist_item_payload(playlist: BasePlaylist) -> ItemPayload:
69  return {
70  "id": playlist.playlist_id,
71  "name": playlist.name,
72  "type": MediaType.PLAYLIST,
73  "uri": playlist.uri,
74  "thumbnail": fetch_image_url(playlist.images),
75  }
76 
77 
79  track: SimplifiedTrack, show_thumbnails: bool = True
80 ) -> ItemPayload:
81  return {
82  "id": track.track_id,
83  "name": track.name,
84  "type": MediaType.TRACK,
85  "uri": track.uri,
86  "thumbnail": (
87  fetch_image_url(track.album.images)
88  if show_thumbnails and isinstance(track, Track)
89  else None
90  ),
91  }
92 
93 
94 def _get_episode_item_payload(episode: SimplifiedEpisode) -> ItemPayload:
95  return {
96  "id": episode.episode_id,
97  "name": episode.name,
98  "type": MediaType.EPISODE,
99  "uri": episode.uri,
100  "thumbnail": fetch_image_url(episode.images),
101  }
102 
103 
104 class BrowsableMedia(StrEnum):
105  """Enum of browsable media."""
106 
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"
116 
117 
118 LIBRARY_MAP = {
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",
128 }
129 
130 CONTENT_TYPE_MEDIA_CLASS: dict[str, Any] = {
131  BrowsableMedia.CURRENT_USER_PLAYLISTS.value: {
132  "parent": MediaClass.DIRECTORY,
133  "children": MediaClass.PLAYLIST,
134  },
135  BrowsableMedia.CURRENT_USER_FOLLOWED_ARTISTS.value: {
136  "parent": MediaClass.DIRECTORY,
137  "children": MediaClass.ARTIST,
138  },
139  BrowsableMedia.CURRENT_USER_SAVED_ALBUMS.value: {
140  "parent": MediaClass.DIRECTORY,
141  "children": MediaClass.ALBUM,
142  },
143  BrowsableMedia.CURRENT_USER_SAVED_TRACKS.value: {
144  "parent": MediaClass.DIRECTORY,
145  "children": MediaClass.TRACK,
146  },
147  BrowsableMedia.CURRENT_USER_SAVED_SHOWS.value: {
148  "parent": MediaClass.DIRECTORY,
149  "children": MediaClass.PODCAST,
150  },
151  BrowsableMedia.CURRENT_USER_RECENTLY_PLAYED.value: {
152  "parent": MediaClass.DIRECTORY,
153  "children": MediaClass.TRACK,
154  },
155  BrowsableMedia.CURRENT_USER_TOP_ARTISTS.value: {
156  "parent": MediaClass.DIRECTORY,
157  "children": MediaClass.ARTIST,
158  },
159  BrowsableMedia.CURRENT_USER_TOP_TRACKS.value: {
160  "parent": MediaClass.DIRECTORY,
161  "children": MediaClass.TRACK,
162  },
163  BrowsableMedia.NEW_RELEASES.value: {
164  "parent": MediaClass.DIRECTORY,
165  "children": MediaClass.ALBUM,
166  },
167  MediaType.PLAYLIST: {
168  "parent": MediaClass.PLAYLIST,
169  "children": MediaClass.TRACK,
170  },
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},
176 }
177 
178 
180  """Missing media required information."""
181 
182 
183 class UnknownMediaType(BrowseError):
184  """Unknown media type."""
185 
186 
188  hass: HomeAssistant,
189  media_content_type: str | None,
190  media_content_id: str | None,
191  *,
192  can_play_artist: bool = True,
193 ) -> BrowseMedia:
194  """Browse Spotify media."""
195  parsed_url = None
196  info = None
197 
198  # Check if caller is requesting the root nodes
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
202  )
203  children = [
204  BrowseMedia(
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",
210  can_play=False,
211  can_expand=True,
212  )
213  for config_entry in config_entries
214  ]
215  return BrowseMedia(
216  title="Spotify",
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",
221  can_play=False,
222  can_expand=True,
223  children=children,
224  )
225 
226  if media_content_id is None or not media_content_id.startswith(MEDIA_PLAYER_PREFIX):
227  raise BrowseError("Invalid Spotify URL specified")
228 
229  # Check for config entry specifier, and extract Spotify URI
230  parsed_url = yarl.URL(media_content_id)
231  host = parsed_url.host
232 
233  if (
234  host is None
235  # config entry ids can be upper or lower case. Yarl always returns host
236  # names in lower case, so we need to look for the config entry in both
237  or (
238  entry := hass.config_entries.async_get_entry(host)
239  or hass.config_entries.async_get_entry(host.upper())
240  )
241  is None
242  or entry.state is not ConfigEntryState.LOADED
243  ):
244  raise BrowseError("Invalid Spotify account specified")
245  media_content_id = parsed_url.name
246  info = entry.runtime_data
247 
248  result = await async_browse_media_internal(
249  hass,
250  info.coordinator.client,
251  media_content_type,
252  media_content_id,
253  can_play_artist=can_play_artist,
254  )
255 
256  # Build new URLs with config entry specifiers
257  result.media_content_id = str(parsed_url.with_name(result.media_content_id))
258  if result.children:
259  for child in result.children:
260  child.media_content_id = str(parsed_url.with_name(child.media_content_id))
261  return result
262 
263 
265  hass: HomeAssistant,
266  spotify: SpotifyClient,
267  media_content_type: str | None,
268  media_content_id: str | None,
269  *,
270  can_play_artist: bool = True,
271 ) -> BrowseMedia:
272  """Browse spotify media."""
273  if media_content_type in (None, f"{MEDIA_PLAYER_PREFIX}library"):
274  return await library_payload(can_play_artist=can_play_artist)
275 
276  # Strip prefix
277  if media_content_type:
278  media_content_type = media_content_type.removeprefix(MEDIA_PLAYER_PREFIX)
279 
280  payload = {
281  "media_content_type": media_content_type,
282  "media_content_id": media_content_id,
283  }
284  response = await build_item_response(
285  spotify,
286  payload,
287  can_play_artist=can_play_artist,
288  )
289  if response is None:
290  raise BrowseError(f"Media not found: {media_content_type} / {media_content_id}")
291  return response
292 
293 
294 async def build_item_response( # noqa: C901
295  spotify: SpotifyClient,
296  payload: dict[str, str | None],
297  *,
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"]
303 
304  if media_content_type is None or media_content_id is None:
305  return None
306 
307  title: str | None = None
308  image: str | None = None
309  items: list[ItemPayload] = []
310 
311  if media_content_type == BrowsableMedia.CURRENT_USER_PLAYLISTS:
312  if playlists := await spotify.get_playlists_for_current_user():
313  items = [_get_playlist_item_payload(playlist) for playlist in playlists]
314  elif media_content_type == BrowsableMedia.CURRENT_USER_FOLLOWED_ARTISTS:
315  if artists := await spotify.get_followed_artists():
316  items = [_get_artist_item_payload(artist) for artist in artists]
317  elif media_content_type == BrowsableMedia.CURRENT_USER_SAVED_ALBUMS:
318  if saved_albums := await spotify.get_saved_albums():
319  items = [
320  _get_album_item_payload(saved_album.album)
321  for saved_album in saved_albums
322  ]
323  elif media_content_type == BrowsableMedia.CURRENT_USER_SAVED_TRACKS:
324  if saved_tracks := await spotify.get_saved_tracks():
325  items = [
326  _get_track_item_payload(saved_track.track)
327  for saved_track in saved_tracks
328  ]
329  elif media_content_type == BrowsableMedia.CURRENT_USER_SAVED_SHOWS:
330  if saved_shows := await spotify.get_saved_shows():
331  items = [
332  {
333  "id": saved_show.show.show_id,
334  "name": saved_show.show.name,
335  "type": MEDIA_TYPE_SHOW,
336  "uri": saved_show.show.uri,
337  "thumbnail": fetch_image_url(saved_show.show.images),
338  }
339  for saved_show in saved_shows
340  ]
341  elif media_content_type == BrowsableMedia.CURRENT_USER_RECENTLY_PLAYED:
342  if recently_played_tracks := await spotify.get_recently_played_tracks():
343  items = [
344  _get_track_item_payload(item.track) for item in recently_played_tracks
345  ]
346  elif media_content_type == BrowsableMedia.CURRENT_USER_TOP_ARTISTS:
347  if top_artists := await spotify.get_top_artists():
348  items = [_get_artist_item_payload(artist) for artist in top_artists]
349  elif media_content_type == BrowsableMedia.CURRENT_USER_TOP_TRACKS:
350  if top_tracks := await spotify.get_top_tracks():
351  items = [_get_track_item_payload(track) for track in top_tracks]
352  elif media_content_type == BrowsableMedia.NEW_RELEASES:
353  if new_releases := await spotify.get_new_releases():
354  items = [_get_album_item_payload(album) for album in 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:
361  if TYPE_CHECKING:
362  assert isinstance(playlist_item.track, Track)
363  items.append(_get_track_item_payload(playlist_item.track))
364  elif playlist_item.track.type is ItemType.EPISODE:
365  if TYPE_CHECKING:
366  assert isinstance(playlist_item.track, SimplifiedEpisode)
367  items.append(_get_episode_item_payload(playlist_item.track))
368  elif media_content_type == MediaType.ALBUM:
369  if album := await spotify.get_album(media_content_id):
370  title = album.name
371  image = album.images[0].url if album.images else None
372  items = [
373  _get_track_item_payload(track, show_thumbnails=False)
374  for track in album.tracks
375  ]
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)
379  ):
380  title = artist.name
381  image = artist.images[0].url if artist.images else None
382  items = [_get_album_item_payload(album) for album in artist_albums]
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)
386  ):
387  title = show.name
388  image = show.images[0].url if show.images else None
389  items = [_get_episode_item_payload(episode) for episode in show_episodes]
390 
391  try:
392  media_class = CONTENT_TYPE_MEDIA_CLASS[media_content_type]
393  except KeyError:
394  _LOGGER.debug("Unknown media type received: %s", media_content_type)
395  return None
396 
397  if title is None:
398  title = LIBRARY_MAP.get(media_content_id, "Unknown")
399 
400  can_play = media_content_type in PLAYABLE_MEDIA_TYPES and (
401  media_content_type != MediaType.ARTIST or can_play_artist
402  )
403 
404  if TYPE_CHECKING:
405  assert title
406  browse_media = BrowseMedia(
407  can_expand=True,
408  can_play=can_play,
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}",
413  thumbnail=image,
414  title=title,
415  )
416 
417  browse_media.children = []
418  for item in items:
419  try:
420  browse_media.children.append(
421  item_payload(item, can_play_artist=can_play_artist)
422  )
423  except (MissingMediaInformation, UnknownMediaType):
424  continue
425 
426  return browse_media
427 
428 
429 def item_payload(item: ItemPayload, *, can_play_artist: bool) -> BrowseMedia:
430  """Create response payload for a single media item.
431 
432  Used by async_browse_media.
433  """
434  media_type = item["type"]
435  media_id = item["uri"]
436 
437  try:
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
442 
443  can_expand = media_type not in [
444  MediaType.TRACK,
445  MediaType.EPISODE,
446  ]
447 
448  can_play = media_type in PLAYABLE_MEDIA_TYPES and (
449  media_type != MediaType.ARTIST or can_play_artist
450  )
451 
452  return BrowseMedia(
453  can_expand=can_expand,
454  can_play=can_play,
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}",
459  title=item["name"],
460  thumbnail=item["thumbnail"],
461  )
462 
463 
464 async def library_payload(*, can_play_artist: bool) -> BrowseMedia:
465  """Create response payload to describe contents of a specific library.
466 
467  Used by async_browse_media.
468  """
469  browse_media = BrowseMedia(
470  can_expand=True,
471  can_play=False,
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",
477  )
478 
479  browse_media.children = []
480  for item_type, item_name in LIBRARY_MAP.items():
481  browse_media.children.append(
482  item_payload(
483  {
484  "name": item_name,
485  "type": item_type,
486  "uri": item_type,
487  "id": None,
488  "thumbnail": None,
489  },
490  can_play_artist=can_play_artist,
491  )
492  )
493  return browse_media
ItemPayload _get_album_item_payload(SimplifiedAlbum album)
Definition: browse_media.py:58
ItemPayload _get_playlist_item_payload(BasePlaylist playlist)
Definition: browse_media.py:68
ItemPayload _get_episode_item_payload(SimplifiedEpisode episode)
Definition: browse_media.py:94
ItemPayload _get_track_item_payload(SimplifiedTrack track, bool show_thumbnails=True)
Definition: browse_media.py:80
BrowseMedia async_browse_media(HomeAssistant hass, str|None media_content_type, str|None media_content_id, *bool can_play_artist=True)
BrowseMedia item_payload(ItemPayload item, *bool can_play_artist)
BrowseMedia|None build_item_response(SpotifyClient spotify, dict[str, str|None] payload, *bool can_play_artist)
BrowseMedia async_browse_media_internal(HomeAssistant hass, SpotifyClient spotify, str|None media_content_type, str|None media_content_id, *bool can_play_artist=True)
BrowseMedia library_payload(*bool can_play_artist)
ItemPayload _get_artist_item_payload(Artist artist)
Definition: browse_media.py:48
str|None fetch_image_url(list[Image] images)
Definition: util.py:21