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

This commit is contained in:
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", "documentation": "https://www.home-assistant.io/integrations/sonos",
"requirements": ["soco==0.25.3"], "requirements": ["soco==0.25.3"],
"dependencies": ["ssdp"], "dependencies": ["ssdp"],
"after_dependencies": ["plex", "zeroconf"], "after_dependencies": ["plex", "zeroconf", "media_source"],
"zeroconf": ["_sonos._tcp.local."], "zeroconf": ["_sonos._tcp.local."],
"ssdp": [ "ssdp": [
{ {
"st": "urn:schemas-upnp-org:device:ZonePlayer:1" "st": "urn:schemas-upnp-org:device:ZonePlayer:1"
} }
], ],
"codeowners": [ "codeowners": ["@cgtobi", "@jjlawren"],
"@cgtobi",
"@jjlawren"
],
"iot_class": "local_push" "iot_class": "local_push"
} }

View File

@ -1,14 +1,21 @@
"""Support for media browsing.""" """Support for media browsing."""
from contextlib import suppress from __future__ import annotations
import logging
import urllib.parse
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 import BrowseMedia
from homeassistant.components.media_player.const import ( from homeassistant.components.media_player.const import (
MEDIA_CLASS_DIRECTORY, MEDIA_CLASS_DIRECTORY,
MEDIA_TYPE_ALBUM, MEDIA_TYPE_ALBUM,
) )
from homeassistant.components.media_player.errors import BrowseError from homeassistant.components.media_player.errors import BrowseError
from homeassistant.core import HomeAssistant
from homeassistant.helpers.network import is_internal_request
from .const import ( from .const import (
EXPANDABLE_MEDIA_TYPES, EXPANDABLE_MEDIA_TYPES,
@ -24,9 +31,109 @@ from .const import (
SONOS_TYPES_MAPPING, SONOS_TYPES_MAPPING,
) )
from .exception import UnknownMediaType from .exception import UnknownMediaType
from .speaker import SonosMedia, SonosSpeaker
_LOGGER = logging.getLogger(__name__) _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): def build_item_response(media_library, payload, get_thumbnail_url=None):
"""Create response payload for the provided media query.""" """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: if not title:
try: try:
title = urllib.parse.unquote(payload["idstring"].split("/")[1]) title = unquote(payload["idstring"].split("/")[1])
except IndexError: except IndexError:
title = LIBRARY_TITLES_MAPPING[payload["idstring"]] title = LIBRARY_TITLES_MAPPING[payload["idstring"]]
@ -120,25 +227,17 @@ 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.""" """Return root payload for Sonos."""
has_local_library = bool( children = []
media_library.browse_by_idstring(
"tracks",
"",
max_items=1,
)
)
if not (favorites or has_local_library): if speaker.favorites:
raise BrowseError("No media available") children.append(
if not has_local_library:
return favorites_payload(favorites)
if not favorites:
return library_payload(media_library, get_thumbnail_url)
children = [
BrowseMedia( BrowseMedia(
title="Favorites", title="Favorites",
media_class=MEDIA_CLASS_DIRECTORY, media_class=MEDIA_CLASS_DIRECTORY,
@ -146,7 +245,13 @@ def root_payload(media_library, favorites, get_thumbnail_url):
media_content_type="favorites", media_content_type="favorites",
can_play=False, can_play=False,
can_expand=True, can_expand=True,
), )
)
if await hass.async_add_executor_job(
partial(media.library.browse_by_idstring, "tracks", "", max_items=1)
):
children.append(
BrowseMedia( BrowseMedia(
title="Music Library", title="Music Library",
media_class=MEDIA_CLASS_DIRECTORY, media_class=MEDIA_CLASS_DIRECTORY,
@ -154,8 +259,30 @@ def root_payload(media_library, favorites, get_thumbnail_url):
media_content_type="library", media_content_type="library",
can_play=False, can_play=False,
can_expand=True, can_expand=True,
), )
] )
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
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( return BrowseMedia(
title="Sonos", title="Sonos",

View File

@ -1,10 +1,12 @@
"""Support to interface with Sonos players.""" """Support to interface with Sonos players."""
from __future__ import annotations from __future__ import annotations
from asyncio import run_coroutine_threadsafe
import datetime import datetime
from datetime import timedelta
import logging import logging
from typing import Any from typing import Any
import urllib.parse from urllib.parse import quote
from soco import alarms from soco import alarms
from soco.core import ( from soco.core import (
@ -16,6 +18,8 @@ from soco.core import (
from soco.data_structures import DidlFavorite from soco.data_structures import DidlFavorite
import voluptuous as vol 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 import MediaPlayerEntity
from homeassistant.components.media_player.const import ( from homeassistant.components.media_player.const import (
ATTR_MEDIA_ENQUEUE, ATTR_MEDIA_ENQUEUE,
@ -43,7 +47,6 @@ from homeassistant.components.media_player.const import (
SUPPORT_VOLUME_MUTE, SUPPORT_VOLUME_MUTE,
SUPPORT_VOLUME_SET, SUPPORT_VOLUME_SET,
) )
from homeassistant.components.media_player.errors import BrowseError
from homeassistant.components.plex.const import PLEX_URI_SCHEME from homeassistant.components.plex.const import PLEX_URI_SCHEME
from homeassistant.components.plex.services import play_on_sonos from homeassistant.components.plex.services import play_on_sonos
from homeassistant.config_entries import ConfigEntry 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 import config_validation as cv, entity_platform, service
from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity_platform import AddEntitiesCallback 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 . import media_browser
from .const import ( from .const import (
@ -517,6 +520,17 @@ class SonosMediaPlayerEntity(SonosEntity, MediaPlayerEntity):
If ATTR_MEDIA_ENQUEUE is True, add `media_id` to the queue. 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": if media_type == "favorite_item_id":
favorite = self.speaker.favorites.lookup_by_item_id(media_id) favorite = self.speaker.favorites.lookup_by_item_id(media_id)
if favorite is None: if favorite is None:
@ -539,6 +553,19 @@ class SonosMediaPlayerEntity(SonosEntity, MediaPlayerEntity):
share_link.add_share_link_to_queue(media_id) share_link.add_share_link_to_queue(media_id)
soco.play_from_queue(0) soco.play_from_queue(0)
elif media_type in (MEDIA_TYPE_MUSIC, MEDIA_TYPE_TRACK): 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): if kwargs.get(ATTR_MEDIA_ENQUEUE):
soco.add_uri_to_queue(media_id) soco.add_uri_to_queue(media_id)
else: else:
@ -654,68 +681,14 @@ class SonosMediaPlayerEntity(SonosEntity, MediaPlayerEntity):
self, media_content_type: str | None = None, media_content_id: str | None = None self, media_content_type: str | None = None, media_content_id: str | None = None
) -> Any: ) -> Any:
"""Implement the websocket media browsing helper.""" """Implement the websocket media browsing helper."""
is_internal = is_internal_request(self.hass) return await media_browser.async_browse_media(
self.hass,
def _get_thumbnail_url( self.speaker,
media_content_type: str, self.media,
media_content_id: str, self.get_browse_image_url,
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_id,
media_content_type, 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,
)
if response is None:
raise BrowseError(
f"Media not found: {media_content_type} / {media_content_id}"
)
return response
def join_players(self, group_members): def join_players(self, group_members):
"""Join `group_members` as a player group with the current player.""" """Join `group_members` as a player group with the current player."""