diff --git a/unifi_protect_backup/cli.py b/unifi_protect_backup/cli.py index 2834df7..29857f8 100644 --- a/unifi_protect_backup/cli.py +++ b/unifi_protect_backup/cli.py @@ -7,13 +7,15 @@ import click from aiorun import run # type: ignore from dateutil.relativedelta import relativedelta -from uiprotect.data.types import SmartDetectObjectType +from uiprotect.data.types import SmartDetectObjectType, SmartDetectAudioType from unifi_protect_backup import __version__ from unifi_protect_backup.unifi_protect_backup_core import UnifiProtectBackup from unifi_protect_backup.utils import human_readable_to_float -DETECTION_TYPES = ["motion", "ring", "line"] + SmartDetectObjectType.values() +DETECTION_TYPES = ["motion", "ring", "line", "fingerprint", "nfc"] +DETECTION_TYPES += [t for t in SmartDetectObjectType.values() if t not in SmartDetectAudioType.values()] +DETECTION_TYPES += [f"{t}" for t in SmartDetectAudioType.values()] def _parse_detection_types(ctx, param, value): diff --git a/unifi_protect_backup/downloader.py b/unifi_protect_backup/downloader.py index 283f193..3a5e72e 100644 --- a/unifi_protect_backup/downloader.py +++ b/unifi_protect_backup/downloader.py @@ -114,7 +114,7 @@ class VideoDownloader: output_queue_max_size = human_readable_size(self.upload_queue.maxsize) self.logger.debug(f"Video Download Buffer: {output_queue_current_size}/{output_queue_max_size}") self.logger.debug(f" Camera: {await get_camera_name(self._protect, event.camera_id)}") - if event.type == EventType.SMART_DETECT: + if event.type in [EventType.SMART_DETECT, EventType.SMART_AUDIO_DETECT]: self.logger.debug(f" Type: {event.type.value} ({', '.join(event.smart_detect_types)})") else: self.logger.debug(f" Type: {event.type.value}") diff --git a/unifi_protect_backup/downloader_experimental.py b/unifi_protect_backup/downloader_experimental.py index 2a04932..072a7fc 100644 --- a/unifi_protect_backup/downloader_experimental.py +++ b/unifi_protect_backup/downloader_experimental.py @@ -114,7 +114,7 @@ class VideoDownloaderExperimental: output_queue_max_size = human_readable_size(self.upload_queue.maxsize) self.logger.debug(f"Video Download Buffer: {output_queue_current_size}/{output_queue_max_size}") self.logger.debug(f" Camera: {await get_camera_name(self._protect, event.camera_id)}") - if event.type == EventType.SMART_DETECT: + if event.type in [EventType.SMART_DETECT, EventType.SMART_AUDIO_DETECT]: self.logger.debug(f" Type: {event.type.value} ({', '.join(event.smart_detect_types)})") else: self.logger.debug(f" Type: {event.type.value}") diff --git a/unifi_protect_backup/event_listener.py b/unifi_protect_backup/event_listener.py index 6aa568e..9e1b13d 100644 --- a/unifi_protect_backup/event_listener.py +++ b/unifi_protect_backup/event_listener.py @@ -3,14 +3,15 @@ import asyncio import logging from time import sleep -from typing import List +from typing import Set from uiprotect.api import ProtectApiClient from uiprotect.websocket import WebsocketState from uiprotect.data.nvr import Event -from uiprotect.data.types import EventType from uiprotect.data.websocket import WSAction, WSSubscriptionMessage +from unifi_protect_backup.utils import wanted_event_type + logger = logging.getLogger(__name__) @@ -21,27 +22,27 @@ class EventListener: self, event_queue: asyncio.Queue, protect: ProtectApiClient, - detection_types: List[str], - ignore_cameras: List[str], - cameras: List[str], + detection_types: Set[str], + ignore_cameras: Set[str], + cameras: Set[str], ): """Init. Args: event_queue (asyncio.Queue): Queue to place events to backup on protect (ProtectApiClient): UniFI Protect API client to use - detection_types (List[str]): Desired Event detection types to look for - ignore_cameras (List[str]): Cameras IDs to ignore events from - cameras (List[str]): Cameras IDs to ONLY include events from + detection_types (Set[str]): Desired Event detection types to look for + ignore_cameras (Set[str]): Cameras IDs to ignore events from + cameras (Set[str]): Cameras IDs to ONLY include events from """ self._event_queue: asyncio.Queue = event_queue self._protect: ProtectApiClient = protect self._unsub = None self._unsub_websocketstate = None - self.detection_types: List[str] = detection_types - self.ignore_cameras: List[str] = ignore_cameras - self.cameras: List[str] = cameras + self.detection_types: Set[str] = detection_types + self.ignore_cameras: Set[str] = ignore_cameras + self.cameras: Set[str] = cameras async def start(self): """Run main Loop.""" @@ -63,35 +64,10 @@ class EventListener: assert isinstance(msg.new_obj, Event) if msg.action != WSAction.UPDATE: return - if msg.new_obj.camera_id in self.ignore_cameras: - return - if self.cameras and msg.new_obj.camera_id not in self.cameras: - return if "end" not in msg.changed_data: return - if msg.new_obj.type not in [ - EventType.MOTION, - EventType.SMART_DETECT, - EventType.RING, - EventType.SMART_DETECT_LINE, - ]: + if not wanted_event_type(msg.new_obj, self.detection_types, self.cameras, self.ignore_cameras): return - if msg.new_obj.type is EventType.MOTION and "motion" not in self.detection_types: - logger.extra_debug(f"Skipping unwanted motion detection event: {msg.new_obj.id}") # type: ignore - return - if msg.new_obj.type is EventType.RING and "ring" not in self.detection_types: - logger.extra_debug(f"Skipping unwanted ring event: {msg.new_obj.id}") # type: ignore - return - if msg.new_obj.type is EventType.SMART_DETECT_LINE and "line" not in self.detection_types: - logger.extra_debug(f"Skipping unwanted line event: {msg.new_obj.id}") # type: ignore - return - elif msg.new_obj.type is EventType.SMART_DETECT: - for event_smart_detection_type in msg.new_obj.smart_detect_types: - if event_smart_detection_type not in self.detection_types: - logger.extra_debug( # type: ignore - f"Skipping unwanted {event_smart_detection_type} detection event: {msg.new_obj.id}" - ) - return # TODO: Will this even work? I think it will block the async loop while self._event_queue.full(): diff --git a/unifi_protect_backup/missing_event_checker.py b/unifi_protect_backup/missing_event_checker.py index 3f2cf79..08d54fa 100644 --- a/unifi_protect_backup/missing_event_checker.py +++ b/unifi_protect_backup/missing_event_checker.py @@ -3,7 +3,7 @@ import asyncio import logging from datetime import datetime -from typing import AsyncIterator, List +from typing import AsyncIterator, List, Set import aiosqlite from dateutil.relativedelta import relativedelta @@ -12,6 +12,7 @@ from uiprotect.data.nvr import Event from uiprotect.data.types import EventType from unifi_protect_backup import VideoDownloader, VideoUploader +from unifi_protect_backup.utils import EVENT_TYPES_MAP, wanted_event_type logger = logging.getLogger(__name__) @@ -27,9 +28,9 @@ class MissingEventChecker: downloader: VideoDownloader, uploaders: List[VideoUploader], retention: relativedelta, - detection_types: List[str], - ignore_cameras: List[str], - cameras: List[str], + detection_types: Set[str], + ignore_cameras: Set[str], + cameras: Set[str], interval: int = 60 * 5, ) -> None: """Init. @@ -41,9 +42,9 @@ class MissingEventChecker: downloader (VideoDownloader): Downloader to check for on-going downloads uploaders (List[VideoUploader]): Uploaders to check for on-going uploads retention (relativedelta): Retention period to limit search window - detection_types (List[str]): Detection types wanted to limit search - ignore_cameras (List[str]): Ignored camera IDs to limit search - cameras (List[str]): Included (ONLY) camera IDs to limit search + detection_types (Set[str]): Detection types wanted to limit search + ignore_cameras (Set[str]): Ignored camera IDs to limit search + cameras (Set[str]): Included (ONLY) camera IDs to limit search interval (int): How frequently, in seconds, to check for missing events, """ @@ -53,9 +54,9 @@ class MissingEventChecker: self._downloader: VideoDownloader = downloader self._uploaders: List[VideoUploader] = uploaders self.retention: relativedelta = retention - self.detection_types: List[str] = detection_types - self.ignore_cameras: List[str] = ignore_cameras - self.cameras: List[str] = cameras + self.detection_types: Set[str] = detection_types + self.ignore_cameras: Set[str] = ignore_cameras + self.cameras: Set[str] = cameras self.interval: int = interval async def _get_missing_events(self) -> AsyncIterator[Event]: @@ -69,12 +70,7 @@ class MissingEventChecker: events_chunk = await self._protect.get_events( start=start_time, end=end_time, - types=[ - EventType.MOTION, - EventType.SMART_DETECT, - EventType.RING, - EventType.SMART_DETECT_LINE, - ], + types=list(EVENT_TYPES_MAP.keys()), limit=chunk_size, ) @@ -109,35 +105,23 @@ class MissingEventChecker: if current_upload is not None: uploading_event_ids.add(current_upload.id) - missing_event_ids = set(unifi_events.keys()) - (db_event_ids | downloading_event_ids | uploading_event_ids) + missing_events = { + event_id: event + for event_id, event in unifi_events.items() + if event_id not in (db_event_ids | downloading_event_ids | uploading_event_ids) + } # Exclude events of unwanted types - def wanted_event_type(event_id, unifi_events=unifi_events): - event = unifi_events[event_id] - if event.start is None or event.end is None: - return False # This event is still on-going - if event.camera_id in self.ignore_cameras: - return False - if self.cameras and event.camera_id not in self.cameras: - return False - if event.type is EventType.MOTION and "motion" not in self.detection_types: - return False - if event.type is EventType.RING and "ring" not in self.detection_types: - return False - if event.type is EventType.SMART_DETECT_LINE and "line" not in self.detection_types: - return False - elif event.type is EventType.SMART_DETECT: - for event_smart_detection_type in event.smart_detect_types: - if event_smart_detection_type not in self.detection_types: - return False - return True - - wanted_event_ids = set(filter(wanted_event_type, missing_event_ids)) + wanted_events = { + event_id: event + for event_id, event in missing_events.items() + if wanted_event_type(event, self.detection_types, self.cameras, self.ignore_cameras) + } # Yeild events one by one to allow the async loop to start other task while # waiting on the full list of events - for id in wanted_event_ids: - yield unifi_events[id] + for event in wanted_events.values(): + yield event # Last chunk was in-complete, we can stop now if len(events_chunk) < chunk_size: diff --git a/unifi_protect_backup/unifi_protect_backup_core.py b/unifi_protect_backup/unifi_protect_backup_core.py index 4762f56..0cd05ee 100644 --- a/unifi_protect_backup/unifi_protect_backup_core.py +++ b/unifi_protect_backup/unifi_protect_backup_core.py @@ -120,6 +120,7 @@ class UnifiProtectBackup: use_experimental_downloader (bool): Use the new experimental downloader (the same method as used by the webUI) parallel_uploads (int): Max number of parallel uploads to allow + """ self.color_logging = color_logging setup_logging(verbose, self.color_logging) @@ -180,11 +181,11 @@ class UnifiProtectBackup: verify_ssl=self.verify_ssl, subscribed_models={ModelType.EVENT}, ) - self.ignore_cameras = ignore_cameras - self.cameras = cameras + self.ignore_cameras = set(ignore_cameras) + self.cameras = set(cameras) self._download_queue: asyncio.Queue = asyncio.Queue() self._unsub: Callable[[], None] - self.detection_types = detection_types + self.detection_types = set(detection_types) self._has_ffprobe = False self._sqlite_path = sqlite_path self._db = None diff --git a/unifi_protect_backup/uploader.py b/unifi_protect_backup/uploader.py index 1d2c337..df8c9ba 100644 --- a/unifi_protect_backup/uploader.py +++ b/unifi_protect_backup/uploader.py @@ -45,6 +45,7 @@ class VideoUploader: file_structure_format (str): format string for how to structure the uploaded files db (aiosqlite.Connection): Async SQlite database connection color_logging (bool): Whether or not to add color to logging output + """ self._protect: ProtectApiClient = protect self.upload_queue: VideoQueue = upload_queue diff --git a/unifi_protect_backup/utils.py b/unifi_protect_backup/utils.py index d563f5a..ba82a48 100644 --- a/unifi_protect_backup/utils.py +++ b/unifi_protect_backup/utils.py @@ -4,12 +4,13 @@ import asyncio import logging import re from datetime import datetime -from typing import Optional +from typing import Optional, Set from apprise import NotifyType from async_lru import alru_cache from uiprotect import ProtectApiClient from uiprotect.data.nvr import Event +from uiprotect.data.types import EventType, SmartDetectObjectType, SmartDetectAudioType from unifi_protect_backup import notifications @@ -454,3 +455,38 @@ async def wait_until(dt): """Sleep until the specified datetime.""" now = datetime.now() await asyncio.sleep((dt - now).total_seconds()) + + +EVENT_TYPES_MAP = { + EventType.MOTION: {"motion"}, + EventType.RING: {"ring"}, + EventType.SMART_DETECT_LINE: {"line"}, + EventType.FINGERPRINT_IDENTIFIED: {"fingerprint"}, + EventType.NFC_CARD_SCANNED: {"nfc"}, + EventType.SMART_DETECT: {t for t in SmartDetectObjectType.values() if t not in SmartDetectAudioType.values()}, + EventType.SMART_AUDIO_DETECT: {f"{t}" for t in SmartDetectAudioType.values()}, +} + + +def wanted_event_type(event, wanted_detection_types: Set[str], cameras: Set[str], ignore_cameras: Set[str]): + """Return True if this event is one we want.""" + if event.start is None or event.end is None: + return False # This event is still on-going + + if event.camera_id in ignore_cameras: + return False + + if cameras and event.camera_id not in cameras: + return False + + if event.type not in EVENT_TYPES_MAP: + return False + + if event.type in [EventType.SMART_DETECT, EventType.SMART_AUDIO_DETECT]: + detection_types = set(event.smart_detect_types) + else: + detection_types = EVENT_TYPES_MAP[event.type] + if not detection_types & wanted_detection_types: # No intersection + return False + + return True