From aea15ee20c05529e6346e32d9b463205bf552b0f Mon Sep 17 00:00:00 2001 From: starkillerOG Date: Tue, 21 Nov 2023 23:43:56 +0100 Subject: [PATCH] Reolink add media browser for playback of recordings (#103407) --- .../components/reolink/media_source.py | 330 ++++++++++++++++++ tests/components/reolink/conftest.py | 6 + tests/components/reolink/test_media_source.py | 288 +++++++++++++++ 3 files changed, 624 insertions(+) create mode 100644 homeassistant/components/reolink/media_source.py create mode 100644 tests/components/reolink/test_media_source.py diff --git a/homeassistant/components/reolink/media_source.py b/homeassistant/components/reolink/media_source.py new file mode 100644 index 00000000000..6a350e13836 --- /dev/null +++ b/homeassistant/components/reolink/media_source.py @@ -0,0 +1,330 @@ +"""Expose Reolink IP camera VODs as media sources.""" + +from __future__ import annotations + +import datetime as dt +import logging + +from homeassistant.components.camera import DOMAIN as CAM_DOMAIN, DynamicStreamSettings +from homeassistant.components.media_player import MediaClass, MediaType +from homeassistant.components.media_source.error import Unresolvable +from homeassistant.components.media_source.models import ( + BrowseMediaSource, + MediaSource, + MediaSourceItem, + PlayMedia, +) +from homeassistant.components.stream import create_stream +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr, entity_registry as er + +from . import ReolinkData +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + + +async def async_get_media_source(hass: HomeAssistant) -> ReolinkVODMediaSource: + """Set up camera media source.""" + return ReolinkVODMediaSource(hass) + + +def res_name(stream: str) -> str: + """Return the user friendly name for a stream.""" + return "High res." if stream == "main" else "Low res." + + +class ReolinkVODMediaSource(MediaSource): + """Provide Reolink camera VODs as media sources.""" + + name: str = "Reolink" + + def __init__(self, hass: HomeAssistant) -> None: + """Initialize ReolinkVODMediaSource.""" + super().__init__(DOMAIN) + self.hass = hass + self.data: dict[str, ReolinkData] = hass.data[DOMAIN] + + async def async_resolve_media(self, item: MediaSourceItem) -> PlayMedia: + """Resolve media to a url.""" + identifier = item.identifier.split("|", 5) + if identifier[0] != "FILE": + raise Unresolvable(f"Unknown media item '{item.identifier}'.") + + _, config_entry_id, channel_str, stream_res, filename = identifier + channel = int(channel_str) + + host = self.data[config_entry_id].host + mime_type, url = await host.api.get_vod_source(channel, filename, stream_res) + if _LOGGER.isEnabledFor(logging.DEBUG): + url_log = f"{url.split('&user=')[0]}&user=xxxxx&password=xxxxx" + _LOGGER.debug( + "Opening VOD stream from %s: %s", host.api.camera_name(channel), url_log + ) + + stream = create_stream(self.hass, url, {}, DynamicStreamSettings()) + stream.add_provider("hls", timeout=3600) + stream_url: str = stream.endpoint_url("hls") + stream_url = stream_url.replace("master_", "") + return PlayMedia(stream_url, mime_type) + + async def async_browse_media( + self, + item: MediaSourceItem, + ) -> BrowseMediaSource: + """Return media.""" + if item.identifier is None: + return await self._async_generate_root() + + identifier = item.identifier.split("|", 7) + item_type = identifier[0] + + if item_type == "CAM": + _, config_entry_id, channel_str = identifier + return await self._async_generate_resolution_select( + config_entry_id, int(channel_str) + ) + if item_type == "RES": + _, config_entry_id, channel_str, stream = identifier + return await self._async_generate_camera_days( + config_entry_id, int(channel_str), stream + ) + if item_type == "DAY": + ( + _, + config_entry_id, + channel_str, + stream, + year_str, + month_str, + day_str, + ) = identifier + return await self._async_generate_camera_files( + config_entry_id, + int(channel_str), + stream, + int(year_str), + int(month_str), + int(day_str), + ) + + raise Unresolvable(f"Unknown media item '{item.identifier}' during browsing.") + + async def _async_generate_root(self) -> BrowseMediaSource: + """Return all available reolink cameras as root browsing structure.""" + children: list[BrowseMediaSource] = [] + + entity_reg = er.async_get(self.hass) + device_reg = dr.async_get(self.hass) + for config_entry in self.hass.config_entries.async_entries(DOMAIN): + if config_entry.state != ConfigEntryState.LOADED: + continue + channels: list[str] = [] + host = self.data[config_entry.entry_id].host + entities = er.async_entries_for_config_entry( + entity_reg, config_entry.entry_id + ) + for entity in entities: + if ( + entity.disabled + or entity.device_id is None + or entity.domain != CAM_DOMAIN + ): + continue + + device = device_reg.async_get(entity.device_id) + ch = entity.unique_id.split("_")[1] + if ch in channels or device is None: + continue + channels.append(ch) + + if ( + host.api.api_version("recReplay", int(ch)) < 1 + or not host.api.hdd_info + ): + # playback stream not supported by this camera or no storage installed + continue + + device_name = device.name + if device.name_by_user is not None: + device_name = device.name_by_user + + children.append( + BrowseMediaSource( + domain=DOMAIN, + identifier=f"CAM|{config_entry.entry_id}|{ch}", + media_class=MediaClass.CHANNEL, + media_content_type=MediaType.PLAYLIST, + title=device_name, + thumbnail=f"/api/camera_proxy/{entity.entity_id}", + can_play=False, + can_expand=True, + ) + ) + + return BrowseMediaSource( + domain=DOMAIN, + identifier=None, + media_class=MediaClass.APP, + media_content_type="", + title="Reolink", + can_play=False, + can_expand=True, + children=children, + ) + + async def _async_generate_resolution_select( + self, config_entry_id: str, channel: int + ) -> BrowseMediaSource: + """Allow the user to select the high or low playback resolution, (low loads faster).""" + host = self.data[config_entry_id].host + + main_enc = await host.api.get_encoding(channel, "main") + if main_enc == "h265": + _LOGGER.debug( + "Reolink camera %s uses h265 encoding for main stream," + "playback only possible using sub stream", + host.api.camera_name(channel), + ) + return await self._async_generate_camera_days( + config_entry_id, channel, "sub" + ) + + children = [ + BrowseMediaSource( + domain=DOMAIN, + identifier=f"RES|{config_entry_id}|{channel}|sub", + media_class=MediaClass.CHANNEL, + media_content_type=MediaType.PLAYLIST, + title="Low resolution", + can_play=False, + can_expand=True, + ), + BrowseMediaSource( + domain=DOMAIN, + identifier=f"RES|{config_entry_id}|{channel}|main", + media_class=MediaClass.CHANNEL, + media_content_type=MediaType.PLAYLIST, + title="High resolution", + can_play=False, + can_expand=True, + ), + ] + + return BrowseMediaSource( + domain=DOMAIN, + identifier=f"RESs|{config_entry_id}|{channel}", + media_class=MediaClass.CHANNEL, + media_content_type=MediaType.PLAYLIST, + title=host.api.camera_name(channel), + can_play=False, + can_expand=True, + children=children, + ) + + async def _async_generate_camera_days( + self, config_entry_id: str, channel: int, stream: str + ) -> BrowseMediaSource: + """Return all days on which recordings are available for a reolink camera.""" + host = self.data[config_entry_id].host + + # We want today of the camera, not necessarily today of the server + now = host.api.time() or await host.api.async_get_time() + start = now - dt.timedelta(days=31) + end = now + + children: list[BrowseMediaSource] = [] + if _LOGGER.isEnabledFor(logging.DEBUG): + _LOGGER.debug( + "Requesting recording days of %s from %s to %s", + host.api.camera_name(channel), + start, + end, + ) + statuses, _ = await host.api.request_vod_files( + channel, start, end, status_only=True, stream=stream + ) + for status in statuses: + for day in status.days: + children.append( + BrowseMediaSource( + domain=DOMAIN, + identifier=f"DAY|{config_entry_id}|{channel}|{stream}|{status.year}|{status.month}|{day}", + media_class=MediaClass.DIRECTORY, + media_content_type=MediaType.PLAYLIST, + title=f"{status.year}/{status.month}/{day}", + can_play=False, + can_expand=True, + ) + ) + + return BrowseMediaSource( + domain=DOMAIN, + identifier=f"DAYS|{config_entry_id}|{channel}|{stream}", + media_class=MediaClass.CHANNEL, + media_content_type=MediaType.PLAYLIST, + title=f"{host.api.camera_name(channel)} {res_name(stream)}", + can_play=False, + can_expand=True, + children=children, + ) + + async def _async_generate_camera_files( + self, + config_entry_id: str, + channel: int, + stream: str, + year: int, + month: int, + day: int, + ) -> BrowseMediaSource: + """Return all recording files on a specific day of a Reolink camera.""" + host = self.data[config_entry_id].host + + start = dt.datetime(year, month, day, hour=0, minute=0, second=0) + end = dt.datetime(year, month, day, hour=23, minute=59, second=59) + + children: list[BrowseMediaSource] = [] + if _LOGGER.isEnabledFor(logging.DEBUG): + _LOGGER.debug( + "Requesting VODs of %s on %s/%s/%s", + host.api.camera_name(channel), + year, + month, + day, + ) + _, vod_files = await host.api.request_vod_files( + channel, start, end, stream=stream + ) + for file in vod_files: + file_name = f"{file.start_time.time()} {file.duration}" + if file.triggers != file.triggers.NONE: + file_name += " " + " ".join( + str(trigger.name).title() + for trigger in file.triggers + if trigger != trigger.NONE + ) + + children.append( + BrowseMediaSource( + domain=DOMAIN, + identifier=f"FILE|{config_entry_id}|{channel}|{stream}|{file.file_name}", + media_class=MediaClass.VIDEO, + media_content_type=MediaType.VIDEO, + title=file_name, + can_play=True, + can_expand=False, + ) + ) + + return BrowseMediaSource( + domain=DOMAIN, + identifier=f"FILES|{config_entry_id}|{channel}|{stream}", + media_class=MediaClass.CHANNEL, + media_content_type=MediaType.PLAYLIST, + title=f"{host.api.camera_name(channel)} {res_name(stream)} {year}/{month}/{day}", + can_play=False, + can_expand=True, + children=children, + ) diff --git a/tests/components/reolink/conftest.py b/tests/components/reolink/conftest.py index 3efc1e481df..2a6fd0fecd3 100644 --- a/tests/components/reolink/conftest.py +++ b/tests/components/reolink/conftest.py @@ -19,8 +19,10 @@ TEST_USERNAME2 = "username" TEST_PASSWORD = "password" TEST_PASSWORD2 = "new_password" TEST_MAC = "ab:cd:ef:gh:ij:kl" +TEST_MAC2 = "12:34:56:78:9a:bc" TEST_PORT = 1234 TEST_NVR_NAME = "test_reolink_name" +TEST_NVR_NAME2 = "test2_reolink_name" TEST_USE_HTTPS = True @@ -59,11 +61,15 @@ def reolink_connect_class( host_mock.use_https = TEST_USE_HTTPS host_mock.is_admin = True host_mock.user_level = "admin" + host_mock.stream_channels = [0] host_mock.sw_version_update_required = False host_mock.hardware_version = "IPC_00000" host_mock.sw_version = "v1.0.0.0.0.0000" host_mock.manufacturer = "Reolink" host_mock.model = "RLC-123" + host_mock.camera_model.return_value = "RLC-123" + host_mock.camera_name.return_value = TEST_NVR_NAME + host_mock.camera_sw_version.return_value = "v1.1.0.0.0.0000" host_mock.session_active = True host_mock.timeout = 60 host_mock.renewtimer.return_value = 600 diff --git a/tests/components/reolink/test_media_source.py b/tests/components/reolink/test_media_source.py new file mode 100644 index 00000000000..7fe3570564a --- /dev/null +++ b/tests/components/reolink/test_media_source.py @@ -0,0 +1,288 @@ +"""Tests for the Reolink media_source platform.""" +from datetime import datetime, timedelta +import logging +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest +from reolink_aio.exceptions import ReolinkError + +from homeassistant.components.media_source import ( + DOMAIN as MEDIA_SOURCE_DOMAIN, + URI_SCHEME, + async_browse_media, + async_resolve_media, +) +from homeassistant.components.media_source.error import Unresolvable +from homeassistant.components.reolink import const +from homeassistant.components.reolink.config_flow import DEFAULT_PROTOCOL +from homeassistant.components.reolink.const import DOMAIN +from homeassistant.components.stream import DOMAIN as MEDIA_STREAM_DOMAIN +from homeassistant.const import ( + CONF_HOST, + CONF_PASSWORD, + CONF_PORT, + CONF_USERNAME, + Platform, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr +from homeassistant.helpers.device_registry import format_mac +from homeassistant.setup import async_setup_component + +from .conftest import ( + TEST_HOST2, + TEST_MAC2, + TEST_NVR_NAME, + TEST_NVR_NAME2, + TEST_PASSWORD2, + TEST_PORT, + TEST_USE_HTTPS, + TEST_USERNAME2, +) + +from tests.common import MockConfigEntry + +TEST_YEAR = 2023 +TEST_MONTH = 11 +TEST_DAY = 14 +TEST_DAY2 = 15 +TEST_HOUR = 13 +TEST_MINUTE = 12 +TEST_FILE_NAME = f"{TEST_YEAR}{TEST_MONTH}{TEST_DAY}{TEST_HOUR}{TEST_MINUTE}00" +TEST_STREAM = "main" +TEST_CHANNEL = "0" + +TEST_MIME_TYPE = "application/x-mpegURL" +TEST_URL = "http:test_url" + + +@pytest.fixture(autouse=True) +async def setup_component(hass: HomeAssistant) -> None: + """Set up component.""" + assert await async_setup_component(hass, MEDIA_SOURCE_DOMAIN, {}) + assert await async_setup_component(hass, MEDIA_STREAM_DOMAIN, {}) + + +async def test_resolve( + hass: HomeAssistant, + reolink_connect: MagicMock, + config_entry: MockConfigEntry, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test resolving Reolink media items.""" + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + reolink_connect.get_vod_source.return_value = (TEST_MIME_TYPE, TEST_URL) + caplog.set_level(logging.DEBUG) + + file_id = ( + f"FILE|{config_entry.entry_id}|{TEST_CHANNEL}|{TEST_STREAM}|{TEST_FILE_NAME}" + ) + + play_media = await async_resolve_media(hass, f"{URI_SCHEME}{DOMAIN}/{file_id}") + + assert play_media.mime_type == TEST_MIME_TYPE + + +async def test_browsing( + hass: HomeAssistant, + reolink_connect: MagicMock, + config_entry: MockConfigEntry, + device_registry: dr.DeviceRegistry, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test browsing the Reolink three.""" + entry_id = config_entry.entry_id + reolink_connect.api_version.return_value = 1 + + with patch("homeassistant.components.reolink.PLATFORMS", [Platform.CAMERA]): + assert await hass.config_entries.async_setup(entry_id) is True + await hass.async_block_till_done() + + entries = dr.async_entries_for_config_entry(device_registry, entry_id) + assert len(entries) > 0 + device_registry.async_update_device(entries[0].id, name_by_user="Cam new name") + + caplog.set_level(logging.DEBUG) + + # browse root + browse = await async_browse_media(hass, f"{URI_SCHEME}{DOMAIN}") + + browse_root_id = f"CAM|{entry_id}|{TEST_CHANNEL}" + assert browse.domain == DOMAIN + assert browse.title == "Reolink" + assert browse.identifier is None + assert browse.children[0].identifier == browse_root_id + + # browse resolution select + browse = await async_browse_media(hass, f"{URI_SCHEME}{DOMAIN}/{browse_root_id}") + + browse_resolution_id = f"RESs|{entry_id}|{TEST_CHANNEL}" + browse_res_sub_id = f"RES|{entry_id}|{TEST_CHANNEL}|sub" + browse_res_main_id = f"RES|{entry_id}|{TEST_CHANNEL}|main" + assert browse.domain == DOMAIN + assert browse.title == TEST_NVR_NAME + assert browse.identifier == browse_resolution_id + assert browse.children[0].identifier == browse_res_sub_id + assert browse.children[1].identifier == browse_res_main_id + + # browse camera recording days + mock_status = MagicMock() + mock_status.year = TEST_YEAR + mock_status.month = TEST_MONTH + mock_status.days = (TEST_DAY, TEST_DAY2) + reolink_connect.request_vod_files.return_value = ([mock_status], []) + + browse = await async_browse_media( + hass, f"{URI_SCHEME}{DOMAIN}/{browse_res_main_id}" + ) + + browse_days_id = f"DAYS|{entry_id}|{TEST_CHANNEL}|{TEST_STREAM}" + browse_day_0_id = f"DAY|{entry_id}|{TEST_CHANNEL}|{TEST_STREAM}|{TEST_YEAR}|{TEST_MONTH}|{TEST_DAY}" + browse_day_1_id = f"DAY|{entry_id}|{TEST_CHANNEL}|{TEST_STREAM}|{TEST_YEAR}|{TEST_MONTH}|{TEST_DAY2}" + assert browse.domain == DOMAIN + assert browse.title == f"{TEST_NVR_NAME} High res." + assert browse.identifier == browse_days_id + assert browse.children[0].identifier == browse_day_0_id + assert browse.children[1].identifier == browse_day_1_id + + # browse camera recording files on day + mock_vod_file = MagicMock() + mock_vod_file.start_time = datetime( + TEST_YEAR, TEST_MONTH, TEST_DAY, TEST_HOUR, TEST_MINUTE + ) + mock_vod_file.duration = timedelta(minutes=15) + mock_vod_file.file_name = TEST_FILE_NAME + reolink_connect.request_vod_files.return_value = ([mock_status], [mock_vod_file]) + + browse = await async_browse_media(hass, f"{URI_SCHEME}{DOMAIN}/{browse_day_0_id}") + + browse_files_id = f"FILES|{entry_id}|{TEST_CHANNEL}|{TEST_STREAM}" + browse_file_id = f"FILE|{entry_id}|{TEST_CHANNEL}|{TEST_STREAM}|{TEST_FILE_NAME}" + assert browse.domain == DOMAIN + assert ( + browse.title == f"{TEST_NVR_NAME} High res. {TEST_YEAR}/{TEST_MONTH}/{TEST_DAY}" + ) + assert browse.identifier == browse_files_id + assert browse.children[0].identifier == browse_file_id + + +async def test_browsing_unsupported_encoding( + hass: HomeAssistant, + reolink_connect: MagicMock, + config_entry: MockConfigEntry, +) -> None: + """Test browsing a Reolink camera with unsupported stream encoding.""" + entry_id = config_entry.entry_id + + with patch("homeassistant.components.reolink.PLATFORMS", [Platform.CAMERA]): + assert await hass.config_entries.async_setup(entry_id) is True + await hass.async_block_till_done() + + browse_root_id = f"CAM|{entry_id}|{TEST_CHANNEL}" + + # browse resolution select/camera recording days when main encoding unsupported + mock_status = MagicMock() + mock_status.year = TEST_YEAR + mock_status.month = TEST_MONTH + mock_status.days = (TEST_DAY, TEST_DAY2) + reolink_connect.request_vod_files.return_value = ([mock_status], []) + reolink_connect.time.return_value = None + reolink_connect.get_encoding.return_value = "h265" + + browse = await async_browse_media(hass, f"{URI_SCHEME}{DOMAIN}/{browse_root_id}") + + browse_days_id = f"DAYS|{entry_id}|{TEST_CHANNEL}|sub" + browse_day_0_id = ( + f"DAY|{entry_id}|{TEST_CHANNEL}|sub|{TEST_YEAR}|{TEST_MONTH}|{TEST_DAY}" + ) + browse_day_1_id = ( + f"DAY|{entry_id}|{TEST_CHANNEL}|sub|{TEST_YEAR}|{TEST_MONTH}|{TEST_DAY2}" + ) + assert browse.domain == DOMAIN + assert browse.title == f"{TEST_NVR_NAME} Low res." + assert browse.identifier == browse_days_id + assert browse.children[0].identifier == browse_day_0_id + assert browse.children[1].identifier == browse_day_1_id + + +async def test_browsing_rec_playback_unsupported( + hass: HomeAssistant, + reolink_connect: MagicMock, + config_entry: MockConfigEntry, +) -> None: + """Test browsing a Reolink camera which does not support playback of recordings.""" + reolink_connect.api_version.return_value = 0 + + with patch("homeassistant.components.reolink.PLATFORMS", [Platform.CAMERA]): + assert await hass.config_entries.async_setup(config_entry.entry_id) is True + await hass.async_block_till_done() + + # browse root + browse = await async_browse_media(hass, f"{URI_SCHEME}{DOMAIN}") + + assert browse.domain == DOMAIN + assert browse.title == "Reolink" + assert browse.identifier is None + assert browse.children == [] + + +async def test_browsing_errors( + hass: HomeAssistant, + reolink_connect: MagicMock, + config_entry: MockConfigEntry, +) -> None: + """Test browsing a Reolink camera errors.""" + reolink_connect.api_version.return_value = 1 + + with patch("homeassistant.components.reolink.PLATFORMS", [Platform.CAMERA]): + assert await hass.config_entries.async_setup(config_entry.entry_id) is True + await hass.async_block_till_done() + + # browse root + with pytest.raises(Unresolvable): + await async_browse_media(hass, f"{URI_SCHEME}{DOMAIN}/UNKNOWN") + with pytest.raises(Unresolvable): + await async_resolve_media(hass, f"{URI_SCHEME}{DOMAIN}/UNKNOWN") + + +async def test_browsing_not_loaded( + hass: HomeAssistant, + reolink_connect: MagicMock, + config_entry: MockConfigEntry, +) -> None: + """Test browsing a Reolink camera integration which is not loaded.""" + reolink_connect.api_version.return_value = 1 + + with patch("homeassistant.components.reolink.PLATFORMS", [Platform.CAMERA]): + assert await hass.config_entries.async_setup(config_entry.entry_id) is True + await hass.async_block_till_done() + + reolink_connect.get_host_data = AsyncMock(side_effect=ReolinkError("Test error")) + config_entry2 = MockConfigEntry( + domain=const.DOMAIN, + unique_id=format_mac(TEST_MAC2), + data={ + CONF_HOST: TEST_HOST2, + CONF_USERNAME: TEST_USERNAME2, + CONF_PASSWORD: TEST_PASSWORD2, + CONF_PORT: TEST_PORT, + const.CONF_USE_HTTPS: TEST_USE_HTTPS, + }, + options={ + const.CONF_PROTOCOL: DEFAULT_PROTOCOL, + }, + title=TEST_NVR_NAME2, + ) + config_entry2.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry2.entry_id) is False + await hass.async_block_till_done() + + # browse root + browse = await async_browse_media(hass, f"{URI_SCHEME}{DOMAIN}") + + assert browse.domain == DOMAIN + assert browse.title == "Reolink" + assert browse.identifier is None + assert len(browse.children) == 1