Add Reolink proxy for playback (#133916)

This commit is contained in:
starkillerOG 2025-01-03 14:24:39 +01:00 committed by GitHub
parent dc048bfcf5
commit 8a2f8dc736
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 418 additions and 21 deletions

View File

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

View File

@ -3,7 +3,7 @@
"name": "Reolink",
"codeowners": ["@starkillerOG"],
"config_flow": true,
"dependencies": ["webhook"],
"dependencies": ["http", "webhook"],
"dhcp": [
{
"hostname": "reolink*"

View File

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

View File

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

View 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

View 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