diff --git a/README.md b/README.md index 2a15a12..7a6d836 100644 --- a/README.md +++ b/README.md @@ -131,6 +131,11 @@ Options: Common usage for this would be to execute a permanent delete instead of using the recycle bin on a destination. Google Drive example: `--drive-use-trash=false` + --postprocess-binary TEXT Optional binary or executable script to run after having + downloaded a video. This can e.g. be a bash script with a CURL + command to post-process the video (detection, move, ...). The + script / binary receives the path where the video is persisted + as first and only argument. --detection-types TEXT A comma separated list of which types of detections to backup. Valid options are: `motion`, `person`, `vehicle`, `ring` [default: motion,person,vehicle,ring] @@ -212,6 +217,7 @@ always take priority over environment variables): - `RCLONE_DESTINATION` - `RCLONE_ARGS` - `RCLONE_PURGE_ARGS` +- `POSTPROCESS_BINARY` - `IGNORE_CAMERAS` - `DETECTION_TYPES` - `FILE_STRUCTURE_FORMAT` @@ -265,6 +271,37 @@ such backends. If you are running on a linux host you can setup `rclone` to use `tmpfs` (which is in RAM) to store its temp files, but this will significantly increase memory usage of the tool. +## Prostprocessing + +To perform additional detection / cleaning / moving / ... on a video post downloading: +- Use `--postprocess-binary` or env. var: `POSTPROCESS_BINARY` + +The binary / executable script receives a first argument with the storage location for the downloaded video. You can easily mount a script from a local filesystem to the container: + +```bash +rm -r /tmp/unifi ; docker rmi ghcr.io/ep1cman/unifi-protect-backup ; poetry build && docker buildx build . -t ghcr.io/ep1cman/unifi-protect-backup ; + docker run --rm \ + -e POSTPROCESS_BINARY='/postprocess.sh' \ + -v '/My/Local/Folder/postprocess.sh':/postprocess.sh \ + ghcr.io/ep1cman/unifi-protect-backup +``` + +The script can be as simple as this (to display the upload path inside the container): + +```bash +#!/bin/bash +echo "$1" +``` + +The logging output will show the stdout and stderr for the postprocess script/binary: + +``` +Uploaded + -- Postprocessing: 'local:/data/camname/date/vidname.pm4' returned status code: '0' + > STDOUT: /data/camname/date/vidname.pm4 + > STDERR: +``` + ### Running Docker Container (LINUX ONLY) Add the following arguments to your docker run command: ``` diff --git a/unifi_protect_backup/cli.py b/unifi_protect_backup/cli.py index 1cdba14..9233808 100644 --- a/unifi_protect_backup/cli.py +++ b/unifi_protect_backup/cli.py @@ -89,6 +89,12 @@ def parse_rclone_retention(ctx, param, retention) -> relativedelta: "be to execute a permanent delete instead of using the recycle bin on a destination. " "Google Drive example: `--drive-use-trash=false`", ) +@click.option( + "--postprocess-binary", + default="", + envvar="POSTPROCESS_BINARY", + help="Optional path to binary to postprocess the processed video, gets video destination path as argument." +) @click.option( "--detection-types", envvar="DETECTION_TYPES", diff --git a/unifi_protect_backup/unifi_protect_backup_core.py b/unifi_protect_backup/unifi_protect_backup_core.py index 18715ba..8a5920f 100644 --- a/unifi_protect_backup/unifi_protect_backup_core.py +++ b/unifi_protect_backup/unifi_protect_backup_core.py @@ -62,6 +62,7 @@ class UnifiProtectBackup: retention: relativedelta, rclone_args: str, rclone_purge_args: str, + postprocess_binary: str, detection_types: List[str], ignore_cameras: List[str], file_structure_format: str, @@ -94,6 +95,7 @@ class UnifiProtectBackup: rclone_args (str): A bandwidth limit which is passed to the `--bwlimit` argument of `rclone` (https://rclone.org/docs/#bwlimit-bandwidth-spec) rclone_purge_args (str): Optional extra arguments to pass to `rclone delete` directly. + postprocess_binary (str): Optional path to a binary that gets called to postprocess, with download location as argument. 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. @@ -132,6 +134,7 @@ class UnifiProtectBackup: logger.debug(f" {retention=}") logger.debug(f" {rclone_args=}") logger.debug(f" {rclone_purge_args=}") + logger.debug(f" {postprocess_binary=}") logger.debug(f" {ignore_cameras=}") logger.debug(f" {verbose=}") logger.debug(f" {detection_types=}") @@ -149,6 +152,7 @@ class UnifiProtectBackup: self.retention = retention self.rclone_args = rclone_args self.rclone_purge_args = rclone_purge_args + self.postprocess_binary = postprocess_binary self.file_structure_format = file_structure_format self.address = address @@ -270,6 +274,7 @@ class UnifiProtectBackup: self.file_structure_format, self._db, self.color_logging, + self.postprocess_binary, ) tasks.append(uploader.start()) diff --git a/unifi_protect_backup/uploader.py b/unifi_protect_backup/uploader.py index 2a860bc..47f821b 100644 --- a/unifi_protect_backup/uploader.py +++ b/unifi_protect_backup/uploader.py @@ -34,6 +34,7 @@ class VideoUploader: file_structure_format: str, db: aiosqlite.Connection, color_logging: bool, + postprocess_binary: str, ): """Init. @@ -45,11 +46,13 @@ 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 + postprocess_binary (str): Optional postprocess binary path (output location as arg) """ self._protect: ProtectApiClient = protect self.upload_queue: VideoQueue = upload_queue self._rclone_destination: str = rclone_destination self._rclone_args: str = rclone_args + self._postprocess_binary: str = postprocess_binary self._file_structure_format: str = file_structure_format self._db: aiosqlite.Connection = db self.current_event = None @@ -82,9 +85,16 @@ class VideoUploader: self.logger.debug(f" Destination: {destination}") try: - await self._upload_video(video, destination, self._rclone_args) + await self._upload_video(video, destination, self._rclone_args, self._postprocess_binary) await self._update_database(event, destination) self.logger.debug("Uploaded") + + # Postprocess + if self._postprocess_binary: + returncode_postprocess, stdout_postprocess, stderr_postprocess = await run_command(f'"{self._postprocess_binary}" "{destination}"') + self.logger.debug(f" -- Postprocessing: '{destination}' returned status code: '{returncode_postprocess}'") + self.logger.debug(f" > STDOUT: {stdout_postprocess.strip()}") + self.logger.debug(f" > STDERR: {stderr_postprocess.strip()}") except SubprocessException: self.logger.error(f" Failed to upload file: '{destination}'") @@ -93,7 +103,7 @@ class VideoUploader: except Exception as e: self.logger.error(f"Unexpected exception occurred, abandoning event {event.id}:", exc_info=e) - async def _upload_video(self, video: bytes, destination: pathlib.Path, rclone_args: str): + async def _upload_video(self, video: bytes, destination: pathlib.Path, rclone_args: str, postprocess_binary: str): """Upload video using rclone. In order to avoid writing to disk, the video file data is piped directly @@ -103,6 +113,7 @@ class VideoUploader: video (bytes): The data to be written to the file destination (pathlib.Path): Where rclone should write the file rclone_args (str): Optional extra arguments to pass to `rclone` + postprocess_binary (str): Optional extra path to postprocessing binary Raises: RuntimeError: If rclone returns a non-zero exit code