Allow browsing the Spotify media player in Sonos (#64921)

This commit is contained in:
Paulus Schoutsen 2022-01-25 12:43:43 -08:00 committed by GitHub
parent f32d9952c8
commit a371f8f788
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
6 changed files with 174 additions and 63 deletions

View File

@ -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": [
{

View File

@ -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)
):

View File

@ -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 = (

View File

@ -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:

View File

@ -22,3 +22,5 @@ SPOTIFY_SCOPES = [
"user-read-recently-played",
"user-follow-read",
]
MEDIA_PLAYER_PREFIX = "spotify://"

View File

@ -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)