Add the ability to change the way the clip files are structured

This commit is contained in:
Sebastian Goscik
2022-03-26 00:04:32 +00:00
parent af8ca90356
commit 381f90f497
3 changed files with 62 additions and 18 deletions

View File

@@ -81,6 +81,12 @@ Options:
multiple IDs. If being set as an environment multiple IDs. If being set as an environment
variable the IDs should be separated by variable the IDs should be separated by
whitespace. 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. -v, --verbose How verbose the logging output should be.
None: Only log info messages created by None: Only log info messages created by
@@ -121,6 +127,25 @@ always take priority over environment variables):
- `RCLONE_ARGS` - `RCLONE_ARGS`
- `IGNORE_CAMERAS` - `IGNORE_CAMERAS`
- `DETECTION_TYPES` - `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 ## Docker Container
You can run this tool as a container if you prefer with the following command. You can run this tool as a container if you prefer with the following command.

View File

@@ -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 " 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.", "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( @click.option(
'-v', '-v',
'--verbose', '--verbose',

View File

@@ -3,6 +3,7 @@ import asyncio
import json import json
import logging import logging
import pathlib import pathlib
import re
import shutil import shutil
from asyncio.exceptions import TimeoutError from asyncio.exceptions import TimeoutError
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
@@ -175,7 +176,8 @@ class UnifiProtectBackup:
rclone_args (str): Extra args passed directly to `rclone rcat`. rclone_args (str): Extra args passed directly to `rclone rcat`.
ignore_cameras (List[str]): List of camera IDs for which to not backup events 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. 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 _download_queue (asyncio.Queue): Queue of events that need to be backed up
_unsub (Callable): Unsubscribe from the websocket callback _unsub (Callable): Unsubscribe from the websocket callback
_has_ffprobe (bool): If ffprobe was found on the host _has_ffprobe (bool): If ffprobe was found on the host
@@ -192,6 +194,7 @@ class UnifiProtectBackup:
rclone_args: str, rclone_args: str,
detection_types: List[str], detection_types: List[str],
ignore_cameras: List[str], ignore_cameras: List[str],
file_structure_format: str,
verbose: int, verbose: int,
port: int = 443, port: int = 443,
): ):
@@ -213,6 +216,7 @@ class UnifiProtectBackup:
`rclone` (https://rclone.org/docs/#bwlimit-bandwidth-spec) `rclone` (https://rclone.org/docs/#bwlimit-bandwidth-spec)
detection_types (List[str]): List of which detection types to backup. 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. 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. verbose (int): How verbose to setup logging, see :func:`setup_logging` for details.
""" """
setup_logging(verbose) setup_logging(verbose)
@@ -233,10 +237,12 @@ class UnifiProtectBackup:
logger.debug(f" {ignore_cameras=}") logger.debug(f" {ignore_cameras=}")
logger.debug(f" {verbose=}") logger.debug(f" {verbose=}")
logger.debug(f" {detection_types=}") logger.debug(f" {detection_types=}")
logger.debug(f" {file_structure_format=}")
self.rclone_destination = rclone_destination self.rclone_destination = rclone_destination
self.retention = retention self.retention = retention
self.rclone_args = rclone_args self.rclone_args = rclone_args
self.file_structure_format = file_structure_format
self.address = address self.address = address
self.port = port self.port = port
@@ -589,12 +595,15 @@ class UnifiProtectBackup:
async def generate_file_path(self, event: Event) -> pathlib.Path: async def generate_file_path(self, event: Event) -> pathlib.Path:
"""Generates the rclone destination path for the provided event. """Generates the rclone destination path for the provided event.
Generates paths in the following structure: Generates rclone destination path for the given even based upon the format string
:: in `self.file_structure_format`.
rclone_destination
|- Camera Name Provides the following fields to the format string:
|- {Date} event: The `Event` object as per
|- {start timestamp} {event type} ({detections}).mp4 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: Args:
event: The event for which to create an output path 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 pathlib.Path: The rclone path the event should be backed up to
""" """
path = pathlib.Path(self.rclone_destination)
assert isinstance(event.camera_id, str) assert isinstance(event.camera_id, str)
path /= await self._get_camera_name(event.camera_id) # directory per camera assert isinstance(event.start, datetime)
path /= event.start.strftime("%Y-%m-%d") # Directory per day 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: file_path = self.file_structure_format.format(**format_context)
detections = " ".join(event.smart_detect_types) file_path = re.sub(r'[^\w\-_\.\(\)/ ]', '', file_path) # Sanitize any invalid chars
file_name += f" ({detections})"
file_name += ".mp4"
path /= file_name return pathlib.Path(f"{self.rclone_destination}/{file_path}")
return path
async def _get_camera_name(self, id: str): async def _get_camera_name(self, id: str):
try: try: