From 0d816946409f4de0c7ba66bada905216a80c9552 Mon Sep 17 00:00:00 2001 From: starkillerOG Date: Mon, 26 May 2025 16:20:55 +0200 Subject: [PATCH] Add event browsing to Reolink recordings (#144259) --- .../components/reolink/media_source.py | 51 ++++++++++++++++++- tests/components/reolink/test_media_source.py | 48 +++++++++++++++-- 2 files changed, 94 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/reolink/media_source.py b/homeassistant/components/reolink/media_source.py index 49257128a2d..36a2f3c5489 100644 --- a/homeassistant/components/reolink/media_source.py +++ b/homeassistant/components/reolink/media_source.py @@ -7,6 +7,7 @@ import logging from reolink_aio.api import DUAL_LENS_MODELS from reolink_aio.enums import VodRequestType +from reolink_aio.typings import VOD_trigger from homeassistant.components.camera import DOMAIN as CAM_DOMAIN, DynamicStreamSettings from homeassistant.components.media_player import MediaClass, MediaType @@ -152,6 +153,26 @@ class ReolinkVODMediaSource(MediaSource): int(month_str), int(day_str), ) + if item_type == "EVE": + ( + _, + config_entry_id, + channel_str, + stream, + year_str, + month_str, + day_str, + event, + ) = identifier + return await self._async_generate_camera_files( + config_entry_id, + int(channel_str), + stream, + int(year_str), + int(month_str), + int(day_str), + event, + ) raise Unresolvable(f"Unknown media item '{item.identifier}' during browsing.") @@ -352,6 +373,7 @@ class ReolinkVODMediaSource(MediaSource): year: int, month: int, day: int, + event: str | None = None, ) -> BrowseMediaSource: """Return all recording files on a specific day of a Reolink camera.""" host = get_host(self.hass, config_entry_id) @@ -368,9 +390,34 @@ class ReolinkVODMediaSource(MediaSource): month, day, ) + event_trigger = VOD_trigger[event] if event is not None else None _, vod_files = await host.api.request_vod_files( - channel, start, end, stream=stream, split_time=VOD_SPLIT_TIME + channel, + start, + end, + stream=stream, + split_time=VOD_SPLIT_TIME, + trigger=event_trigger, ) + + if event is None and host.api.is_nvr and not host.api.is_hub: + triggers = VOD_trigger.NONE + for file in vod_files: + triggers |= file.triggers + + children.extend( + BrowseMediaSource( + domain=DOMAIN, + identifier=f"EVE|{config_entry_id}|{channel}|{stream}|{year}|{month}|{day}|{trigger.name}", + media_class=MediaClass.DIRECTORY, + media_content_type=MediaType.PLAYLIST, + title=str(trigger.name).title(), + can_play=False, + can_expand=True, + ) + for trigger in triggers + ) + for file in vod_files: file_name = f"{file.start_time.time()} {file.duration}" if file.triggers != file.triggers.NONE: @@ -397,6 +444,8 @@ class ReolinkVODMediaSource(MediaSource): ) if host.api.model in DUAL_LENS_MODELS: title = f"{host.api.camera_name(channel)} lens {channel} {res_name(stream)} {year}/{month}/{day}" + if event: + title = f"{title} {event.title()}" return BrowseMediaSource( domain=DOMAIN, diff --git a/tests/components/reolink/test_media_source.py b/tests/components/reolink/test_media_source.py index 59868514226..126d445ca01 100644 --- a/tests/components/reolink/test_media_source.py +++ b/tests/components/reolink/test_media_source.py @@ -6,6 +6,7 @@ from unittest.mock import AsyncMock, MagicMock, patch import pytest from reolink_aio.exceptions import ReolinkError +from reolink_aio.typings import VOD_trigger from homeassistant.components.media_source import ( DOMAIN as MEDIA_SOURCE_DOMAIN, @@ -16,6 +17,7 @@ from homeassistant.components.media_source import ( ) from homeassistant.components.reolink.config_flow import DEFAULT_PROTOCOL from homeassistant.components.reolink.const import CONF_BC_PORT, CONF_USE_HTTPS, DOMAIN +from homeassistant.components.reolink.media_source import VOD_SPLIT_TIME from homeassistant.components.stream import DOMAIN as MEDIA_STREAM_DOMAIN from homeassistant.const import ( CONF_HOST, @@ -53,6 +55,8 @@ TEST_HOUR = 13 TEST_MINUTE = 12 TEST_START = f"{TEST_YEAR}{TEST_MONTH}{TEST_DAY}{TEST_HOUR}{TEST_MINUTE}" TEST_END = f"{TEST_YEAR}{TEST_MONTH}{TEST_DAY}{TEST_HOUR}{TEST_MINUTE + 5}" +TEST_START_TIME = datetime(TEST_YEAR, TEST_MONTH, TEST_DAY, 0, 0) +TEST_END_TIME = datetime(TEST_YEAR, TEST_MONTH, TEST_DAY, 23, 59, 59) TEST_FILE_NAME = f"{TEST_START}00" TEST_FILE_NAME_MP4 = f"{TEST_START}00.mp4" TEST_STREAM = "main" @@ -212,13 +216,12 @@ async def test_browsing( # 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.start_time = TEST_START_TIME mock_vod_file.start_time_id = TEST_START mock_vod_file.end_time_id = TEST_END - mock_vod_file.duration = timedelta(minutes=15) + mock_vod_file.duration = timedelta(minutes=5) mock_vod_file.file_name = TEST_FILE_NAME + mock_vod_file.triggers = VOD_trigger.PERSON 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}") @@ -232,9 +235,46 @@ async def test_browsing( ) assert browse.identifier == browse_files_id assert browse.children[0].identifier == browse_file_id + reolink_connect.request_vod_files.assert_called_with( + int(TEST_CHANNEL), + TEST_START_TIME, + TEST_END_TIME, + stream=TEST_STREAM, + split_time=VOD_SPLIT_TIME, + trigger=None, + ) reolink_connect.model = TEST_HOST_MODEL + # browse event trigger person on a NVR + reolink_connect.is_nvr = True + browse_event_person_id = f"EVE|{entry_id}|{TEST_CHANNEL}|{TEST_STREAM}|{TEST_YEAR}|{TEST_MONTH}|{TEST_DAY}|{VOD_trigger.PERSON.name}" + + browse = await async_browse_media(hass, f"{URI_SCHEME}{DOMAIN}/{browse_day_0_id}") + assert browse.children[0].identifier == browse_event_person_id + + browse = await async_browse_media( + hass, f"{URI_SCHEME}{DOMAIN}/{browse_event_person_id}" + ) + + assert browse.domain == DOMAIN + assert ( + browse.title + == f"{TEST_NVR_NAME} High res. {TEST_YEAR}/{TEST_MONTH}/{TEST_DAY} Person" + ) + assert browse.identifier == browse_files_id + assert browse.children[0].identifier == browse_file_id + reolink_connect.request_vod_files.assert_called_with( + int(TEST_CHANNEL), + TEST_START_TIME, + TEST_END_TIME, + stream=TEST_STREAM, + split_time=VOD_SPLIT_TIME, + trigger=VOD_trigger.PERSON, + ) + + reolink_connect.is_nvr = False + async def test_browsing_h265_encoding( hass: HomeAssistant,