Allow Sonos to browse and play local media via media browser (#64603)

pull/64683/head
Paulus Schoutsen 2022-01-21 13:49:06 -08:00 committed by GitHub
parent 7a2b699371
commit ed2e1f431c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
3 changed files with 201 additions and 104 deletions

View File

@ -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"
}

View File

@ -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",

View File

@ -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."""