mirror of https://github.com/home-assistant/core
559 lines
19 KiB
Python
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
|