From ed2e1f431cbf627077b1977489ab973af430aac7 Mon Sep 17 00:00:00 2001
From: Paulus Schoutsen <balloob@gmail.com>
Date: Fri, 21 Jan 2022 13:49:06 -0800
Subject: [PATCH] Allow Sonos to browse and play local media via media browser
 (#64603)

---
 homeassistant/components/sonos/manifest.json  |   7 +-
 .../components/sonos/media_browser.py         | 197 ++++++++++++++----
 .../components/sonos/media_player.py          | 101 ++++-----
 3 files changed, 201 insertions(+), 104 deletions(-)

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