Allow Sonos to browse and play local media via media browser (#64603)
parent
7a2b699371
commit
ed2e1f431c
|
@ -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"
|
||||
}
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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."""
|
||||
|
|
Loading…
Reference in New Issue