From 3bce870c6dec3becc6ea84d5c2abe02563d56e27 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Wed, 9 Feb 2022 12:50:33 -0800 Subject: [PATCH] Add helper for media players to handle HA hosted media (#66120) * Sonos to sign all HASS urls * Don't sign if queries in url * Extract media player hass URL handling to helper --- homeassistant/components/cast/media_player.py | 25 +--- .../components/media_player/__init__.py | 76 +----------- .../components/media_player/browse_media.py | 112 ++++++++++++++++++ .../components/media_player/const.py | 2 + .../components/media_source/__init__.py | 5 +- homeassistant/components/roku/media_player.py | 23 +--- .../components/sonos/media_player.py | 20 +--- .../components/vlc_telnet/media_player.py | 30 ++--- .../media_player/test_browse_media.py | 60 ++++++++++ 9 files changed, 201 insertions(+), 152 deletions(-) create mode 100644 homeassistant/components/media_player/browse_media.py create mode 100644 tests/components/media_player/test_browse_media.py diff --git a/homeassistant/components/cast/media_player.py b/homeassistant/components/cast/media_player.py index 1354c5c00fb..29fa38fbdc6 100644 --- a/homeassistant/components/cast/media_player.py +++ b/homeassistant/components/cast/media_player.py @@ -3,10 +3,9 @@ from __future__ import annotations import asyncio from contextlib import suppress -from datetime import datetime, timedelta +from datetime import datetime import json import logging -from urllib.parse import quote import pychromecast from pychromecast.controllers.homeassistant import HomeAssistantController @@ -21,11 +20,11 @@ import voluptuous as vol import yarl from homeassistant.components import media_source, zeroconf -from homeassistant.components.http.auth import async_sign_path from homeassistant.components.media_player import ( BrowseError, BrowseMedia, MediaPlayerEntity, + async_process_play_media_url, ) from homeassistant.components.media_player.const import ( ATTR_MEDIA_EXTRA, @@ -582,10 +581,11 @@ class CastDevice(MediaPlayerEntity): return # If media ID is a relative URL, we serve it from HA. - # Create a signed path. - if media_id[0] == "/" or is_hass_url(self.hass, media_id): + media_id = async_process_play_media_url(self.hass, media_id) + + # Configure play command for when playing a HLS stream + if is_hass_url(self.hass, media_id): parsed = yarl.URL(media_id) - # Configure play command for when playing a HLS stream if parsed.path.startswith("/api/hls/"): extra = { **extra, @@ -595,19 +595,6 @@ class CastDevice(MediaPlayerEntity): }, } - if parsed.query: - _LOGGER.debug("Not signing path for content with query param") - else: - media_id = async_sign_path( - self.hass, - quote(media_id), - timedelta(seconds=media_source.DEFAULT_EXPIRY_TIME), - ) - - if media_id[0] == "/": - # prepend URL - media_id = f"{get_url(self.hass)}{media_id}" - # Default to play with the default media receiver app_data = {"media_id": media_id, "media_type": media_type, **extra} await self.hass.async_add_executor_job( diff --git a/homeassistant/components/media_player/__init__.py b/homeassistant/components/media_player/__init__.py index 587c75dd035..2de42c05dde 100644 --- a/homeassistant/components/media_player/__init__.py +++ b/homeassistant/components/media_player/__init__.py @@ -59,7 +59,6 @@ import homeassistant.helpers.config_validation as cv from homeassistant.helpers.config_validation import ( # noqa: F401 PLATFORM_SCHEMA, PLATFORM_SCHEMA_BASE, - datetime, ) from homeassistant.helpers.entity import Entity, EntityDescription from homeassistant.helpers.entity_component import EntityComponent @@ -67,7 +66,8 @@ from homeassistant.helpers.network import get_url from homeassistant.helpers.typing import ConfigType from homeassistant.loader import bind_hass -from .const import ( +from .browse_media import BrowseMedia, async_process_play_media_url # noqa: F401 +from .const import ( # noqa: F401 ATTR_APP_ID, ATTR_APP_NAME, ATTR_GROUP_MEMBERS, @@ -97,6 +97,7 @@ from .const import ( ATTR_MEDIA_VOLUME_MUTED, ATTR_SOUND_MODE, ATTR_SOUND_MODE_LIST, + CONTENT_AUTH_EXPIRY_TIME, DOMAIN, MEDIA_CLASS_DIRECTORY, REPEAT_MODES, @@ -1204,74 +1205,3 @@ async def websocket_browse_media(hass, connection, msg): _LOGGER.warning("Browse Media should use new BrowseMedia class") connection.send_result(msg["id"], payload) - - -class BrowseMedia: - """Represent a browsable media file.""" - - def __init__( - self, - *, - media_class: str, - media_content_id: str, - media_content_type: str, - title: str, - can_play: bool, - can_expand: bool, - children: list[BrowseMedia] | None = None, - children_media_class: str | None = None, - thumbnail: str | None = None, - ) -> None: - """Initialize browse media item.""" - self.media_class = media_class - self.media_content_id = media_content_id - self.media_content_type = media_content_type - self.title = title - self.can_play = can_play - self.can_expand = can_expand - self.children = children - self.children_media_class = children_media_class - self.thumbnail = thumbnail - - def as_dict(self, *, parent: bool = True) -> dict: - """Convert Media class to browse media dictionary.""" - if self.children_media_class is None: - self.calculate_children_class() - - response = { - "title": self.title, - "media_class": self.media_class, - "media_content_type": self.media_content_type, - "media_content_id": self.media_content_id, - "can_play": self.can_play, - "can_expand": self.can_expand, - "children_media_class": self.children_media_class, - "thumbnail": self.thumbnail, - } - - if not parent: - return response - - if self.children: - response["children"] = [ - child.as_dict(parent=False) for child in self.children - ] - else: - response["children"] = [] - - return response - - def calculate_children_class(self) -> None: - """Count the children media classes and calculate the correct class.""" - if self.children is None or len(self.children) == 0: - return - - self.children_media_class = MEDIA_CLASS_DIRECTORY - - proposed_class = self.children[0].media_class - if all(child.media_class == proposed_class for child in self.children): - self.children_media_class = proposed_class - - def __repr__(self): - """Return representation of browse media.""" - return f"" diff --git a/homeassistant/components/media_player/browse_media.py b/homeassistant/components/media_player/browse_media.py new file mode 100644 index 00000000000..829c96671a9 --- /dev/null +++ b/homeassistant/components/media_player/browse_media.py @@ -0,0 +1,112 @@ +"""Browse media features for media player.""" +from __future__ import annotations + +from datetime import timedelta +import logging +from urllib.parse import quote + +import yarl + +from homeassistant.components.http.auth import async_sign_path +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.network import get_url, is_hass_url + +from .const import CONTENT_AUTH_EXPIRY_TIME, MEDIA_CLASS_DIRECTORY + + +@callback +def async_process_play_media_url(hass: HomeAssistant, media_content_id: str) -> str: + """Update a media URL with authentication if it points at Home Assistant.""" + if media_content_id[0] != "/" and not is_hass_url(hass, media_content_id): + return media_content_id + + parsed = yarl.URL(media_content_id) + + if parsed.query: + logging.getLogger(__name__).debug( + "Not signing path for content with query param" + ) + else: + signed_path = async_sign_path( + hass, + quote(parsed.path), + timedelta(seconds=CONTENT_AUTH_EXPIRY_TIME), + ) + media_content_id = str(parsed.join(yarl.URL(signed_path))) + + # prepend external URL + if media_content_id[0] == "/": + media_content_id = f"{get_url(hass)}{media_content_id}" + + return media_content_id + + +class BrowseMedia: + """Represent a browsable media file.""" + + def __init__( + self, + *, + media_class: str, + media_content_id: str, + media_content_type: str, + title: str, + can_play: bool, + can_expand: bool, + children: list[BrowseMedia] | None = None, + children_media_class: str | None = None, + thumbnail: str | None = None, + ) -> None: + """Initialize browse media item.""" + self.media_class = media_class + self.media_content_id = media_content_id + self.media_content_type = media_content_type + self.title = title + self.can_play = can_play + self.can_expand = can_expand + self.children = children + self.children_media_class = children_media_class + self.thumbnail = thumbnail + + def as_dict(self, *, parent: bool = True) -> dict: + """Convert Media class to browse media dictionary.""" + if self.children_media_class is None: + self.calculate_children_class() + + response = { + "title": self.title, + "media_class": self.media_class, + "media_content_type": self.media_content_type, + "media_content_id": self.media_content_id, + "can_play": self.can_play, + "can_expand": self.can_expand, + "children_media_class": self.children_media_class, + "thumbnail": self.thumbnail, + } + + if not parent: + return response + + if self.children: + response["children"] = [ + child.as_dict(parent=False) for child in self.children + ] + else: + response["children"] = [] + + return response + + def calculate_children_class(self) -> None: + """Count the children media classes and calculate the correct class.""" + if self.children is None or len(self.children) == 0: + return + + self.children_media_class = MEDIA_CLASS_DIRECTORY + + proposed_class = self.children[0].media_class + if all(child.media_class == proposed_class for child in self.children): + self.children_media_class = proposed_class + + def __repr__(self) -> str: + """Return representation of browse media.""" + return f"" diff --git a/homeassistant/components/media_player/const.py b/homeassistant/components/media_player/const.py index 67f4331aa60..e7b16f6ac88 100644 --- a/homeassistant/components/media_player/const.py +++ b/homeassistant/components/media_player/const.py @@ -1,4 +1,6 @@ """Provides the constants needed for component.""" +# How long our auth signature on the content should be valid for +CONTENT_AUTH_EXPIRY_TIME = 3600 * 24 ATTR_APP_ID = "app_id" ATTR_APP_NAME = "app_name" diff --git a/homeassistant/components/media_source/__init__.py b/homeassistant/components/media_source/__init__.py index 2374eca2e6a..81c629529df 100644 --- a/homeassistant/components/media_source/__init__.py +++ b/homeassistant/components/media_source/__init__.py @@ -12,6 +12,7 @@ from homeassistant.components import frontend, websocket_api from homeassistant.components.http.auth import async_sign_path from homeassistant.components.media_player import ( ATTR_MEDIA_CONTENT_ID, + CONTENT_AUTH_EXPIRY_TIME, BrowseError, BrowseMedia, ) @@ -28,8 +29,6 @@ from .const import DOMAIN, URI_SCHEME, URI_SCHEME_REGEX from .error import MediaSourceError, Unresolvable from .models import BrowseMediaSource, MediaSourceItem, PlayMedia -DEFAULT_EXPIRY_TIME = 3600 * 24 - __all__ = [ "DOMAIN", "is_media_source_id", @@ -147,7 +146,7 @@ async def websocket_browse_media( { vol.Required("type"): "media_source/resolve_media", vol.Required(ATTR_MEDIA_CONTENT_ID): str, - vol.Optional("expires", default=DEFAULT_EXPIRY_TIME): int, + vol.Optional("expires", default=CONTENT_AUTH_EXPIRY_TIME): int, } ) @websocket_api.async_response diff --git a/homeassistant/components/roku/media_player.py b/homeassistant/components/roku/media_player.py index 4011a420ab1..48b85f5912c 100644 --- a/homeassistant/components/roku/media_player.py +++ b/homeassistant/components/roku/media_player.py @@ -4,17 +4,15 @@ from __future__ import annotations import datetime as dt import logging from typing import Any -from urllib.parse import quote import voluptuous as vol -import yarl from homeassistant.components import media_source -from homeassistant.components.http.auth import async_sign_path from homeassistant.components.media_player import ( BrowseMedia, MediaPlayerDeviceClass, MediaPlayerEntity, + async_process_play_media_url, ) from homeassistant.components.media_player.const import ( ATTR_MEDIA_EXTRA, @@ -47,7 +45,6 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_platform from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.network import get_url, is_hass_url from . import roku_exception_handler from .browse_media import async_browse_media @@ -376,22 +373,8 @@ class RokuMediaPlayer(RokuEntity, MediaPlayerEntity): media_type = MEDIA_TYPE_URL media_id = sourced_media.url - # Sign and prefix with URL if playing a relative URL - if media_id[0] == "/" or is_hass_url(self.hass, media_id): - parsed = yarl.URL(media_id) - - if parsed.query: - _LOGGER.debug("Not signing path for content with query param") - else: - media_id = async_sign_path( - self.hass, - quote(media_id), - dt.timedelta(seconds=media_source.DEFAULT_EXPIRY_TIME), - ) - - # prepend external URL - if media_id[0] == "/": - media_id = f"{get_url(self.hass)}{media_id}" + # If media ID is a relative URL, we serve it from HA. + media_id = async_process_play_media_url(self.hass, media_id) if media_type not in PLAY_MEDIA_SUPPORTED_TYPES: _LOGGER.error( diff --git a/homeassistant/components/sonos/media_player.py b/homeassistant/components/sonos/media_player.py index 5f5220cd164..e7ee76070a1 100644 --- a/homeassistant/components/sonos/media_player.py +++ b/homeassistant/components/sonos/media_player.py @@ -6,7 +6,6 @@ import datetime import json import logging from typing import Any -from urllib.parse import quote from soco import alarms from soco.core import ( @@ -19,8 +18,10 @@ from soco.data_structures import DidlFavorite import voluptuous as vol from homeassistant.components import media_source, spotify -from homeassistant.components.http.auth import async_sign_path -from homeassistant.components.media_player import MediaPlayerEntity +from homeassistant.components.media_player import ( + MediaPlayerEntity, + async_process_play_media_url, +) from homeassistant.components.media_player.const import ( ATTR_MEDIA_ENQUEUE, MEDIA_TYPE_ALBUM, @@ -56,7 +57,6 @@ 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 get_url from . import media_browser from .const import ( @@ -568,17 +568,7 @@ class SonosMediaPlayerEntity(SonosEntity, MediaPlayerEntity): 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), - datetime.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}" + media_id = async_process_play_media_url(self.hass, media_id) if kwargs.get(ATTR_MEDIA_ENQUEUE): soco.add_uri_to_queue(media_id) diff --git a/homeassistant/components/vlc_telnet/media_player.py b/homeassistant/components/vlc_telnet/media_player.py index 2bca965c3eb..140c2b2c253 100644 --- a/homeassistant/components/vlc_telnet/media_player.py +++ b/homeassistant/components/vlc_telnet/media_player.py @@ -2,19 +2,20 @@ from __future__ import annotations from collections.abc import Awaitable, Callable, Coroutine -from datetime import datetime, timedelta +from datetime import datetime from functools import wraps from typing import Any, TypeVar -from urllib.parse import quote from aiovlc.client import Client from aiovlc.exceptions import AuthError, CommandError, ConnectError from typing_extensions import Concatenate, ParamSpec -import yarl from homeassistant.components import media_source -from homeassistant.components.http.auth import async_sign_path -from homeassistant.components.media_player import BrowseMedia, MediaPlayerEntity +from homeassistant.components.media_player import ( + BrowseMedia, + MediaPlayerEntity, + async_process_play_media_url, +) from homeassistant.components.media_player.const import ( MEDIA_TYPE_MUSIC, SUPPORT_BROWSE_MEDIA, @@ -37,7 +38,6 @@ from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.device_registry import DeviceEntryType from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.network import get_url, is_hass_url import homeassistant.util.dt as dt_util from .const import DATA_AVAILABLE, DATA_VLC, DEFAULT_NAME, DOMAIN, LOGGER @@ -315,22 +315,8 @@ class VlcDevice(MediaPlayerEntity): f"Invalid media type {media_type}. Only {MEDIA_TYPE_MUSIC} is supported" ) - # Sign and prefix with URL if playing a relative URL - if media_id[0] == "/" or is_hass_url(self.hass, media_id): - parsed = yarl.URL(media_id) - - if parsed.query: - LOGGER.debug("Not signing path for content with query param") - else: - media_id = async_sign_path( - self.hass, - quote(media_id), - timedelta(seconds=media_source.DEFAULT_EXPIRY_TIME), - ) - - # prepend external URL - if media_id[0] == "/": - media_id = f"{get_url(self.hass)}{media_id}" + # If media ID is a relative URL, we serve it from HA. + media_id = async_process_play_media_url(self.hass, media_id) await self._vlc.add(media_id) self._state = STATE_PLAYING diff --git a/tests/components/media_player/test_browse_media.py b/tests/components/media_player/test_browse_media.py new file mode 100644 index 00000000000..ba7a93fc3a3 --- /dev/null +++ b/tests/components/media_player/test_browse_media.py @@ -0,0 +1,60 @@ +"""Test media browser helpers for media player.""" +from unittest.mock import Mock, patch + +import pytest + +from homeassistant.components.media_player.browse_media import ( + async_process_play_media_url, +) +from homeassistant.config import async_process_ha_core_config + + +@pytest.fixture +def mock_sign_path(): + """Mock sign path.""" + with patch( + "homeassistant.components.media_player.browse_media.async_sign_path", + side_effect=lambda _, url, _2: url + "?authSig=bla", + ): + yield + + +async def test_process_play_media_url(hass, mock_sign_path): + """Test it prefixes and signs urls.""" + await async_process_ha_core_config( + hass, + {"internal_url": "http://example.local:8123"}, + ) + hass.config.api = Mock(use_ssl=False, port=8123, local_ip="192.168.123.123") + + # Not changing a url that is not a hass url + assert ( + async_process_play_media_url(hass, "https://not-hass.com/path") + == "https://not-hass.com/path" + ) + + # Testing signing hass URLs + assert ( + async_process_play_media_url(hass, "/path") + == "http://example.local:8123/path?authSig=bla" + ) + assert ( + async_process_play_media_url(hass, "http://example.local:8123/path") + == "http://example.local:8123/path?authSig=bla" + ) + assert ( + async_process_play_media_url(hass, "http://192.168.123.123:8123/path") + == "http://192.168.123.123:8123/path?authSig=bla" + ) + + # Test skip signing URLs that have a query param + assert ( + async_process_play_media_url(hass, "/path?hello=world") + == "http://example.local:8123/path?hello=world" + ) + assert ( + async_process_play_media_url( + hass, "http://192.168.123.123:8123/path?hello=world" + ) + == "http://192.168.123.123:8123/path?hello=world" + )