Add ability to INCLUDE specific cameras instead of EXCLUDE (#179)

Co-authored-by: Sebastian Goscik <sebastian.goscik@live.co.uk>
This commit is contained in:
Wietse Wind
2025-01-18 16:12:55 +01:00
committed by GitHub
parent 475beaee3d
commit 6e5d90a9f5
7 changed files with 108 additions and 15 deletions

View File

@@ -139,6 +139,11 @@ Options:
environment variable the IDs should be separated by whitespace.
Alternatively, use a Unifi user with a role which has access
restricted to the subset of cameras that you wish to backup.
--camera TEXT IDs of *ONLY* cameras for which events should be backed up. Use
multiple times to include multiple IDs. If being set as an
environment variable the IDs should be separated by whitespace.
Alternatively, use a Unifi user with a role which has access
restricted to the subset of cameras that you wish to backup.
--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: {camera_name}/{event.s
@@ -213,6 +218,7 @@ always take priority over environment variables):
- `RCLONE_ARGS`
- `RCLONE_PURGE_ARGS`
- `IGNORE_CAMERAS`
- `CAMERAS`
- `DETECTION_TYPES`
- `FILE_STRUCTURE_FORMAT`
- `SQLITE_PATH`
@@ -248,13 +254,45 @@ now on, you can use the `--skip-missing` flag. This does not enable the periodic
If you use this feature it is advised that your run the tool once with this flag, then stop it once the database has been created and the events are ignored. Keeping this flag set permanently could cause events to be missed if the tool crashes and is restarted etc.
## Ignoring cameras
## Selecting cameras
Cameras can be excluded from backups by either:
- Using `--ignore-camera`, see [usage](#usage)
- IDs can be obtained by scanning the logs, starting at `Found cameras:` up to the next log line (currently `NVR TZ`). You can find this section of the logs by piping the logs in to this `sed` command
By default unifi-protect-backup backs up clips from all cameras.
If you want to limit the backups to certain cameras you can do that in one of two ways.
Note: Camera IDs can be obtained by scanning the logs, by looking for `Found cameras:`. You can find this section of the logs by piping the logs in to this `sed` command
`sed -n '/Found cameras:/,/NVR TZ/p'`
- Using a Unifi user with a role which has access restricted to the subset of cameras that you wish to backup.
### Back-up only specific cameras
By using the `--camera` argument, you can specify the ID of the cameras you want to backup. If you want to backup more than one camera you can specify this argument more than once. If this argument is specified all other cameras will be ignored.
#### Example:
If you have three cameras:
- `CAMERA_ID_1`
- `CAMERA_ID_2`
- `CAMERA_ID_3`
and run the following command:
```
$ unifi-protect-backup [...] --camera CAMERA_ID_1 --camera CAMERA_ID_2
```
Only `CAMERA_ID_1` and `CAMERA_ID_2` will be backed up.
### Ignoring cameras
By using the `--ignore-camera` argument, you can specify the ID of the cameras you *do not* want to backup. If you want to ignore more than one camera you can specify this argument more than once. If this argument is specified all cameras will be backed up except the ones specified
#### Example:
If you have three cameras:
- `CAMERA_ID_1`
- `CAMERA_ID_2`
- `CAMERA_ID_3`
and run the following command:
```
$ unifi-protect-backup [...] --ignore-camera CAMERA_ID_1 --ignore-camera CAMERA_ID_2
```
Only `CAMERA_ID_3` will be backed up.
### Note about unifi protect accounts
It is possible to limit what cameras a unifi protect accounts can see. If an account does not have access to a camera this tool will never see it as available so it will not be impacted by the above arguments.
# A note about `rclone` backends and disk wear
This tool attempts to not write the downloaded files to disk to minimise disk wear, and instead streams them directly to

View File

@@ -1,11 +1,21 @@
#!/usr/bin/with-contenv bash
export RCLONE_CONFIG=/config/rclone/rclone.conf
export XDG_CACHE_HOME=/config
echo $VERBOSITY
[[ -n "$VERBOSITY" ]] && export VERBOSITY_ARG=-$VERBOSITY || export VERBOSITY_ARG=""
exec \
s6-setuidgid abc unifi-protect-backup ${VERBOSITY_ARG}
# Run without exec to catch the exit code
s6-setuidgid abc unifi-protect-backup ${VERBOSITY_ARG}
exit_code=$?
# If exit code is 200 (arg error), exit the container
if [ $exit_code -eq 200 ]; then
# Send shutdown signal to s6
/run/s6/basedir/bin/halt
exit $exit_code
fi
# Otherwise, let s6 handle potential restart
exit $exit_code

View File

@@ -30,4 +30,4 @@ clean:
docker:
poetry build
docker buildx build . --platform $(container_arches) -t $(container_name) --push
docker buildx build . --platform $(container_arches) -t $(container_name) --push

View File

@@ -1,5 +1,6 @@
"""Console script for unifi_protect_backup."""
import sys
import re
import click
@@ -108,6 +109,16 @@ def parse_rclone_retention(ctx, param, retention) -> relativedelta:
"Alternatively, use a Unifi user with a role which has access restricted to the subset of cameras "
"that you wish to backup.",
)
@click.option(
"--camera",
"cameras",
multiple=True,
envvar="ONLY_CAMERAS",
help="IDs of *ONLY* cameras for which events should be backed up. Use multiple times to include "
"multiple IDs. If being set as an environment variable the IDs should be separated by whitespace. "
"Alternatively, use a Unifi user with a role which has access restricted to the subset of cameras "
"that you wish to backup.",
)
@click.option(
"--file-structure-format",
envvar="FILE_STRUCTURE_FORMAT",
@@ -228,8 +239,25 @@ a lot of failed downloads with the default downloader.
)
def main(**kwargs):
"""A Python based tool for backing up Unifi Protect event clips as they occur."""
event_listener = UnifiProtectBackup(**kwargs)
run(event_listener.start(), stop_on_unhandled_errors=True)
try:
# Validate only one of the camera select arguments was given
if kwargs.get("cameras") and kwargs.get("ignore_cameras"):
click.echo(
"Error: --camera and --ignore-camera options are mutually exclusive. "
"Please use only one of these options.",
err=True,
)
raise SystemExit(200) # throw 200 = arg error, service will not be restarted (docker)
# Only create the event listener and run if validation passes
event_listener = UnifiProtectBackup(**kwargs)
run(event_listener.start(), stop_on_unhandled_errors=True)
except SystemExit as e:
sys.exit(e.code)
except Exception as e:
click.echo(f"Error: {str(e)}", err=True)
sys.exit(1)
if __name__ == "__main__":

View File

@@ -3,7 +3,7 @@
import asyncio
import logging
from time import sleep
from typing import List
from typing import List, Optional
from uiprotect.api import ProtectApiClient
from uiprotect.websocket import WebsocketState
@@ -23,6 +23,7 @@ class EventListener:
protect: ProtectApiClient,
detection_types: List[str],
ignore_cameras: List[str],
cameras: Optional[List[str]] = None,
):
"""Init.
@@ -31,6 +32,7 @@ class EventListener:
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 (Optional[List[str]]): Cameras IDs to ONLY include events from
"""
self._event_queue: asyncio.Queue = event_queue
self._protect: ProtectApiClient = protect
@@ -38,6 +40,7 @@ class EventListener:
self._unsub_websocketstate = None
self.detection_types: List[str] = detection_types
self.ignore_cameras: List[str] = ignore_cameras
self.cameras: Optional[List[str]] = cameras
async def start(self):
"""Main Loop."""
@@ -60,6 +63,8 @@ class EventListener:
return
if msg.new_obj.camera_id in self.ignore_cameras:
return
if self.cameras is not None 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 [

View File

@@ -3,7 +3,7 @@
import asyncio
import logging
from datetime import datetime
from typing import AsyncIterator, List
from typing import AsyncIterator, List, Optional
import aiosqlite
from dateutil.relativedelta import relativedelta
@@ -28,7 +28,8 @@ class MissingEventChecker:
uploader: VideoUploader,
retention: relativedelta,
detection_types: List[str],
ignore_cameras: List[str],
ignore_cameras: List[str] = [],
cameras: Optional[List[str]] = None,
interval: int = 60 * 5,
) -> None:
"""Init.
@@ -42,6 +43,7 @@ class MissingEventChecker:
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 (Optional[List[str]]): Included (ONLY) camera IDs to limit search
interval (int): How frequently, in seconds, to check for missing events,
"""
self._protect: ProtectApiClient = protect
@@ -52,6 +54,7 @@ class MissingEventChecker:
self.retention: relativedelta = retention
self.detection_types: List[str] = detection_types
self.ignore_cameras: List[str] = ignore_cameras
self.cameras: Optional[List[str]] = cameras
self.interval: int = interval
async def _get_missing_events(self) -> AsyncIterator[Event]:
@@ -113,6 +116,8 @@ class MissingEventChecker:
return False # This event is still on-going
if event.camera_id in self.ignore_cameras:
return False
if self.cameras is not None 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:

