mirror of
https://github.com/home-assistant/core.git
synced 2025-04-23 08:47:57 +00:00
Add Reolink proxy for playback (#133916)
This commit is contained in:
parent
dc048bfcf5
commit
8a2f8dc736
@ -27,6 +27,7 @@ from .exceptions import PasswordIncompatible, ReolinkException, UserNotAdmin
|
||||
from .host import ReolinkHost
|
||||
from .services import async_setup_services
|
||||
from .util import ReolinkConfigEntry, ReolinkData, get_device_uid_and_ch
|
||||
from .views import PlaybackProxyView
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@ -189,6 +190,8 @@ async def async_setup_entry(
|
||||
|
||||
migrate_entity_ids(hass, config_entry.entry_id, host)
|
||||
|
||||
hass.http.register_view(PlaybackProxyView(hass))
|
||||
|
||||
await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS)
|
||||
|
||||
config_entry.async_on_unload(
|
||||
|
@ -3,7 +3,7 @@
|
||||
"name": "Reolink",
|
||||
"codeowners": ["@starkillerOG"],
|
||||
"config_flow": true,
|
||||
"dependencies": ["webhook"],
|
||||
"dependencies": ["http", "webhook"],
|
||||
"dhcp": [
|
||||
{
|
||||
"hostname": "reolink*"
|
||||
|
@ -23,8 +23,8 @@ from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import device_registry as dr, entity_registry as er
|
||||
|
||||
from .const import DOMAIN
|
||||
from .host import ReolinkHost
|
||||
from .util import ReolinkConfigEntry
|
||||
from .util import get_host
|
||||
from .views import async_generate_playback_proxy_url
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@ -47,15 +47,6 @@ def res_name(stream: str) -> str:
|
||||
return "Low res."
|
||||
|
||||
|
||||
def get_host(hass: HomeAssistant, config_entry_id: str) -> ReolinkHost:
|
||||
"""Return the Reolink host from the config entry id."""
|
||||
config_entry: ReolinkConfigEntry | None = hass.config_entries.async_get_entry(
|
||||
config_entry_id
|
||||
)
|
||||
assert config_entry is not None
|
||||
return config_entry.runtime_data.host
|
||||
|
||||
|
||||
class ReolinkVODMediaSource(MediaSource):
|
||||
"""Provide Reolink camera VODs as media sources."""
|
||||
|
||||
@ -90,22 +81,22 @@ class ReolinkVODMediaSource(MediaSource):
|
||||
|
||||
vod_type = get_vod_type()
|
||||
|
||||
if vod_type in [VodRequestType.DOWNLOAD, VodRequestType.PLAYBACK]:
|
||||
proxy_url = async_generate_playback_proxy_url(
|
||||
config_entry_id, channel, filename, stream_res, vod_type.value
|
||||
)
|
||||
return PlayMedia(proxy_url, "video/mp4")
|
||||
|
||||
mime_type, url = await host.api.get_vod_source(
|
||||
channel, filename, stream_res, vod_type
|
||||
)
|
||||
if _LOGGER.isEnabledFor(logging.DEBUG):
|
||||
url_log = url
|
||||
if "&user=" in url_log:
|
||||
url_log = f"{url_log.split('&user=')[0]}&user=xxxxx&password=xxxxx"
|
||||
elif "&token=" in url_log:
|
||||
url_log = f"{url_log.split('&token=')[0]}&token=xxxxx"
|
||||
_LOGGER.debug(
|
||||
"Opening VOD stream from %s: %s", host.api.camera_name(channel), url_log
|
||||
"Opening VOD stream from %s: %s",
|
||||
host.api.camera_name(channel),
|
||||
host.api.hide_password(url),
|
||||
)
|
||||
|
||||
if mime_type == "video/mp4":
|
||||
return PlayMedia(url, mime_type)
|
||||
|
||||
stream = create_stream(self.hass, url, {}, DynamicStreamSettings())
|
||||
stream.add_provider("hls", timeout=3600)
|
||||
stream_url: str = stream.endpoint_url("hls")
|
||||
|
@ -22,6 +22,7 @@ from reolink_aio.exceptions import (
|
||||
)
|
||||
|
||||
from homeassistant import config_entries
|
||||
from homeassistant.components.media_source import Unresolvable
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
|
||||
from homeassistant.helpers import device_registry as dr
|
||||
@ -51,6 +52,18 @@ def is_connected(hass: HomeAssistant, config_entry: config_entries.ConfigEntry)
|
||||
)
|
||||
|
||||
|
||||
def get_host(hass: HomeAssistant, config_entry_id: str) -> ReolinkHost:
|
||||
"""Return the Reolink host from the config entry id."""
|
||||
config_entry: ReolinkConfigEntry | None = hass.config_entries.async_get_entry(
|
||||
config_entry_id
|
||||
)
|
||||
if config_entry is None:
|
||||
raise Unresolvable(
|
||||
f"Could not find Reolink config entry id '{config_entry_id}'."
|
||||
)
|
||||
return config_entry.runtime_data.host
|
||||
|
||||
|
||||
def get_device_uid_and_ch(
|
||||
device: dr.DeviceEntry, host: ReolinkHost
|
||||
) -> tuple[list[str], int | None, bool]:
|
||||
|
147
homeassistant/components/reolink/views.py
Normal file
147
homeassistant/components/reolink/views.py
Normal file
@ -0,0 +1,147 @@
|
||||
"""Reolink Integration views."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from http import HTTPStatus
|
||||
import logging
|
||||
from urllib import parse
|
||||
|
||||
from aiohttp import ClientError, ClientTimeout, web
|
||||
from reolink_aio.enums import VodRequestType
|
||||
from reolink_aio.exceptions import ReolinkError
|
||||
|
||||
from homeassistant.components.http import HomeAssistantView
|
||||
from homeassistant.components.media_source import Unresolvable
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
from homeassistant.util.ssl import SSLCipherList
|
||||
|
||||
from .util import get_host
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@callback
|
||||
def async_generate_playback_proxy_url(
|
||||
config_entry_id: str, channel: int, filename: str, stream_res: str, vod_type: str
|
||||
) -> str:
|
||||
"""Generate proxy URL for event video."""
|
||||
|
||||
url_format = PlaybackProxyView.url
|
||||
return url_format.format(
|
||||
config_entry_id=config_entry_id,
|
||||
channel=channel,
|
||||
filename=parse.quote(filename, safe=""),
|
||||
stream_res=stream_res,
|
||||
vod_type=vod_type,
|
||||
)
|
||||
|
||||
|
||||
class PlaybackProxyView(HomeAssistantView):
|
||||
"""View to proxy playback video from Reolink."""
|
||||
|
||||
requires_auth = True
|
||||
url = "/api/reolink/video/{config_entry_id}/{channel}/{stream_res}/{vod_type}/{filename}"
|
||||
name = "api:reolink_playback"
|
||||
|
||||
def __init__(self, hass: HomeAssistant) -> None:
|
||||
"""Initialize a proxy view."""
|
||||
self.hass = hass
|
||||
self.session = async_get_clientsession(
|
||||
hass,
|
||||
verify_ssl=False,
|
||||
ssl_cipher=SSLCipherList.INSECURE,
|
||||
)
|
||||
|
||||
async def get(
|
||||
self,
|
||||
request: web.Request,
|
||||
config_entry_id: str,
|
||||
channel: str,
|
||||
stream_res: str,
|
||||
vod_type: str,
|
||||
filename: str,
|
||||
retry: int = 2,
|
||||
) -> web.StreamResponse:
|
||||
"""Get playback proxy video response."""
|
||||
retry = retry - 1
|
||||
|
||||
filename = parse.unquote(filename)
|
||||
ch = int(channel)
|
||||
try:
|
||||
host = get_host(self.hass, config_entry_id)
|
||||
except Unresolvable:
|
||||
err_str = f"Reolink playback proxy could not find config entry id: {config_entry_id}"
|
||||
_LOGGER.warning(err_str)
|
||||
return web.Response(body=err_str, status=HTTPStatus.BAD_REQUEST)
|
||||
|
||||
try:
|
||||
mime_type, reolink_url = await host.api.get_vod_source(
|
||||
ch, filename, stream_res, VodRequestType(vod_type)
|
||||
)
|
||||
except ReolinkError as err:
|
||||
_LOGGER.warning("Reolink playback proxy error: %s", str(err))
|
||||
return web.Response(body=str(err), status=HTTPStatus.BAD_REQUEST)
|
||||
|
||||
if _LOGGER.isEnabledFor(logging.DEBUG):
|
||||
_LOGGER.debug(
|
||||
"Opening VOD stream from %s: %s",
|
||||
host.api.camera_name(ch),
|
||||
host.api.hide_password(reolink_url),
|
||||
)
|
||||
|
||||
try:
|
||||
reolink_response = await self.session.get(
|
||||
reolink_url,
|
||||
timeout=ClientTimeout(
|
||||
connect=15, sock_connect=15, sock_read=5, total=None
|
||||
),
|
||||
)
|
||||
except ClientError as err:
|
||||
err_str = host.api.hide_password(
|
||||
f"Reolink playback error while getting mp4: {err!s}"
|
||||
)
|
||||
if retry <= 0:
|
||||
_LOGGER.warning(err_str)
|
||||
return web.Response(body=err_str, status=HTTPStatus.BAD_REQUEST)
|
||||
_LOGGER.debug("%s, renewing token", err_str)
|
||||
await host.api.expire_session(unsubscribe=False)
|
||||
return await self.get(
|
||||
request, config_entry_id, channel, stream_res, vod_type, filename, retry
|
||||
)
|
||||
|
||||
# Reolink typo "apolication/octet-stream" instead of "application/octet-stream"
|
||||
if reolink_response.content_type not in [
|
||||
"video/mp4",
|
||||
"application/octet-stream",
|
||||
"apolication/octet-stream",
|
||||
]:
|
||||
err_str = f"Reolink playback expected video/mp4 but got {reolink_response.content_type}"
|
||||
_LOGGER.error(err_str)
|
||||
return web.Response(body=err_str, status=HTTPStatus.BAD_REQUEST)
|
||||
|
||||
response = web.StreamResponse(
|
||||
status=200,
|
||||
reason="OK",
|
||||
headers={
|
||||
"Content-Type": "video/mp4",
|
||||
},
|
||||
)
|
||||
|
||||
if reolink_response.content_length is not None:
|
||||
response.content_length = reolink_response.content_length
|
||||
|
||||
await response.prepare(request)
|
||||
|
||||
try:
|
||||
async for chunk in reolink_response.content.iter_chunked(65536):
|
||||
await response.write(chunk)
|
||||
except TimeoutError:
|
||||
_LOGGER.debug(
|
||||
"Timeout while reading Reolink playback from %s, writing EOF",
|
||||
host.api.nvr_name,
|
||||
)
|
||||
|
||||
reolink_response.release()
|
||||
await response.write_eof()
|
||||
return response
|
243
tests/components/reolink/test_views.py
Normal file
243
tests/components/reolink/test_views.py
Normal file
@ -0,0 +1,243 @@
|
||||
"""Tests for the Reolink views platform."""
|
||||
|
||||
from http import HTTPStatus
|
||||
import logging
|
||||
from typing import Any, cast
|
||||
from unittest.mock import AsyncMock, MagicMock, Mock, patch
|
||||
|
||||
from aiohttp import ClientConnectionError, ClientResponse
|
||||
import pytest
|
||||
from reolink_aio.enums import VodRequestType
|
||||
from reolink_aio.exceptions import ReolinkError
|
||||
|
||||
from homeassistant.components.reolink.views import async_generate_playback_proxy_url
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
from tests.common import MockConfigEntry
|
||||
from tests.typing import ClientSessionGenerator
|
||||
|
||||
TEST_YEAR = 2023
|
||||
TEST_MONTH = 11
|
||||
TEST_DAY = 14
|
||||
TEST_DAY2 = 15
|
||||
TEST_HOUR = 13
|
||||
TEST_MINUTE = 12
|
||||
TEST_FILE_NAME_MP4 = f"{TEST_YEAR}{TEST_MONTH}{TEST_DAY}{TEST_HOUR}{TEST_MINUTE}00.mp4"
|
||||
TEST_STREAM = "sub"
|
||||
TEST_CHANNEL = "0"
|
||||
TEST_VOD_TYPE = VodRequestType.PLAYBACK.value
|
||||
TEST_MIME_TYPE_MP4 = "video/mp4"
|
||||
TEST_URL = "http://test_url&token=test"
|
||||
TEST_ERROR = "TestError"
|
||||
|
||||
|
||||
def get_mock_session(
|
||||
response: list[Any] | None = None,
|
||||
content_length: int = 8,
|
||||
content_type: str = TEST_MIME_TYPE_MP4,
|
||||
) -> Mock:
|
||||
"""Get a mock session to mock the camera response."""
|
||||
if response is None:
|
||||
response = [b"test", b"test", StopAsyncIteration()]
|
||||
|
||||
content = Mock()
|
||||
content.__anext__ = AsyncMock(side_effect=response)
|
||||
content.__aiter__ = Mock(return_value=content)
|
||||
|
||||
mock_response = Mock()
|
||||
mock_response.content_length = content_length
|
||||
mock_response.content_type = content_type
|
||||
mock_response.content.iter_chunked = Mock(return_value=content)
|
||||
|
||||
mock_session = Mock()
|
||||
mock_session.get = AsyncMock(return_value=mock_response)
|
||||
return mock_session
|
||||
|
||||
|
||||
async def test_playback_proxy(
|
||||
hass: HomeAssistant,
|
||||
reolink_connect: MagicMock,
|
||||
config_entry: MockConfigEntry,
|
||||
hass_client: ClientSessionGenerator,
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
"""Test successful playback proxy URL."""
|
||||
reolink_connect.get_vod_source.return_value = (TEST_MIME_TYPE_MP4, TEST_URL)
|
||||
|
||||
mock_session = get_mock_session()
|
||||
|
||||
with patch(
|
||||
"homeassistant.components.reolink.views.async_get_clientsession",
|
||||
return_value=mock_session,
|
||||
):
|
||||
assert await hass.config_entries.async_setup(config_entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
caplog.set_level(logging.DEBUG)
|
||||
|
||||
proxy_url = async_generate_playback_proxy_url(
|
||||
config_entry.entry_id,
|
||||
TEST_CHANNEL,
|
||||
TEST_FILE_NAME_MP4,
|
||||
TEST_STREAM,
|
||||
TEST_VOD_TYPE,
|
||||
)
|
||||
|
||||
http_client = await hass_client()
|
||||
response = cast(ClientResponse, await http_client.get(proxy_url))
|
||||
|
||||
assert await response.content.read() == b"testtest"
|
||||
assert response.status == 200
|
||||
|
||||
|
||||
async def test_proxy_get_source_error(
|
||||
hass: HomeAssistant,
|
||||
reolink_connect: MagicMock,
|
||||
config_entry: MockConfigEntry,
|
||||
hass_client: ClientSessionGenerator,
|
||||
) -> None:
|
||||
"""Test error while getting source for playback proxy URL."""
|
||||
reolink_connect.get_vod_source.side_effect = ReolinkError(TEST_ERROR)
|
||||
|
||||
assert await hass.config_entries.async_setup(config_entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
proxy_url = async_generate_playback_proxy_url(
|
||||
config_entry.entry_id,
|
||||
TEST_CHANNEL,
|
||||
TEST_FILE_NAME_MP4,
|
||||
TEST_STREAM,
|
||||
TEST_VOD_TYPE,
|
||||
)
|
||||
|
||||
http_client = await hass_client()
|
||||
response = await http_client.get(proxy_url)
|
||||
|
||||
assert await response.content.read() == bytes(TEST_ERROR, "utf-8")
|
||||
assert response.status == HTTPStatus.BAD_REQUEST
|
||||
reolink_connect.get_vod_source.side_effect = None
|
||||
|
||||
|
||||
async def test_proxy_invalid_config_entry_id(
|
||||
hass: HomeAssistant,
|
||||
reolink_connect: MagicMock,
|
||||
config_entry: MockConfigEntry,
|
||||
hass_client: ClientSessionGenerator,
|
||||
) -> None:
|
||||
"""Test config entry id not found for playback proxy URL."""
|
||||
assert await hass.config_entries.async_setup(config_entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
proxy_url = async_generate_playback_proxy_url(
|
||||
"wrong_config_id",
|
||||
TEST_CHANNEL,
|
||||
TEST_FILE_NAME_MP4,
|
||||
TEST_STREAM,
|
||||
TEST_VOD_TYPE,
|
||||
)
|
||||
|
||||
http_client = await hass_client()
|
||||
response = await http_client.get(proxy_url)
|
||||
|
||||
assert await response.content.read() == bytes(
|
||||
"Reolink playback proxy could not find config entry id: wrong_config_id",
|
||||
"utf-8",
|
||||
)
|
||||
assert response.status == HTTPStatus.BAD_REQUEST
|
||||
|
||||
|
||||
async def test_playback_proxy_timeout(
|
||||
hass: HomeAssistant,
|
||||
reolink_connect: MagicMock,
|
||||
config_entry: MockConfigEntry,
|
||||
hass_client: ClientSessionGenerator,
|
||||
) -> None:
|
||||
"""Test playback proxy URL with a timeout in the second chunk."""
|
||||
reolink_connect.get_vod_source.return_value = (TEST_MIME_TYPE_MP4, TEST_URL)
|
||||
|
||||
mock_session = get_mock_session([b"test", TimeoutError()], 4)
|
||||
|
||||
with patch(
|
||||
"homeassistant.components.reolink.views.async_get_clientsession",
|
||||
return_value=mock_session,
|
||||
):
|
||||
assert await hass.config_entries.async_setup(config_entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
proxy_url = async_generate_playback_proxy_url(
|
||||
config_entry.entry_id,
|
||||
TEST_CHANNEL,
|
||||
TEST_FILE_NAME_MP4,
|
||||
TEST_STREAM,
|
||||
TEST_VOD_TYPE,
|
||||
)
|
||||
|
||||
http_client = await hass_client()
|
||||
response = cast(ClientResponse, await http_client.get(proxy_url))
|
||||
|
||||
assert await response.content.read() == b"test"
|
||||
assert response.status == 200
|
||||
|
||||
|
||||
async def test_playback_wrong_content(
|
||||
hass: HomeAssistant,
|
||||
reolink_connect: MagicMock,
|
||||
config_entry: MockConfigEntry,
|
||||
hass_client: ClientSessionGenerator,
|
||||
) -> None:
|
||||
"""Test playback proxy URL with a wrong content type in the response."""
|
||||
reolink_connect.get_vod_source.return_value = (TEST_MIME_TYPE_MP4, TEST_URL)
|
||||
|
||||
mock_session = get_mock_session(content_type="video/x-flv")
|
||||
|
||||
with patch(
|
||||
"homeassistant.components.reolink.views.async_get_clientsession",
|
||||
return_value=mock_session,
|
||||
):
|
||||
assert await hass.config_entries.async_setup(config_entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
proxy_url = async_generate_playback_proxy_url(
|
||||
config_entry.entry_id,
|
||||
TEST_CHANNEL,
|
||||
TEST_FILE_NAME_MP4,
|
||||
TEST_STREAM,
|
||||
TEST_VOD_TYPE,
|
||||
)
|
||||
|
||||
http_client = await hass_client()
|
||||
response = cast(ClientResponse, await http_client.get(proxy_url))
|
||||
|
||||
assert response.status == HTTPStatus.BAD_REQUEST
|
||||
|
||||
|
||||
async def test_playback_connect_error(
|
||||
hass: HomeAssistant,
|
||||
reolink_connect: MagicMock,
|
||||
config_entry: MockConfigEntry,
|
||||
hass_client: ClientSessionGenerator,
|
||||
) -> None:
|
||||
"""Test playback proxy URL with a connection error."""
|
||||
reolink_connect.get_vod_source.return_value = (TEST_MIME_TYPE_MP4, TEST_URL)
|
||||
|
||||
mock_session = Mock()
|
||||
mock_session.get = AsyncMock(side_effect=ClientConnectionError(TEST_ERROR))
|
||||
|
||||
with patch(
|
||||
"homeassistant.components.reolink.views.async_get_clientsession",
|
||||
return_value=mock_session,
|
||||
):
|
||||
assert await hass.config_entries.async_setup(config_entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
proxy_url = async_generate_playback_proxy_url(
|
||||
config_entry.entry_id,
|
||||
TEST_CHANNEL,
|
||||
TEST_FILE_NAME_MP4,
|
||||
TEST_STREAM,
|
||||
TEST_VOD_TYPE,
|
||||
)
|
||||
|
||||
http_client = await hass_client()
|
||||
response = cast(ClientResponse, await http_client.get(proxy_url))
|
||||
|
||||
assert response.status == HTTPStatus.BAD_REQUEST
|
Loading…
x
Reference in New Issue
Block a user