core/homeassistant/components/spotify/browse_media.py

559 lines
19 KiB
Python

"""Support for Spotify media browsing."""
from __future__ import annotations
from enum import StrEnum
import logging
from typing import TYPE_CHECKING, Any, TypedDict
from spotifyaio import (
Artist,
BasePlaylist,
SimplifiedAlbum,
SimplifiedTrack,
SpotifyClient,
Track,
)
import yarl
from homeassistant.components.media_player import (
BrowseError,
BrowseMedia,
MediaClass,
MediaType,
)
from homeassistant.config_entries import ConfigEntryState
from homeassistant.core import HomeAssistant
from .const import DOMAIN, MEDIA_PLAYER_PREFIX, MEDIA_TYPE_SHOW, PLAYABLE_MEDIA_TYPES
from .util import fetch_image_url
BROWSE_LIMIT = 48
_LOGGER = logging.getLogger(__name__)
class ItemPayload(TypedDict):
"""TypedDict for item payload."""
name: str
type: str
uri: str
id: str | None
thumbnail: str | None
def _get_artist_item_payload(artist: Artist) -> ItemPayload:
return {
"id": artist.artist_id,
"name": artist.name,
"type": MediaType.ARTIST,
"uri": artist.uri,
"thumbnail": fetch_image_url(artist.images),
}
def _get_album_item_payload(album: SimplifiedAlbum) -> ItemPayload:
return {
"id": album.album_id,
"name": album.name,
"type": MediaType.ALBUM,
"uri": album.uri,
"thumbnail": fetch_image_url(album.images),
}
def _get_playlist_item_payload(playlist: BasePlaylist) -> ItemPayload:
return {
"id": playlist.playlist_id,
"name": playlist.name,
"type": MediaType.PLAYLIST,
"uri": playlist.uri,
"thumbnail": fetch_image_url(playlist.images),
}
def _get_track_item_payload(
track: SimplifiedTrack, show_thumbnails: bool = True
) -> ItemPayload:
return {
"id": track.track_id,
"name": track.name,
"type": MediaType.TRACK,
"uri": track.uri,
"thumbnail": (
fetch_image_url(track.album.images)
if show_thumbnails and isinstance(track, Track)
else None
),
}
class BrowsableMedia(StrEnum):
"""Enum of browsable media."""
CURRENT_USER_PLAYLISTS = "current_user_playlists"
CURRENT_USER_FOLLOWED_ARTISTS = "current_user_followed_artists"
CURRENT_USER_SAVED_ALBUMS = "current_user_saved_albums"
CURRENT_USER_SAVED_TRACKS = "current_user_saved_tracks"
CURRENT_USER_SAVED_SHOWS = "current_user_saved_shows"
CURRENT_USER_RECENTLY_PLAYED = "current_user_recently_played"
CURRENT_USER_TOP_ARTISTS = "current_user_top_artists"
CURRENT_USER_TOP_TRACKS = "current_user_top_tracks"
CATEGORIES = "categories"
FEATURED_PLAYLISTS = "featured_playlists"
NEW_RELEASES = "new_releases"
LIBRARY_MAP = {
BrowsableMedia.CURRENT_USER_PLAYLISTS.value: "Playlists",
BrowsableMedia.CURRENT_USER_FOLLOWED_ARTISTS.value: "Artists",
BrowsableMedia.CURRENT_USER_SAVED_ALBUMS.value: "Albums",
BrowsableMedia.CURRENT_USER_SAVED_TRACKS.value: "Tracks",
BrowsableMedia.CURRENT_USER_SAVED_SHOWS.value: "Podcasts",
BrowsableMedia.CURRENT_USER_RECENTLY_PLAYED.value: "Recently played",
BrowsableMedia.CURRENT_USER_TOP_ARTISTS.value: "Top Artists",
BrowsableMedia.CURRENT_USER_TOP_TRACKS.value: "Top Tracks",
BrowsableMedia.CATEGORIES.value: "Categories",
BrowsableMedia.FEATURED_PLAYLISTS.value: "Featured Playlists",
BrowsableMedia.NEW_RELEASES.value: "New Releases",
}
CONTENT_TYPE_MEDIA_CLASS: dict[str, Any] = {
BrowsableMedia.CURRENT_USER_PLAYLISTS.value: {
"parent": MediaClass.DIRECTORY,
"children": MediaClass.PLAYLIST,
},
BrowsableMedia.CURRENT_USER_FOLLOWED_ARTISTS.value: {
"parent": MediaClass.DIRECTORY,
"children": MediaClass.ARTIST,
},
BrowsableMedia.CURRENT_USER_SAVED_ALBUMS.value: {
"parent": MediaClass.DIRECTORY,
"children": MediaClass.ALBUM,
},
BrowsableMedia.CURRENT_USER_SAVED_TRACKS.value: {
"parent": MediaClass.DIRECTORY,
"children": MediaClass.TRACK,
},
BrowsableMedia.CURRENT_USER_SAVED_SHOWS.value: {
"parent": MediaClass.DIRECTORY,
"children": MediaClass.PODCAST,
},
BrowsableMedia.CURRENT_USER_RECENTLY_PLAYED.value: {
"parent": MediaClass.DIRECTORY,
"children": MediaClass.TRACK,
},
BrowsableMedia.CURRENT_USER_TOP_ARTISTS.value: {
"parent": MediaClass.DIRECTORY,
"children": MediaClass.ARTIST,
},
BrowsableMedia.CURRENT_USER_TOP_TRACKS.value: {
"parent": MediaClass.DIRECTORY,
"children": MediaClass.TRACK,
},
BrowsableMedia.FEATURED_PLAYLISTS.value: {
"parent": MediaClass.DIRECTORY,
"children": MediaClass.PLAYLIST,
},
BrowsableMedia.CATEGORIES.value: {
"parent": MediaClass.DIRECTORY,
"children": MediaClass.GENRE,
},
"category_playlists": {
"parent": MediaClass.DIRECTORY,
"children": MediaClass.PLAYLIST,
},
BrowsableMedia.NEW_RELEASES.value: {
"parent": MediaClass.DIRECTORY,
"children": MediaClass.ALBUM,
},
MediaType.PLAYLIST: {
"parent": MediaClass.PLAYLIST,
"children": MediaClass.TRACK,
},
MediaType.ALBUM: {"parent": MediaClass.ALBUM, "children": MediaClass.TRACK},
MediaType.ARTIST: {"parent": MediaClass.ARTIST, "children": MediaClass.ALBUM},
MediaType.EPISODE: {"parent": MediaClass.EPISODE, "children": None},
MEDIA_TYPE_SHOW: {"parent": MediaClass.PODCAST, "children": MediaClass.EPISODE},
MediaType.TRACK: {"parent": MediaClass.TRACK, "children": None},
}
class MissingMediaInformation(BrowseError):
"""Missing media required information."""
class UnknownMediaType(BrowseError):
"""Unknown media type."""
async def async_browse_media(
hass: HomeAssistant,
media_content_type: str | None,
media_content_id: str | None,
*,
can_play_artist: bool = True,
) -> BrowseMedia:
"""Browse Spotify media."""
parsed_url = None
info = None
# Check if caller is requesting the root nodes
if media_content_type is None and media_content_id is None:
config_entries = hass.config_entries.async_entries(
DOMAIN, include_disabled=False, include_ignore=False
)
children = [
BrowseMedia(
title=config_entry.title,
media_class=MediaClass.APP,
media_content_id=f"{MEDIA_PLAYER_PREFIX}{config_entry.entry_id}",
media_content_type=f"{MEDIA_PLAYER_PREFIX}library",
thumbnail="https://brands.home-assistant.io/_/spotify/logo.png",
can_play=False,
can_expand=True,
)
for config_entry in config_entries
]
return BrowseMedia(
title="Spotify",
media_class=MediaClass.APP,
media_content_id=MEDIA_PLAYER_PREFIX,
media_content_type="spotify",
thumbnail="https://brands.home-assistant.io/_/spotify/logo.png",
can_play=False,
can_expand=True,
children=children,
)
if media_content_id is None or not media_content_id.startswith(MEDIA_PLAYER_PREFIX):
raise BrowseError("Invalid Spotify URL specified")
# Check for config entry specifier, and extract Spotify URI
parsed_url = yarl.URL(media_content_id)
host = parsed_url.host
if (
host is None
# config entry ids can be upper or lower case. Yarl always returns host
# names in lower case, so we need to look for the config entry in both
or (
entry := hass.config_entries.async_get_entry(host)
or hass.config_entries.async_get_entry(host.upper())
)
is None
or entry.state is not ConfigEntryState.LOADED
):
raise BrowseError("Invalid Spotify account specified")
media_content_id = parsed_url.name
info = entry.runtime_data
result = await async_browse_media_internal(
hass,
info.coordinator.client,
media_content_type,
media_content_id,
can_play_artist=can_play_artist,
)
# Build new URLs with config entry specifiers
result.media_content_id = str(parsed_url.with_name(result.media_content_id))
if result.children:
for child in result.children:
child.media_content_id = str(parsed_url.with_name(child.media_content_id))
return result
async def async_browse_media_internal(
hass: HomeAssistant,
spotify: SpotifyClient,
media_content_type: str | None,
media_content_id: str | None,
*,
can_play_artist: bool = True,
) -> BrowseMedia:
"""Browse spotify media."""
if media_content_type in (None, f"{MEDIA_PLAYER_PREFIX}library"):
return await library_payload(can_play_artist=can_play_artist)
# Strip prefix
if media_content_type:
media_content_type = media_content_type.removeprefix(MEDIA_PLAYER_PREFIX)
payload = {
"media_content_type": media_content_type,
"media_content_id": media_content_id,
}
response = await build_item_response(
spotify,
payload,
can_play_artist=can_play_artist,
)
if response is None:
raise BrowseError(f"Media not found: {media_content_type} / {media_content_id}")
return response
async def build_item_response( # noqa: C901
spotify: SpotifyClient,
payload: dict[str, str | None],
*,
can_play_artist: bool,
) -> BrowseMedia | None:
"""Create response payload for the provided media query."""
media_content_type = payload["media_content_type"]
media_content_id = payload["media_content_id"]
if media_content_type is None or media_content_id is None:
return None
title: str | None = None
image: str | None = None
items: list[ItemPayload] = []
if media_content_type == BrowsableMedia.CURRENT_USER_PLAYLISTS:
if playlists := await spotify.get_playlists_for_current_user():
items = [_get_playlist_item_payload(playlist) for playlist in playlists]
elif media_content_type == BrowsableMedia.CURRENT_USER_FOLLOWED_ARTISTS:
if artists := await spotify.get_followed_artists():
items = [_get_artist_item_payload(artist) for artist in artists]
elif media_content_type == BrowsableMedia.CURRENT_USER_SAVED_ALBUMS:
if saved_albums := await spotify.get_saved_albums():
items = [
_get_album_item_payload(saved_album.album)
for saved_album in saved_albums
]
elif media_content_type == BrowsableMedia.CURRENT_USER_SAVED_TRACKS:
if saved_tracks := await spotify.get_saved_tracks():
items = [
_get_track_item_payload(saved_track.track)
for saved_track in saved_tracks
]
elif media_content_type == BrowsableMedia.CURRENT_USER_SAVED_SHOWS:
if saved_shows := await spotify.get_saved_shows():
items = [
{
"id": saved_show.show.show_id,
"name": saved_show.show.name,
"type": MEDIA_TYPE_SHOW,
"uri": saved_show.show.uri,
"thumbnail": fetch_image_url(saved_show.show.images),
}
for saved_show in saved_shows
]
elif media_content_type == BrowsableMedia.CURRENT_USER_RECENTLY_PLAYED:
if recently_played_tracks := await spotify.get_recently_played_tracks():
items = [
_get_track_item_payload(item.track) for item in recently_played_tracks
]
elif media_content_type == BrowsableMedia.CURRENT_USER_TOP_ARTISTS:
if top_artists := await spotify.get_top_artists():
items = [_get_artist_item_payload(artist) for artist in top_artists]
elif media_content_type == BrowsableMedia.CURRENT_USER_TOP_TRACKS:
if top_tracks := await spotify.get_top_tracks():
items = [_get_track_item_payload(track) for track in top_tracks]
elif media_content_type == BrowsableMedia.FEATURED_PLAYLISTS:
if featured_playlists := await spotify.get_featured_playlists():
items = [
_get_playlist_item_payload(playlist) for playlist in featured_playlists
]
elif media_content_type == BrowsableMedia.CATEGORIES:
if categories := await spotify.get_categories():
items = [
{
"id": category.category_id,
"name": category.name,
"type": "category_playlists",
"uri": category.category_id,
"thumbnail": category.icons[0].url if category.icons else None,
}
for category in categories
]
elif media_content_type == "category_playlists":
if (
playlists := await spotify.get_category_playlists(
category_id=media_content_id
)
) and (category := await spotify.get_category(media_content_id)):
title = category.name
image = category.icons[0].url if category.icons else None
items = [_get_playlist_item_payload(playlist) for playlist in playlists]
elif media_content_type == BrowsableMedia.NEW_RELEASES:
if new_releases := await spotify.get_new_releases():
items = [_get_album_item_payload(album) for album in new_releases]
elif media_content_type == MediaType.PLAYLIST:
if playlist := await spotify.get_playlist(media_content_id):
title = playlist.name
image = playlist.images[0].url if playlist.images else None
items = [
_get_track_item_payload(playlist_track.track)
for playlist_track in playlist.tracks.items
]
elif media_content_type == MediaType.ALBUM:
if album := await spotify.get_album(media_content_id):
title = album.name
image = album.images[0].url if album.images else None
items = [
_get_track_item_payload(track, show_thumbnails=False)
for track in album.tracks
]
elif media_content_type == MediaType.ARTIST:
if (artist_albums := await spotify.get_artist_albums(media_content_id)) and (
artist := await spotify.get_artist(media_content_id)
):
title = artist.name
image = artist.images[0].url if artist.images else None
items = [_get_album_item_payload(album) for album in artist_albums]
elif media_content_type == MEDIA_TYPE_SHOW:
if (show_episodes := await spotify.get_show_episodes(media_content_id)) and (
show := await spotify.get_show(media_content_id)
):
title = show.name
image = show.images[0].url if show.images else None
items = [
{
"id": episode.episode_id,
"name": episode.name,
"type": MediaType.EPISODE,
"uri": episode.uri,
"thumbnail": fetch_image_url(episode.images),
}
for episode in show_episodes
]
try:
media_class = CONTENT_TYPE_MEDIA_CLASS[media_content_type]
except KeyError:
_LOGGER.debug("Unknown media type received: %s", media_content_type)
return None
if media_content_type == BrowsableMedia.CATEGORIES:
media_item = BrowseMedia(
can_expand=True,
can_play=False,
children_media_class=media_class["children"],
media_class=media_class["parent"],
media_content_id=media_content_id,
media_content_type=f"{MEDIA_PLAYER_PREFIX}{media_content_type}",
title=LIBRARY_MAP.get(media_content_id, "Unknown"),
)
media_item.children = []
for item in items:
if (item_id := item["id"]) is None:
_LOGGER.debug("Missing ID for media item: %s", item)
continue
media_item.children.append(
BrowseMedia(
can_expand=True,
can_play=False,
children_media_class=MediaClass.TRACK,
media_class=MediaClass.PLAYLIST,
media_content_id=item_id,
media_content_type=f"{MEDIA_PLAYER_PREFIX}category_playlists",
thumbnail=item["thumbnail"],
title=item["name"],
)
)
return media_item
if title is None:
title = LIBRARY_MAP.get(media_content_id, "Unknown")
can_play = media_content_type in PLAYABLE_MEDIA_TYPES and (
media_content_type != MediaType.ARTIST or can_play_artist
)
if TYPE_CHECKING:
assert title
browse_media = BrowseMedia(
can_expand=True,
can_play=can_play,
children_media_class=media_class["children"],
media_class=media_class["parent"],
media_content_id=media_content_id,
media_content_type=f"{MEDIA_PLAYER_PREFIX}{media_content_type}",
thumbnail=image,
title=title,
)
browse_media.children = []
for item in items:
try:
browse_media.children.append(
item_payload(item, can_play_artist=can_play_artist)
)
except (MissingMediaInformation, UnknownMediaType):
continue
return browse_media
def item_payload(item: ItemPayload, *, can_play_artist: bool) -> BrowseMedia:
"""Create response payload for a single media item.
Used by async_browse_media.
"""
media_type = item["type"]
media_id = item["uri"]
try:
media_class = CONTENT_TYPE_MEDIA_CLASS[media_type]
except KeyError as err:
_LOGGER.debug("Unknown media type received: %s", media_type)
raise UnknownMediaType from err
can_expand = media_type not in [
MediaType.TRACK,
MediaType.EPISODE,
]
can_play = media_type in PLAYABLE_MEDIA_TYPES and (
media_type != MediaType.ARTIST or can_play_artist
)
return BrowseMedia(
can_expand=can_expand,
can_play=can_play,
children_media_class=media_class["children"],
media_class=media_class["parent"],
media_content_id=media_id,
media_content_type=f"{MEDIA_PLAYER_PREFIX}{media_type}",
title=item["name"],
thumbnail=item["thumbnail"],
)
async def library_payload(*, can_play_artist: bool) -> BrowseMedia:
"""Create response payload to describe contents of a specific library.
Used by async_browse_media.
"""
browse_media = BrowseMedia(
can_expand=True,
can_play=False,
children_media_class=MediaClass.DIRECTORY,
media_class=MediaClass.DIRECTORY,
media_content_id="library",
media_content_type=f"{MEDIA_PLAYER_PREFIX}library",
title="Media Library",
)
browse_media.children = []
for item_type, item_name in LIBRARY_MAP.items():
browse_media.children.append(
item_payload(
{
"name": item_name,
"type": item_type,
"uri": item_type,
"id": None,
"thumbnail": None,
},
can_play_artist=can_play_artist,
)
)
return browse_media