mirror of
https://github.com/home-assistant/core.git
synced 2025-07-22 20:57:21 +00:00
Allow Sonos to browse and play local media via media browser (#64603)
This commit is contained in:
parent
7a2b699371
commit
ed2e1f431c
@ -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"
|
||||||
}
|
}
|
||||||
|
@ -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,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."""
|
"""Return root payload for Sonos."""
|
||||||
has_local_library = bool(
|
children = []
|
||||||
media_library.browse_by_idstring(
|
|
||||||
"tracks",
|
if speaker.favorites:
|
||||||
"",
|
children.append(
|
||||||
max_items=1,
|
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):
|
if await hass.async_add_executor_job(
|
||||||
raise BrowseError("No media available")
|
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:
|
try:
|
||||||
return favorites_payload(favorites)
|
item = await media_source.async_browse_media(
|
||||||
if not favorites:
|
hass, None, content_filter=media_source_filter
|
||||||
return library_payload(media_library, get_thumbnail_url)
|
)
|
||||||
|
# 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 = [
|
if len(children) == 1:
|
||||||
BrowseMedia(
|
return await async_browse_media(
|
||||||
title="Favorites",
|
hass,
|
||||||
media_class=MEDIA_CLASS_DIRECTORY,
|
speaker,
|
||||||
media_content_id="",
|
media,
|
||||||
media_content_type="favorites",
|
get_browse_image_url,
|
||||||
can_play=False,
|
children[0].media_content_id,
|
||||||
can_expand=True,
|
children[0].media_content_type,
|
||||||
),
|
)
|
||||||
BrowseMedia(
|
|
||||||
title="Music Library",
|
|
||||||
media_class=MEDIA_CLASS_DIRECTORY,
|
|
||||||
media_content_id="",
|
|
||||||
media_content_type="library",
|
|
||||||
can_play=False,
|
|
||||||
can_expand=True,
|
|
||||||
),
|
|
||||||
]
|
|
||||||
|
|
||||||
return BrowseMedia(
|
return BrowseMedia(
|
||||||
title="Sonos",
|
title="Sonos",
|
||||||
|
@ -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,
|
media_content_id,
|
||||||
) -> str | None:
|
media_content_type,
|
||||||
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,
|
|
||||||
)
|
)
|
||||||
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."""
|
||||||
|
Loading…
x
Reference in New Issue
Block a user