View File

@@ -64,6 +64,7 @@ class UnifiProtectBackup:
rclone_purge_args: str,
detection_types: List[str],
ignore_cameras: List[str],
cameras: List[str],
file_structure_format: str,
verbose: int,
download_buffer_size: int,
@@ -96,6 +97,7 @@ class UnifiProtectBackup:
rclone_purge_args (str): Optional extra arguments to pass to `rclone delete` directly.
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.
cameras (List[str]): List of ONLY camera IDs for which to 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.
download_buffer_size (int): How many bytes big the download buffer should be
@@ -133,6 +135,7 @@ class UnifiProtectBackup:
logger.debug(f" {rclone_args=}")
logger.debug(f" {rclone_purge_args=}")
logger.debug(f" {ignore_cameras=}")
logger.debug(f" {cameras=}")
logger.debug(f" {verbose=}")
logger.debug(f" {detection_types=}")
logger.debug(f" {file_structure_format=}")
@@ -166,6 +169,7 @@ class UnifiProtectBackup:
subscribed_models={ModelType.EVENT},
)
self.ignore_cameras = ignore_cameras
self.cameras = cameras
self._download_queue: asyncio.Queue = asyncio.Queue()
self._unsub: Callable[[], None]
self.detection_types = detection_types
@@ -276,7 +280,9 @@ class UnifiProtectBackup:
# Create event listener task
# This will connect to the unifi protect websocket and listen for events. When one is detected it will
# be added to the queue of events to download
event_listener = EventListener(download_queue, self._protect, self.detection_types, self.ignore_cameras)
event_listener = EventListener(
download_queue, self._protect, self.detection_types, self.ignore_cameras, self.cameras
)
tasks.append(event_listener.start())
# Create purge task
@@ -302,6 +308,7 @@ class UnifiProtectBackup:
self.retention,
self.detection_types,
self.ignore_cameras,
self.cameras,
)
if self._skip_missing:
logger.info("Ignoring missing events")