diff --git a/README.md b/README.md index ce4c556..6c2189c 100644 --- a/README.md +++ b/README.md @@ -81,6 +81,12 @@ Options: multiple IDs. If being set as an environment variable the IDs should be separated by whitespace. + --file-structure-format TEXT A Python format string used to generate the + file structure/name on the rclone remote.For + details of the fields available, see the + projects `README.md` file. [default: {camer + a_name}/{event.start:%Y-%m-%d}/{event.end:%Y + -%m-%dT%H-%M-%S} {detection_type}.mp4] -v, --verbose How verbose the logging output should be. None: Only log info messages created by @@ -121,6 +127,25 @@ always take priority over environment variables): - `RCLONE_ARGS` - `IGNORE_CAMERAS` - `DETECTION_TYPES` +- `FILE_STRUCTURE_FORMAT` + +## File path formatting + +By default, the application will save clips in the following structure on the provided rclone remote: +``` +{camera_name}/{event.start:%Y-%m-%d}/{event.end:%Y-%m-%dT%H-%M-%S} {detection_type}.mp4 +``` +If you wish for the clips to be structured differently you can do this using the `--file-structure-format` +option. It uses standard [python format string syntax](https://docs.python.org/3/library/string.html#formatstrings). + +The following fields are provided to the format string: + - *event:* The `Event` object as per https://github.com/briis/pyunifiprotect/blob/master/pyunifiprotect/data/nvr.py + - *duration_seconds:* The duration of the event in seconds + - *detection_type:* A nicely formatted list of the event detection type and the smart detection types (if any) + - *camera_name:* The name of the camera that generated this event + +You can optionally format the `event.start`/`event.end` timestamps as per the [`strftime` format](https://docs.python.org/3/library/datetime.html#strftime-strptime-behavior) by appening it after a `:` e.g to get just the date without the time: `{event.start:%Y-%m-%d}` + ## Docker Container You can run this tool as a container if you prefer with the following command. diff --git a/unifi_protect_backup/cli.py b/unifi_protect_backup/cli.py index b9dd77e..105f709 100644 --- a/unifi_protect_backup/cli.py +++ b/unifi_protect_backup/cli.py @@ -73,6 +73,14 @@ def _parse_detection_types(ctx, param, value): help="IDs of cameras for which events should not be backed up. Use multiple times to ignore " "multiple IDs. If being set as an environment variable the IDs should be separated by whitespace.", ) +@click.option( + '--file-structure-format', + envvar='FILE_STRUCTURE_FORMAT', + default="{camera_name}/{event.start:%Y-%m-%d}/{event.end:%Y-%m-%dT%H-%M-%S} {detection_type}.mp4", + show_default=True, + help="A Python format string used to generate the file structure/name on the rclone remote." + "For details of the fields available, see the projects `README.md` file.", +) @click.option( '-v', '--verbose', diff --git a/unifi_protect_backup/unifi_protect_backup.py b/unifi_protect_backup/unifi_protect_backup.py index 9f89cda..30a3b1a 100644 --- a/unifi_protect_backup/unifi_protect_backup.py +++ b/unifi_protect_backup/unifi_protect_backup.py @@ -3,6 +3,7 @@ import asyncio import json import logging import pathlib +import re import shutil from asyncio.exceptions import TimeoutError from datetime import datetime, timedelta, timezone @@ -175,7 +176,8 @@ class UnifiProtectBackup: rclone_args (str): Extra args passed directly to `rclone rcat`. ignore_cameras (List[str]): List of camera IDs for which to not backup events verbose (int): How verbose to setup logging, see :func:`setup_logging` for details. - detection_types(List[str]): List of which detection types to backup. + detection_types (List[str]): List of which detection types to backup. + file_structure_format (str): A Python format string for output file path _download_queue (asyncio.Queue): Queue of events that need to be backed up _unsub (Callable): Unsubscribe from the websocket callback _has_ffprobe (bool): If ffprobe was found on the host @@ -192,6 +194,7 @@ class UnifiProtectBackup: rclone_args: str, detection_types: List[str], ignore_cameras: List[str], + file_structure_format: str, verbose: int, port: int = 443, ): @@ -213,6 +216,7 @@ class UnifiProtectBackup: `rclone` (https://rclone.org/docs/#bwlimit-bandwidth-spec) detection_types (List[str]): List of which detection types to backup. ignore_cameras (List[str]): List of camera IDs for which to not backup events. + file_structure_format (str): A Python format string for output file path. verbose (int): How verbose to setup logging, see :func:`setup_logging` for details. """ setup_logging(verbose) @@ -233,10 +237,12 @@ class UnifiProtectBackup: logger.debug(f" {ignore_cameras=}") logger.debug(f" {verbose=}") logger.debug(f" {detection_types=}") + logger.debug(f" {file_structure_format=}") self.rclone_destination = rclone_destination self.retention = retention self.rclone_args = rclone_args + self.file_structure_format = file_structure_format self.address = address self.port = port @@ -589,12 +595,15 @@ class UnifiProtectBackup: async def generate_file_path(self, event: Event) -> pathlib.Path: """Generates the rclone destination path for the provided event. - Generates paths in the following structure: - :: - rclone_destination - |- Camera Name - |- {Date} - |- {start timestamp} {event type} ({detections}).mp4 + Generates rclone destination path for the given even based upon the format string + in `self.file_structure_format`. + + Provides the following fields to the format string: + event: The `Event` object as per + https://github.com/briis/pyunifiprotect/blob/master/pyunifiprotect/data/nvr.py + duration_seconds: The duration of the event in seconds + detection_type: A nicely formatted list of the event detection type and the smart detection types (if any) + camera_name: The name of the camera that generated this event Args: event: The event for which to create an output path @@ -603,21 +612,23 @@ class UnifiProtectBackup: pathlib.Path: The rclone path the event should be backed up to """ - path = pathlib.Path(self.rclone_destination) assert isinstance(event.camera_id, str) - path /= await self._get_camera_name(event.camera_id) # directory per camera - path /= event.start.strftime("%Y-%m-%d") # Directory per day + assert isinstance(event.start, datetime) + assert isinstance(event.end, datetime) - file_name = f"{event.start.strftime('%Y-%m-%dT%H-%M-%S')} {event.type}" + format_context = { + "event": event, + "duration_seconds": (event.end - event.start).total_seconds(), + "detection_type": f"{event.type} ({' '.join(event.smart_detect_types)})" + if event.smart_detect_types + else f"{event.type}", + "camera_name": await self._get_camera_name(event.camera_id), + } - if event.smart_detect_types: - detections = " ".join(event.smart_detect_types) - file_name += f" ({detections})" - file_name += ".mp4" + file_path = self.file_structure_format.format(**format_context) + file_path = re.sub(r'[^\w\-_\.\(\)/ ]', '', file_path) # Sanitize any invalid chars - path /= file_name - - return path + return pathlib.Path(f"{self.rclone_destination}/{file_path}") async def _get_camera_name(self, id: str): try: