diff --git a/homeassistant/components/sonos/manifest.json b/homeassistant/components/sonos/manifest.json index b943013d4bc..72d0e2e3b02 100644 --- a/homeassistant/components/sonos/manifest.json +++ b/homeassistant/components/sonos/manifest.json @@ -5,16 +5,13 @@ "documentation": "https://www.home-assistant.io/integrations/sonos", "requirements": ["soco==0.25.3"], "dependencies": ["ssdp"], - "after_dependencies": ["plex", "zeroconf"], + "after_dependencies": ["plex", "zeroconf", "media_source"], "zeroconf": ["_sonos._tcp.local."], "ssdp": [ { "st": "urn:schemas-upnp-org:device:ZonePlayer:1" } ], - "codeowners": [ - "@cgtobi", - "@jjlawren" - ], + "codeowners": ["@cgtobi", "@jjlawren"], "iot_class": "local_push" } diff --git a/homeassistant/components/sonos/media_browser.py b/homeassistant/components/sonos/media_browser.py index 9a07b13c25d..7bbfefc8aca 100644 --- a/homeassistant/components/sonos/media_browser.py +++ b/homeassistant/components/sonos/media_browser.py @@ -1,14 +1,21 @@ """Support for media browsing.""" -from contextlib import suppress -import logging -import urllib.parse +from __future__ import annotations +from collections.abc import Callable +from contextlib import suppress +from functools import partial +import logging +from urllib.parse import quote_plus, unquote + +from homeassistant.components import media_source from homeassistant.components.media_player import BrowseMedia from homeassistant.components.media_player.const import ( MEDIA_CLASS_DIRECTORY, MEDIA_TYPE_ALBUM, ) from homeassistant.components.media_player.errors import BrowseError +from homeassistant.core import HomeAssistant +from homeassistant.helpers.network import is_internal_request from .const import ( EXPANDABLE_MEDIA_TYPES, @@ -24,9 +31,109 @@ from .const import ( SONOS_TYPES_MAPPING, ) from .exception import UnknownMediaType +from .speaker import SonosMedia, SonosSpeaker _LOGGER = logging.getLogger(__name__) +GetBrowseImageUrlType = Callable[[str, str, "str | None"], str] + + +def get_thumbnail_url_full( + media: SonosMedia, + is_internal: bool, + get_browse_image_url: GetBrowseImageUrlType, + media_content_type: str, + media_content_id: str, + media_image_id: str | None = None, +) -> str | None: + """Get thumbnail URL.""" + if is_internal: + item = get_media( # type: ignore[no-untyped-call] + media.library, + media_content_id, + media_content_type, + ) + return getattr(item, "album_art_uri", None) # type: ignore[no-any-return] + + return get_browse_image_url( + media_content_type, + quote_plus(media_content_id), + media_image_id, + ) + + +def media_source_filter(item: BrowseMedia): + """Filter media sources.""" + return item.media_content_type.startswith("audio/") + + +async def async_browse_media( + hass, + speaker: SonosSpeaker, + media: SonosMedia, + get_browse_image_url: GetBrowseImageUrlType, + media_content_id: str | None, + media_content_type: str | None, +): + """Browse media.""" + + if media_content_id is None: + return await root_payload( + hass, + speaker, + media, + get_browse_image_url, + ) + + if media_source.is_media_source_id(media_content_id): + return await media_source.async_browse_media( + hass, media_content_id, content_filter=media_source_filter + ) + + if media_content_type == "library": + return await hass.async_add_executor_job( + library_payload, + media.library, + partial( + get_thumbnail_url_full, + media, + is_internal_request(hass), + get_browse_image_url, + ), + ) + + if media_content_type == "favorites": + return await hass.async_add_executor_job( + favorites_payload, + speaker.favorites, + ) + + if media_content_type == "favorites_folder": + return await hass.async_add_executor_job( + favorites_folder_payload, + speaker.favorites, + media_content_id, + ) + + payload = { + "search_type": media_content_type, + "idstring": media_content_id, + } + response = await hass.async_add_executor_job( + build_item_response, + media.library, + payload, + partial( + get_thumbnail_url_full, + media, + is_internal_request(hass), + get_browse_image_url, + ), + ) + if response is None: + raise BrowseError(f"Media not found: {media_content_type} / {media_content_id}") + return response + def build_item_response(media_library, payload, get_thumbnail_url=None): """Create response payload for the provided media query.""" @@ -62,7 +169,7 @@ def build_item_response(media_library, payload, get_thumbnail_url=None): if not title: try: - title = urllib.parse.unquote(payload["idstring"].split("/")[1]) + title = unquote(payload["idstring"].split("/")[1]) except IndexError: title = LIBRARY_TITLES_MAPPING[payload["idstring"]] @@ -120,42 +227,62 @@ def item_payload(item, get_thumbnail_url=None): ) -def root_payload(media_library, favorites, get_thumbnail_url): +async def root_payload( + hass: HomeAssistant, + speaker: SonosSpeaker, + media: SonosMedia, + get_browse_image_url: GetBrowseImageUrlType, +): """Return root payload for Sonos.""" - has_local_library = bool( - media_library.browse_by_idstring( - "tracks", - "", - max_items=1, + children = [] + + if speaker.favorites: + children.append( + BrowseMedia( + title="Favorites", + media_class=MEDIA_CLASS_DIRECTORY, + media_content_id="", + media_content_type="favorites", + can_play=False, + can_expand=True, + ) ) - ) - if not (favorites or has_local_library): - raise BrowseError("No media available") + if await hass.async_add_executor_job( + partial(media.library.browse_by_idstring, "tracks", "", max_items=1) + ): + children.append( + BrowseMedia( + title="Music Library", + media_class=MEDIA_CLASS_DIRECTORY, + media_content_id="", + media_content_type="library", + can_play=False, + can_expand=True, + ) + ) - if not has_local_library: - return favorites_payload(favorites) - if not favorites: - return library_payload(media_library, get_thumbnail_url) + try: + item = await media_source.async_browse_media( + hass, None, content_filter=media_source_filter + ) + # If domain is None, it's overview of available sources + if item.domain is None: + children.extend(item.children) + else: + children.append(item) + except media_source.BrowseError: + pass - children = [ - BrowseMedia( - title="Favorites", - media_class=MEDIA_CLASS_DIRECTORY, - media_content_id="", - media_content_type="favorites", - can_play=False, - can_expand=True, - ), - BrowseMedia( - title="Music Library", - media_class=MEDIA_CLASS_DIRECTORY, - media_content_id="", - media_content_type="library", - can_play=False, - can_expand=True, - ), - ] + if len(children) == 1: + return await async_browse_media( + hass, + speaker, + media, + get_browse_image_url, + children[0].media_content_id, + children[0].media_content_type, + ) return BrowseMedia( title="Sonos", diff --git a/homeassistant/components/sonos/media_player.py b/homeassistant/components/sonos/media_player.py index 49cbe5ae165..850155c0d6c 100644 --- a/homeassistant/components/sonos/media_player.py +++ b/homeassistant/components/sonos/media_player.py @@ -1,10 +1,12 @@ """Support to interface with Sonos players.""" from __future__ import annotations +from asyncio import run_coroutine_threadsafe import datetime +from datetime import timedelta import logging from typing import Any -import urllib.parse +from urllib.parse import quote from soco import alarms from soco.core import ( @@ -16,6 +18,8 @@ from soco.core import ( from soco.data_structures import DidlFavorite import voluptuous as vol +from homeassistant.components import media_source +from homeassistant.components.http.auth import async_sign_path from homeassistant.components.media_player import MediaPlayerEntity from homeassistant.components.media_player.const import ( ATTR_MEDIA_ENQUEUE, @@ -43,7 +47,6 @@ from homeassistant.components.media_player.const import ( SUPPORT_VOLUME_MUTE, SUPPORT_VOLUME_SET, ) -from homeassistant.components.media_player.errors import BrowseError from homeassistant.components.plex.const import PLEX_URI_SCHEME from homeassistant.components.plex.services import play_on_sonos from homeassistant.config_entries import ConfigEntry @@ -53,7 +56,7 @@ from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import config_validation as cv, entity_platform, service from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.network import is_internal_request +from homeassistant.helpers.network import get_url from . import media_browser from .const import ( @@ -517,6 +520,17 @@ class SonosMediaPlayerEntity(SonosEntity, MediaPlayerEntity): If ATTR_MEDIA_ENQUEUE is True, add `media_id` to the queue. """ + if media_source.is_media_source_id(media_id): + media_type = MEDIA_TYPE_MUSIC + media_id = ( + run_coroutine_threadsafe( + media_source.async_resolve_media(self.hass, media_id), + self.hass.loop, + ) + .result() + .url + ) + if media_type == "favorite_item_id": favorite = self.speaker.favorites.lookup_by_item_id(media_id) if favorite is None: @@ -539,6 +553,19 @@ class SonosMediaPlayerEntity(SonosEntity, MediaPlayerEntity): share_link.add_share_link_to_queue(media_id) soco.play_from_queue(0) elif media_type in (MEDIA_TYPE_MUSIC, MEDIA_TYPE_TRACK): + # If media ID is a relative URL, we serve it from HA. + # Create a signed path. + if media_id[0] == "/": + media_id = async_sign_path( + self.hass, + quote(media_id), + timedelta(seconds=media_source.DEFAULT_EXPIRY_TIME), + ) + + # prepend external URL + hass_url = get_url(self.hass, prefer_external=True) + media_id = f"{hass_url}{media_id}" + if kwargs.get(ATTR_MEDIA_ENQUEUE): soco.add_uri_to_queue(media_id) else: @@ -654,68 +681,14 @@ class SonosMediaPlayerEntity(SonosEntity, MediaPlayerEntity): self, media_content_type: str | None = None, media_content_id: str | None = None ) -> Any: """Implement the websocket media browsing helper.""" - is_internal = is_internal_request(self.hass) - - def _get_thumbnail_url( - media_content_type: str, - media_content_id: str, - media_image_id: str | None = None, - ) -> str | None: - if is_internal: - item = media_browser.get_media( # type: ignore[no-untyped-call] - self.media.library, - media_content_id, - media_content_type, - ) - return getattr(item, "album_art_uri", None) # type: ignore[no-any-return] - - return self.get_browse_image_url( - media_content_type, - urllib.parse.quote_plus(media_content_id), - media_image_id, - ) - - if media_content_type in [None, "root"]: - return await self.hass.async_add_executor_job( - media_browser.root_payload, - self.media.library, - self.speaker.favorites, - _get_thumbnail_url, - ) - - if media_content_type == "library": - return await self.hass.async_add_executor_job( - media_browser.library_payload, self.media.library, _get_thumbnail_url - ) - - if media_content_type == "favorites": - return await self.hass.async_add_executor_job( - media_browser.favorites_payload, - self.speaker.favorites, - ) - - if media_content_type == "favorites_folder": - return await self.hass.async_add_executor_job( - media_browser.favorites_folder_payload, - self.speaker.favorites, - media_content_id, - ) - - payload = { - "search_type": media_content_type, - "idstring": media_content_id, - } - response = await self.hass.async_add_executor_job( - media_browser.build_item_response, - self.media.library, - payload, - _get_thumbnail_url, + return await media_browser.async_browse_media( + self.hass, + self.speaker, + self.media, + self.get_browse_image_url, + media_content_id, + media_content_type, ) - if response is None: - raise BrowseError( - f"Media not found: {media_content_type} / {media_content_id}" - ) - return response def join_players(self, group_members): """Join `group_members` as a player group with the current player."""