This commit is contained in:
Wietse Wind
2025-01-25 13:43:24 -08:00
committed by GitHub
4 changed files with 61 additions and 2 deletions

View File

@@ -131,6 +131,11 @@ Options:
Common usage for this would be to execute a permanent delete Common usage for this would be to execute a permanent delete
instead of using the recycle bin on a destination. Google Drive instead of using the recycle bin on a destination. Google Drive
example: `--drive-use-trash=false` 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. --detection-types TEXT A comma separated list of which types of detections to backup.
Valid options are: `motion`, `person`, `vehicle`, `ring` Valid options are: `motion`, `person`, `vehicle`, `ring`
[default: motion,person,vehicle,ring] [default: motion,person,vehicle,ring]
@@ -217,6 +222,7 @@ always take priority over environment variables):
- `RCLONE_DESTINATION` - `RCLONE_DESTINATION`
- `RCLONE_ARGS` - `RCLONE_ARGS`
- `RCLONE_PURGE_ARGS` - `RCLONE_PURGE_ARGS`
- `POSTPROCESS_BINARY`
- `IGNORE_CAMERAS` - `IGNORE_CAMERAS`
- `CAMERAS` - `CAMERAS`
- `DETECTION_TYPES` - `DETECTION_TYPES`
@@ -303,6 +309,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. 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) ### Running Docker Container (LINUX ONLY)
Add the following arguments to your docker run command: Add the following arguments to your docker run command:
``` ```

View File

@@ -90,6 +90,12 @@ def parse_rclone_retention(ctx, param, retention) -> relativedelta:
"be to execute a permanent delete instead of using the recycle bin on a destination. " "be to execute a permanent delete instead of using the recycle bin on a destination. "
"Google Drive example: `--drive-use-trash=false`", "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( @click.option(
"--detection-types", "--detection-types",
envvar="DETECTION_TYPES", envvar="DETECTION_TYPES",

View File

@@ -70,6 +70,7 @@ class UnifiProtectBackup:
retention: relativedelta, retention: relativedelta,
rclone_args: str, rclone_args: str,
rclone_purge_args: str, rclone_purge_args: str,
postprocess_binary: str,
detection_types: List[str], detection_types: List[str],
ignore_cameras: List[str], ignore_cameras: List[str],
cameras: List[str], cameras: List[str],
@@ -103,6 +104,7 @@ class UnifiProtectBackup:
rclone_args (str): A bandwidth limit which is passed to the `--bwlimit` argument of rclone_args (str): A bandwidth limit which is passed to the `--bwlimit` argument of
`rclone` (https://rclone.org/docs/#bwlimit-bandwidth-spec) `rclone` (https://rclone.org/docs/#bwlimit-bandwidth-spec)
rclone_purge_args (str): Optional extra arguments to pass to `rclone delete` directly. 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. 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.
cameras (List[str]): List of ONLY camera IDs for which to backup events. cameras (List[str]): List of ONLY camera IDs for which to backup events.
@@ -142,6 +144,7 @@ class UnifiProtectBackup:
logger.debug(f" {retention=}") logger.debug(f" {retention=}")
logger.debug(f" {rclone_args=}") logger.debug(f" {rclone_args=}")
logger.debug(f" {rclone_purge_args=}") logger.debug(f" {rclone_purge_args=}")
logger.debug(f" {postprocess_binary=}")
logger.debug(f" {ignore_cameras=}") logger.debug(f" {ignore_cameras=}")
logger.debug(f" {cameras=}") logger.debug(f" {cameras=}")
logger.debug(f" {verbose=}") logger.debug(f" {verbose=}")
@@ -160,6 +163,7 @@ class UnifiProtectBackup:
self.retention = retention self.retention = retention
self.rclone_args = rclone_args self.rclone_args = rclone_args
self.rclone_purge_args = rclone_purge_args self.rclone_purge_args = rclone_purge_args
self.postprocess_binary = postprocess_binary
self.file_structure_format = file_structure_format self.file_structure_format = file_structure_format
self.address = address self.address = address
@@ -282,6 +286,7 @@ class UnifiProtectBackup:
self.file_structure_format, self.file_structure_format,
self._db, self._db,
self.color_logging, self.color_logging,
self.postprocess_binary,
) )
tasks.append(uploader.start()) tasks.append(uploader.start())

View File

@@ -34,6 +34,7 @@ class VideoUploader:
file_structure_format: str, file_structure_format: str,
db: aiosqlite.Connection, db: aiosqlite.Connection,
color_logging: bool, color_logging: bool,
postprocess_binary: str,
): ):
"""Init. """Init.
@@ -45,11 +46,13 @@ class VideoUploader:
file_structure_format (str): format string for how to structure the uploaded files file_structure_format (str): format string for how to structure the uploaded files
db (aiosqlite.Connection): Async SQlite database connection db (aiosqlite.Connection): Async SQlite database connection
color_logging (bool): Whether or not to add color to logging output 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._protect: ProtectApiClient = protect
self.upload_queue: VideoQueue = upload_queue self.upload_queue: VideoQueue = upload_queue
self._rclone_destination: str = rclone_destination self._rclone_destination: str = rclone_destination
self._rclone_args: str = rclone_args self._rclone_args: str = rclone_args
self._postprocess_binary: str = postprocess_binary
self._file_structure_format: str = file_structure_format self._file_structure_format: str = file_structure_format
self._db: aiosqlite.Connection = db self._db: aiosqlite.Connection = db
self.current_event = None self.current_event = None
@@ -82,9 +85,16 @@ class VideoUploader:
self.logger.debug(f" Destination: {destination}") self.logger.debug(f" Destination: {destination}")
try: 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) await self._update_database(event, destination)
self.logger.debug("Uploaded") 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: except SubprocessException:
self.logger.error(f" Failed to upload file: '{destination}'") self.logger.error(f" Failed to upload file: '{destination}'")
@@ -93,7 +103,7 @@ class VideoUploader:
except Exception as e: except Exception as e:
self.logger.error(f"Unexpected exception occurred, abandoning event {event.id}:", exc_info=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. """Upload video using rclone.
In order to avoid writing to disk, the video file data is piped directly 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 video (bytes): The data to be written to the file
destination (pathlib.Path): Where rclone should write the file destination (pathlib.Path): Where rclone should write the file
rclone_args (str): Optional extra arguments to pass to `rclone` rclone_args (str): Optional extra arguments to pass to `rclone`
postprocess_binary (str): Optional extra path to postprocessing binary
Raises: Raises:
RuntimeError: If rclone returns a non-zero exit code RuntimeError: If rclone returns a non-zero exit code