mirror of
https://github.com/ep1cman/unifi-protect-backup.git
synced 2025-12-05 23:53:30 +00:00
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:
48
README.md
48
README.md
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
2
makefile
2
makefile
@@ -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
|
||||
|
||||
@@ -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__":
|
||||
|
||||
@@ -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 [
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user