mirror of
https://github.com/home-assistant/core.git
synced 2025-04-25 01:38:02 +00:00
Allow browsing the Spotify media player in Sonos (#64921)
This commit is contained in:
parent
f32d9952c8
commit
a371f8f788
@ -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": [
|
||||
{
|
||||
|
@ -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)
|
||||
):
|
||||
|
@ -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 = (
|
||||
|
@ -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:
|
||||
|
@ -22,3 +22,5 @@ SPOTIFY_SCOPES = [
|
||||
"user-read-recently-played",
|
||||
"user-follow-read",
|
||||
]
|
||||
|
||||
MEDIA_PLAYER_PREFIX = "spotify://"
|
||||
|
@ -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)
|
||||
|
Loading…
x
Reference in New Issue
Block a user