diff --git a/homeassistant/components/sonos/manifest.json b/homeassistant/components/sonos/manifest.json index 8402240952d..b482556f287 100644 --- a/homeassistant/components/sonos/manifest.json +++ b/homeassistant/components/sonos/manifest.json @@ -5,7 +5,7 @@ "documentation": "https://www.home-assistant.io/integrations/sonos", "requirements": ["soco==0.26.0"], "dependencies": ["ssdp"], - "after_dependencies": ["plex", "zeroconf", "media_source"], + "after_dependencies": ["plex", "spotify", "zeroconf", "media_source"], "zeroconf": ["_sonos._tcp.local."], "ssdp": [ { diff --git a/homeassistant/components/sonos/media_browser.py b/homeassistant/components/sonos/media_browser.py index 7bbfefc8aca..e999121ecc2 100644 --- a/homeassistant/components/sonos/media_browser.py +++ b/homeassistant/components/sonos/media_browser.py @@ -7,7 +7,7 @@ from functools import partial import logging from urllib.parse import quote_plus, unquote -from homeassistant.components import media_source +from homeassistant.components import media_source, spotify from homeassistant.components.media_player import BrowseMedia from homeassistant.components.media_player.const import ( MEDIA_CLASS_DIRECTORY, @@ -90,6 +90,14 @@ async def async_browse_media( hass, media_content_id, content_filter=media_source_filter ) + if spotify.is_spotify_media_type(media_content_type): + return await spotify.async_browse_media( + hass, media_content_type, media_content_id, can_play_artist=False + ) + + if media_content_type == "spotify": + return await spotify.async_browse_media(hass, None, None, can_play_artist=False) + if media_content_type == "library": return await hass.async_add_executor_job( library_payload, @@ -248,6 +256,19 @@ async def root_payload( ) ) + if "spotify" in hass.config.components: + children.append( + BrowseMedia( + title="Spotify", + media_class=MEDIA_CLASS_DIRECTORY, + media_content_id="", + media_content_type="spotify", + thumbnail="https://brands.home-assistant.io/spotify/icon.png", + can_play=False, + can_expand=True, + ) + ) + if await hass.async_add_executor_job( partial(media.library.browse_by_idstring, "tracks", "", max_items=1) ): diff --git a/homeassistant/components/sonos/media_player.py b/homeassistant/components/sonos/media_player.py index 83790244e56..07fcfb9a3f4 100644 --- a/homeassistant/components/sonos/media_player.py +++ b/homeassistant/components/sonos/media_player.py @@ -18,7 +18,7 @@ from soco.core import ( from soco.data_structures import DidlFavorite import voluptuous as vol -from homeassistant.components import media_source +from homeassistant.components import media_source, spotify from homeassistant.components.http.auth import async_sign_path from homeassistant.components.media_player import MediaPlayerEntity from homeassistant.components.media_player.const import ( @@ -520,6 +520,9 @@ class SonosMediaPlayerEntity(SonosEntity, MediaPlayerEntity): If ATTR_MEDIA_ENQUEUE is True, add `media_id` to the queue. """ + if spotify.is_spotify_media_type(media_type): + media_type = spotify.resolve_spotify_media_type(media_type) + if media_source.is_media_source_id(media_id): media_type = MEDIA_TYPE_MUSIC media_id = ( diff --git a/homeassistant/components/spotify/__init__.py b/homeassistant/components/spotify/__init__.py index d251c35c3ec..5393e0e8eb9 100644 --- a/homeassistant/components/spotify/__init__.py +++ b/homeassistant/components/spotify/__init__.py @@ -26,8 +26,10 @@ from .const import ( DATA_SPOTIFY_ME, DATA_SPOTIFY_SESSION, DOMAIN, + MEDIA_PLAYER_PREFIX, SPOTIFY_SCOPES, ) +from .media_player import async_browse_media_internal CONFIG_SCHEMA = vol.Schema( { @@ -44,6 +46,31 @@ CONFIG_SCHEMA = vol.Schema( PLATFORMS = [Platform.MEDIA_PLAYER] +def is_spotify_media_type(media_content_type): + """Return whether the media_content_type is a valid Spotify media_id.""" + return media_content_type.startswith(MEDIA_PLAYER_PREFIX) + + +def resolve_spotify_media_type(media_content_type): + """Return actual spotify media_content_type.""" + return media_content_type[len(MEDIA_PLAYER_PREFIX) :] + + +async def async_browse_media( + hass, media_content_type, media_content_id, *, can_play_artist=True +): + """Browse Spotify media.""" + info = list(hass.data[DOMAIN].values())[0] + return await async_browse_media_internal( + hass, + info[DATA_SPOTIFY_CLIENT], + info[DATA_SPOTIFY_ME], + media_content_type, + media_content_id, + can_play_artist=can_play_artist, + ) + + async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the Spotify integration.""" if DOMAIN not in config: diff --git a/homeassistant/components/spotify/const.py b/homeassistant/components/spotify/const.py index 6b677aca996..7978ac8712f 100644 --- a/homeassistant/components/spotify/const.py +++ b/homeassistant/components/spotify/const.py @@ -22,3 +22,5 @@ SPOTIFY_SCOPES = [ "user-read-recently-played", "user-follow-read", ] + +MEDIA_PLAYER_PREFIX = "spotify://" diff --git a/homeassistant/components/spotify/media_player.py b/homeassistant/components/spotify/media_player.py index 7f4865c21aa..c60c8891cf4 100644 --- a/homeassistant/components/spotify/media_player.py +++ b/homeassistant/components/spotify/media_player.py @@ -4,12 +4,14 @@ from __future__ import annotations from asyncio import run_coroutine_threadsafe import datetime as dt from datetime import timedelta +from functools import partial import logging import requests from spotipy import Spotify, SpotifyException from yarl import URL +from homeassistant.backports.enum import StrEnum from homeassistant.components.media_player import BrowseMedia, MediaPlayerEntity from homeassistant.components.media_player.const import ( MEDIA_CLASS_ALBUM, @@ -63,6 +65,7 @@ from .const import ( DATA_SPOTIFY_ME, DATA_SPOTIFY_SESSION, DOMAIN, + MEDIA_PLAYER_PREFIX, SPOTIFY_SCOPES, ) @@ -107,63 +110,86 @@ PLAYABLE_MEDIA_TYPES = [ MEDIA_TYPE_TRACK, ] + +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 = { - "current_user_playlists": "Playlists", - "current_user_followed_artists": "Artists", - "current_user_saved_albums": "Albums", - "current_user_saved_tracks": "Tracks", - "current_user_saved_shows": "Podcasts", - "current_user_recently_played": "Recently played", - "current_user_top_artists": "Top Artists", - "current_user_top_tracks": "Top Tracks", - "categories": "Categories", - "featured_playlists": "Featured Playlists", - "new_releases": "New Releases", + BrowsableMedia.CURRENT_USER_PLAYLISTS: "Playlists", + BrowsableMedia.CURRENT_USER_FOLLOWED_ARTISTS: "Artists", + BrowsableMedia.CURRENT_USER_SAVED_ALBUMS: "Albums", + BrowsableMedia.CURRENT_USER_SAVED_TRACKS: "Tracks", + BrowsableMedia.CURRENT_USER_SAVED_SHOWS: "Podcasts", + BrowsableMedia.CURRENT_USER_RECENTLY_PLAYED: "Recently played", + BrowsableMedia.CURRENT_USER_TOP_ARTISTS: "Top Artists", + BrowsableMedia.CURRENT_USER_TOP_TRACKS: "Top Tracks", + BrowsableMedia.CATEGORIES: "Categories", + BrowsableMedia.FEATURED_PLAYLISTS: "Featured Playlists", + BrowsableMedia.NEW_RELEASES: "New Releases", } CONTENT_TYPE_MEDIA_CLASS = { - "current_user_playlists": { + BrowsableMedia.CURRENT_USER_PLAYLISTS: { "parent": MEDIA_CLASS_DIRECTORY, "children": MEDIA_CLASS_PLAYLIST, }, - "current_user_followed_artists": { + BrowsableMedia.CURRENT_USER_FOLLOWED_ARTISTS: { "parent": MEDIA_CLASS_DIRECTORY, "children": MEDIA_CLASS_ARTIST, }, - "current_user_saved_albums": { + BrowsableMedia.CURRENT_USER_SAVED_ALBUMS: { "parent": MEDIA_CLASS_DIRECTORY, "children": MEDIA_CLASS_ALBUM, }, - "current_user_saved_tracks": { + BrowsableMedia.CURRENT_USER_SAVED_TRACKS: { "parent": MEDIA_CLASS_DIRECTORY, "children": MEDIA_CLASS_TRACK, }, - "current_user_saved_shows": { + BrowsableMedia.CURRENT_USER_SAVED_SHOWS: { "parent": MEDIA_CLASS_DIRECTORY, "children": MEDIA_CLASS_PODCAST, }, - "current_user_recently_played": { + BrowsableMedia.CURRENT_USER_RECENTLY_PLAYED: { "parent": MEDIA_CLASS_DIRECTORY, "children": MEDIA_CLASS_TRACK, }, - "current_user_top_artists": { + BrowsableMedia.CURRENT_USER_TOP_ARTISTS: { "parent": MEDIA_CLASS_DIRECTORY, "children": MEDIA_CLASS_ARTIST, }, - "current_user_top_tracks": { + BrowsableMedia.CURRENT_USER_TOP_TRACKS: { "parent": MEDIA_CLASS_DIRECTORY, "children": MEDIA_CLASS_TRACK, }, - "featured_playlists": { + BrowsableMedia.FEATURED_PLAYLISTS: { "parent": MEDIA_CLASS_DIRECTORY, "children": MEDIA_CLASS_PLAYLIST, }, - "categories": {"parent": MEDIA_CLASS_DIRECTORY, "children": MEDIA_CLASS_GENRE}, + BrowsableMedia.CATEGORIES: { + "parent": MEDIA_CLASS_DIRECTORY, + "children": MEDIA_CLASS_GENRE, + }, "category_playlists": { "parent": MEDIA_CLASS_DIRECTORY, "children": MEDIA_CLASS_PLAYLIST, }, - "new_releases": {"parent": MEDIA_CLASS_DIRECTORY, "children": MEDIA_CLASS_ALBUM}, + BrowsableMedia.NEW_RELEASES: { + "parent": MEDIA_CLASS_DIRECTORY, + "children": MEDIA_CLASS_ALBUM, + }, MEDIA_TYPE_PLAYLIST: { "parent": MEDIA_CLASS_PLAYLIST, "children": MEDIA_CLASS_TRACK, @@ -421,6 +447,9 @@ class SpotifyMediaPlayer(MediaPlayerEntity): @spotify_exception_handler def play_media(self, media_type: str, media_id: str, **kwargs) -> None: """Play media.""" + if media_type.startswith(MEDIA_PLAYER_PREFIX): + media_type = media_type[len(MEDIA_PLAYER_PREFIX) :] + kwargs = {} # Spotify can't handle URI's with query strings or anchors @@ -494,57 +523,81 @@ class SpotifyMediaPlayer(MediaPlayerEntity): ) raise NotImplementedError - if media_content_type in (None, "library"): - return await self.hass.async_add_executor_job(library_payload) - - payload = { - "media_content_type": media_content_type, - "media_content_id": media_content_id, - } - response = await self.hass.async_add_executor_job( - build_item_response, self._spotify, self._me, payload + return await async_browse_media_internal( + self.hass, self._spotify, self._me, media_content_type, media_content_id ) - if response is None: - raise BrowseError( - f"Media not found: {media_content_type} / {media_content_id}" - ) - return response -def build_item_response(spotify, user, payload): # noqa: C901 +async def async_browse_media_internal( + hass, + spotify, + current_user, + media_content_type, + media_content_id, + *, + can_play_artist=True, +): + """Browse spotify media.""" + if media_content_type in (None, f"{MEDIA_PLAYER_PREFIX}library"): + return await hass.async_add_executor_job( + partial(library_payload, can_play_artist=can_play_artist) + ) + + # Strip prefix + media_content_type = media_content_type[len(MEDIA_PLAYER_PREFIX) :] + + payload = { + "media_content_type": media_content_type, + "media_content_id": media_content_id, + } + response = await hass.async_add_executor_job( + partial( + build_item_response, + spotify, + current_user, + 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 + + +def build_item_response(spotify, user, payload, *, can_play_artist): # noqa: C901 """Create response payload for the provided media query.""" media_content_type = payload["media_content_type"] media_content_id = payload["media_content_id"] title = None image = None - if media_content_type == "current_user_playlists": + if media_content_type == BrowsableMedia.CURRENT_USER_PLAYLISTS: media = spotify.current_user_playlists(limit=BROWSE_LIMIT) items = media.get("items", []) - elif media_content_type == "current_user_followed_artists": + elif media_content_type == BrowsableMedia.CURRENT_USER_FOLLOWED_ARTISTS: media = spotify.current_user_followed_artists(limit=BROWSE_LIMIT) items = media.get("artists", {}).get("items", []) - elif media_content_type == "current_user_saved_albums": + elif media_content_type == BrowsableMedia.CURRENT_USER_SAVED_ALBUMS: media = spotify.current_user_saved_albums(limit=BROWSE_LIMIT) items = [item["album"] for item in media.get("items", [])] - elif media_content_type == "current_user_saved_tracks": + elif media_content_type == BrowsableMedia.CURRENT_USER_SAVED_TRACKS: media = spotify.current_user_saved_tracks(limit=BROWSE_LIMIT) items = [item["track"] for item in media.get("items", [])] - elif media_content_type == "current_user_saved_shows": + elif media_content_type == BrowsableMedia.CURRENT_USER_SAVED_SHOWS: media = spotify.current_user_saved_shows(limit=BROWSE_LIMIT) items = [item["show"] for item in media.get("items", [])] - elif media_content_type == "current_user_recently_played": + elif media_content_type == BrowsableMedia.CURRENT_USER_RECENTLY_PLAYED: media = spotify.current_user_recently_played(limit=BROWSE_LIMIT) items = [item["track"] for item in media.get("items", [])] - elif media_content_type == "current_user_top_artists": + elif media_content_type == BrowsableMedia.CURRENT_USER_TOP_ARTISTS: media = spotify.current_user_top_artists(limit=BROWSE_LIMIT) items = media.get("items", []) - elif media_content_type == "current_user_top_tracks": + elif media_content_type == BrowsableMedia.CURRENT_USER_TOP_TRACKS: media = spotify.current_user_top_tracks(limit=BROWSE_LIMIT) items = media.get("items", []) - elif media_content_type == "featured_playlists": + elif media_content_type == BrowsableMedia.FEATURED_PLAYLISTS: media = spotify.featured_playlists(country=user["country"], limit=BROWSE_LIMIT) items = media.get("playlists", {}).get("items", []) - elif media_content_type == "categories": + elif media_content_type == BrowsableMedia.CATEGORIES: media = spotify.categories(country=user["country"], limit=BROWSE_LIMIT) items = media.get("categories", {}).get("items", []) elif media_content_type == "category_playlists": @@ -557,7 +610,7 @@ def build_item_response(spotify, user, payload): # noqa: C901 title = category.get("name") image = fetch_image_url(category, key="icons") items = media.get("playlists", {}).get("items", []) - elif media_content_type == "new_releases": + elif media_content_type == BrowsableMedia.NEW_RELEASES: media = spotify.new_releases(country=user["country"], limit=BROWSE_LIMIT) items = media.get("albums", {}).get("items", []) elif media_content_type == MEDIA_TYPE_PLAYLIST: @@ -591,13 +644,13 @@ def build_item_response(spotify, user, payload): # noqa: C901 _LOGGER.debug("Unknown media type received: %s", media_content_type) return None - if media_content_type == "categories": + if media_content_type == BrowsableMedia.CATEGORIES: media_item = BrowseMedia( title=LIBRARY_MAP.get(media_content_id), media_class=media_class["parent"], children_media_class=media_class["children"], media_content_id=media_content_id, - media_content_type=media_content_type, + media_content_type=MEDIA_PLAYER_PREFIX + media_content_type, can_play=False, can_expand=True, children=[], @@ -614,7 +667,7 @@ def build_item_response(spotify, user, payload): # noqa: C901 media_class=MEDIA_CLASS_PLAYLIST, children_media_class=MEDIA_CLASS_TRACK, media_content_id=item_id, - media_content_type="category_playlists", + media_content_type=MEDIA_PLAYER_PREFIX + "category_playlists", thumbnail=fetch_image_url(item, key="icons"), can_play=False, can_expand=True, @@ -633,14 +686,17 @@ def build_item_response(spotify, user, payload): # noqa: C901 "media_class": media_class["parent"], "children_media_class": media_class["children"], "media_content_id": media_content_id, - "media_content_type": media_content_type, - "can_play": media_content_type in PLAYABLE_MEDIA_TYPES, + "media_content_type": MEDIA_PLAYER_PREFIX + media_content_type, + "can_play": media_content_type in PLAYABLE_MEDIA_TYPES + and (media_content_type != MEDIA_TYPE_ARTIST or can_play_artist), "children": [], "can_expand": True, } for item in items: try: - params["children"].append(item_payload(item)) + params["children"].append( + item_payload(item, can_play_artist=can_play_artist) + ) except (MissingMediaInformation, UnknownMediaType): continue @@ -652,7 +708,7 @@ def build_item_response(spotify, user, payload): # noqa: C901 return BrowseMedia(**params) -def item_payload(item): +def item_payload(item, *, can_play_artist): """ Create response payload for a single media item. @@ -681,8 +737,9 @@ def item_payload(item): "media_class": media_class["parent"], "children_media_class": media_class["children"], "media_content_id": media_id, - "media_content_type": media_type, - "can_play": media_type in PLAYABLE_MEDIA_TYPES, + "media_content_type": MEDIA_PLAYER_PREFIX + media_type, + "can_play": media_type in PLAYABLE_MEDIA_TYPES + and (media_type != MEDIA_TYPE_ARTIST or can_play_artist), "can_expand": can_expand, } @@ -694,7 +751,7 @@ def item_payload(item): return BrowseMedia(**payload) -def library_payload(): +def library_payload(*, can_play_artist): """ Create response payload to describe contents of a specific library. @@ -704,7 +761,7 @@ def library_payload(): "title": "Media Library", "media_class": MEDIA_CLASS_DIRECTORY, "media_content_id": "library", - "media_content_type": "library", + "media_content_type": MEDIA_PLAYER_PREFIX + "library", "can_play": False, "can_expand": True, "children": [], @@ -713,7 +770,8 @@ def library_payload(): for item in [{"name": n, "type": t} for t, n in LIBRARY_MAP.items()]: library_info["children"].append( item_payload( - {"name": item["name"], "type": item["type"], "uri": item["type"]} + {"name": item["name"], "type": item["type"], "uri": item["type"]}, + can_play_artist=can_play_artist, ) ) response = BrowseMedia(**library_info)