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
This commit is contained in:
Paulus Schoutsen 2022-02-09 12:50:33 -08:00 committed by GitHub
parent bd657e5dd7
commit 3bce870c6d
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
9 changed files with 201 additions and 152 deletions

View file

@ -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(

View file

@ -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"<BrowseMedia {self.title} ({self.media_class})>"

View file

@ -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"<BrowseMedia {self.title} ({self.media_class})>"

View file

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

View file

@ -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

View file

@ -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(

View file

@ -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)

View file

@ -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

View file

@ -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"
)