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:
parent
bd657e5dd7
commit
3bce870c6d
9 changed files with 201 additions and 152 deletions
|
@ -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(
|
||||
|
|
|
@ -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})>"
|
||||
|
|
112
homeassistant/components/media_player/browse_media.py
Normal file
112
homeassistant/components/media_player/browse_media.py
Normal 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})>"
|
|
@ -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"
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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
|
||||
|
|
60
tests/components/media_player/test_browse_media.py
Normal file
60
tests/components/media_player/test_browse_media.py
Normal 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"
|
||||
)
|
Loading…
Add table
Add a link
Reference in a new issue