mirror of
https://github.com/ep1cman/unifi-protect-backup.git
synced 2025-12-05 23:53:30 +00:00
Compare commits
33 Commits
v0.12.0
...
ef0cf38f83
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ef0cf38f83 | ||
|
|
2bd48014a0 | ||
|
|
afe025be1d | ||
|
|
a14ff1bf30 | ||
|
|
ba64722937 | ||
|
|
65d8e66e79 | ||
|
|
cb54078153 | ||
|
|
048e061df1 | ||
|
|
7f8177de35 | ||
|
|
eaabfbdb4e | ||
|
|
edf377adc4 | ||
|
|
ef06d2a4d4 | ||
|
|
12c8539977 | ||
|
|
474d3c32fa | ||
|
|
3750847055 | ||
|
|
c16a380918 | ||
|
|
df466b5d0b | ||
|
|
18a78863a7 | ||
|
|
4d2002b98d | ||
|
|
4b4cb86749 | ||
|
|
c091fa4f92 | ||
|
|
2bf90b6763 | ||
|
|
f275443a7a | ||
|
|
3a43c1b670 | ||
|
|
e0421c1dd1 | ||
|
|
4ee70e6d4b | ||
|
|
ce2993624f | ||
|
|
cec1f69d8d | ||
|
|
c07fb30fff | ||
|
|
1de9b9a757 | ||
|
|
3ec69a7a97 | ||
|
|
855607fa29 | ||
|
|
e11828bd59 |
@@ -1,5 +1,5 @@
|
||||
[bumpversion]
|
||||
current_version = 0.12.0
|
||||
current_version = 0.14.0
|
||||
commit = True
|
||||
tag = True
|
||||
|
||||
@@ -7,6 +7,10 @@ tag = True
|
||||
search = version = "{current_version}"
|
||||
replace = version = "{new_version}"
|
||||
|
||||
[bumpversion:file:uv.lock]
|
||||
search = version = "{current_version}"
|
||||
replace = version = "{new_version}"
|
||||
|
||||
[bumpversion:file:unifi_protect_backup/__init__.py]
|
||||
search = __version__ = "{current_version}"
|
||||
replace = __version__ = "{new_version}"
|
||||
|
||||
26
.github/ISSUE_TEMPLATE/bug_report.md
vendored
Normal file
26
.github/ISSUE_TEMPLATE/bug_report.md
vendored
Normal file
@@ -0,0 +1,26 @@
|
||||
---
|
||||
name: Bug report
|
||||
about: Create a report to help UPB improve
|
||||
title: ''
|
||||
labels: ''
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
* Unifi Protect Backup version:
|
||||
* Unifi Protect version:
|
||||
* Python version:
|
||||
* Operating System:
|
||||
* Are you using a docker container or native?:
|
||||
|
||||
### Description
|
||||
|
||||
Describe what you were trying to get done.
|
||||
Tell us what happened, what went wrong, and what you expected to happen.
|
||||
|
||||
### What I Did
|
||||
|
||||
```
|
||||
Paste the command(s) you ran and the output.
|
||||
If there was a crash, please include the traceback here.
|
||||
```
|
||||
124
.github/workflows/dev.yml
vendored
124
.github/workflows/dev.yml
vendored
@@ -1,90 +1,108 @@
|
||||
# This is a basic workflow to help you get started with Actions
|
||||
name: Test and Build
|
||||
|
||||
name: dev workflow
|
||||
|
||||
# Controls when the action will run.
|
||||
on:
|
||||
# Triggers the workflow on push events but only for the dev branch
|
||||
push:
|
||||
branches: [ dev ]
|
||||
branches-ignore:
|
||||
- main
|
||||
pull_request:
|
||||
|
||||
# Allows you to run this workflow manually from the Actions tab
|
||||
workflow_dispatch:
|
||||
|
||||
# A workflow run is made up of one or more jobs that can run sequentially or in parallel
|
||||
jobs:
|
||||
# This workflow contains a single job called "test"
|
||||
test:
|
||||
# The type of runner that the job will run on
|
||||
strategy:
|
||||
matrix:
|
||||
python-versions: [3.9]
|
||||
os: [ubuntu-18.04, macos-latest, windows-latest]
|
||||
python-versions: ["3.10", "3.11", "3.12", "3.13"]
|
||||
os: [ubuntu-latest, macos-latest, windows-latest]
|
||||
runs-on: ${{ matrix.os }}
|
||||
|
||||
# Steps represent a sequence of tasks that will be executed as part of the job
|
||||
|
||||
steps:
|
||||
# Checks-out your repository under $GITHUB_WORKSPACE, so your job can access it
|
||||
- uses: actions/checkout@v2
|
||||
- uses: actions/setup-python@v2
|
||||
- name: Configure Git to maintain line endings
|
||||
run: |
|
||||
git config --global core.autocrlf false
|
||||
git config --global core.eol lf
|
||||
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: ${{ matrix.python-versions }}
|
||||
|
||||
- name: Install dependencies
|
||||
- name: Install uv (Unix)
|
||||
if: runner.os != 'Windows'
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install poetry tox tox-gh-actions
|
||||
curl -LsSf https://astral.sh/uv/install.sh | sh
|
||||
echo "$HOME/.cargo/bin" >> $GITHUB_PATH
|
||||
|
||||
- name: test with tox
|
||||
run:
|
||||
tox
|
||||
- name: Install uv (Windows)
|
||||
if: runner.os == 'Windows'
|
||||
run: |
|
||||
iwr -useb https://astral.sh/uv/install.ps1 | iex
|
||||
echo "$HOME\.cargo\bin" | Out-File -FilePath $env:GITHUB_PATH -Encoding utf8 -Append
|
||||
|
||||
- name: list files
|
||||
run: ls -l .
|
||||
|
||||
- uses: codecov/codecov-action@v1
|
||||
with:
|
||||
fail_ci_if_error: true
|
||||
files: coverage.xml
|
||||
- name: Install dev dependencies
|
||||
run: |
|
||||
uv sync --dev
|
||||
|
||||
- name: Run pre-commit
|
||||
run: uv run pre-commit run --all-files
|
||||
|
||||
- name: Run pytest
|
||||
run: uv run pytest
|
||||
|
||||
- name: Build
|
||||
run: uv build
|
||||
|
||||
dev_container:
|
||||
name: Create dev container
|
||||
runs-on: ubuntu-20.04
|
||||
if: github.event_name != 'pull_request'
|
||||
|
||||
# Steps represent a sequence of tasks that will be executed as part of the job
|
||||
name: Create dev container
|
||||
needs: test
|
||||
if: github.ref == 'refs/heads/dev'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
|
||||
steps:
|
||||
# Checks-out your repository under $GITHUB_WORKSPACE, so your job can access it
|
||||
- uses: actions/checkout@v2
|
||||
|
||||
- uses: actions/setup-python@v2
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: 3.10
|
||||
python-version: '3.12'
|
||||
|
||||
- name: Install dependencies
|
||||
- name: Install uv (Unix)
|
||||
if: runner.os != 'Windows'
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install poetry tox tox-gh-actions
|
||||
curl -LsSf https://astral.sh/uv/install.sh | sh
|
||||
echo "$HOME/.cargo/bin" >> $GITHUB_PATH
|
||||
|
||||
- name: Build wheels and source tarball
|
||||
run: >-
|
||||
poetry build
|
||||
- name: Install uv (Windows)
|
||||
if: runner.os == 'Windows'
|
||||
run: |
|
||||
iwr -useb https://astral.sh/uv/install.ps1 | iex
|
||||
echo "$HOME\.cargo\bin" | Out-File -FilePath $env:GITHUB_PATH -Encoding utf8 -Append
|
||||
|
||||
- name: Build
|
||||
run: uv build
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v1
|
||||
uses: docker/setup-qemu-action@v3
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v1
|
||||
|
||||
- name: Log in to container registry
|
||||
uses: docker/login-action@v2
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Build and push dev
|
||||
uses: docker/build-push-action@v2
|
||||
- name: Build and push Docker image
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
platforms: linux/amd64,linux/arm64
|
||||
|
||||
50
.github/workflows/release.yml
vendored
50
.github/workflows/release.yml
vendored
@@ -1,6 +1,6 @@
|
||||
# Publish package on main branch if it's tagged with 'v*'
|
||||
|
||||
name: release & publish workflow
|
||||
name: Release & Publish Workflow
|
||||
|
||||
on:
|
||||
push:
|
||||
@@ -11,16 +11,17 @@ on:
|
||||
jobs:
|
||||
release:
|
||||
name: Create Release
|
||||
runs-on: ubuntu-20.04
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Get version from tag
|
||||
id: tag_name
|
||||
run: |
|
||||
echo ::set-output name=current_version::${GITHUB_REF#refs/tags/v}
|
||||
echo "current_version=${GITHUB_REF#refs/tags/v}" >> $GITHUB_OUTPUT
|
||||
shell: bash
|
||||
|
||||
- uses: actions/checkout@v2
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Get Changelog Entry
|
||||
id: changelog_reader
|
||||
@@ -29,56 +30,57 @@ jobs:
|
||||
version: ${{ steps.tag_name.outputs.current_version }}
|
||||
path: ./CHANGELOG.md
|
||||
|
||||
- uses: actions/setup-python@v2
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.10"
|
||||
|
||||
- name: Install dependencies
|
||||
- name: Install uv
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install poetry
|
||||
curl -LsSf https://astral.sh/uv/install.sh | sh
|
||||
echo "$HOME/.cargo/bin" >> $GITHUB_PATH
|
||||
|
||||
- name: Build wheels and source tarball
|
||||
run: >-
|
||||
poetry build
|
||||
run: uv build
|
||||
|
||||
- name: show temporary files
|
||||
run: >-
|
||||
ls -lR
|
||||
- name: Show build artifacts
|
||||
run: ls -lR dist/
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v1
|
||||
uses: docker/setup-qemu-action@v3
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v1
|
||||
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Log in to container registry
|
||||
uses: docker/login-action@v2
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Build and push dev
|
||||
uses: docker/build-push-action@v2
|
||||
|
||||
- name: Build and push container
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: true
|
||||
tags: ghcr.io/${{ github.repository }}:${{ steps.tag_name.outputs.current_version }}, ghcr.io/${{ github.repository }}:latest
|
||||
tags: |
|
||||
ghcr.io/${{ github.repository }}:${{ steps.tag_name.outputs.current_version }}
|
||||
ghcr.io/${{ github.repository }}:latest
|
||||
|
||||
- name: create github release
|
||||
- name: Create GitHub release
|
||||
id: create_release
|
||||
uses: softprops/action-gh-release@v1
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
with:
|
||||
body: ${{ steps.changelog_reader.outputs.changes }}
|
||||
files: dist/*.whl
|
||||
files: dist/*
|
||||
draft: false
|
||||
prerelease: false
|
||||
|
||||
- name: publish to PyPI
|
||||
- name: Publish to PyPI
|
||||
uses: pypa/gh-action-pypi-publish@release/v1
|
||||
with:
|
||||
user: __token__
|
||||
|
||||
@@ -5,14 +5,14 @@ repos:
|
||||
- id: forbid-crlf
|
||||
- id: remove-crlf
|
||||
- repo: https://github.com/pre-commit/pre-commit-hooks
|
||||
rev: v3.4.0
|
||||
rev: v5.0.0
|
||||
hooks:
|
||||
- id: check-merge-conflict
|
||||
- id: check-yaml
|
||||
args: [ --unsafe ]
|
||||
- repo: https://github.com/astral-sh/ruff-pre-commit
|
||||
# Ruff version.
|
||||
rev: v0.5.7
|
||||
rev: v0.11.4
|
||||
hooks:
|
||||
# Run the linter.
|
||||
- id: ruff
|
||||
|
||||
17
CHANGELOG.md
17
CHANGELOG.md
@@ -4,6 +4,23 @@ All notable changes to this project will be documented in this file.
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [0.14.0] - 2025-07-18
|
||||
- Add support for Finger Print, NFC Card Scan, and Audio Detections
|
||||
|
||||
## [0.13.1] - 2025-06-26
|
||||
### Fixed
|
||||
- Bumped uiprotect version to support unifi protect 6
|
||||
|
||||
## [0.13.0] - 2025-04-09
|
||||
### Added
|
||||
- Parallel uploaders are now supported
|
||||
- All smart detection types are now supported
|
||||
- Migrated the project from poetry to uv
|
||||
|
||||
### Fixed
|
||||
- Corrected the envar for setting cameras to backup for ONLY_CAMERAS -> CAMERAS
|
||||
- Bumped to the latest uiprotect library to fix issue when unifi access devices are present
|
||||
|
||||
## [0.12.0] - 2025-01-18
|
||||
### Added
|
||||
- Tool now targets UIProtect instead of pyunifiprotect which should help any lingering auth issues with Unifi OS 4.X
|
||||
|
||||
@@ -55,12 +55,11 @@ Ready to contribute? Here's how to set up `unifi-protect-backup` for local devel
|
||||
$ git clone git@github.com:your_name_here/unifi-protect-backup.git
|
||||
```
|
||||
|
||||
3. Ensure [poetry](https://python-poetry.org/docs/) is installed.
|
||||
4. Install dependencies and start your virtualenv:
|
||||
3. Ensure [uv](https://docs.astral.sh/uv/) is installed.
|
||||
4. Create virtual environment and install dependencies:
|
||||
|
||||
```
|
||||
$ poetry install --with dev,test
|
||||
$ poetry shell
|
||||
$ uv install --dev
|
||||
```
|
||||
|
||||
5. Create a branch for local development:
|
||||
@@ -75,21 +74,21 @@ Ready to contribute? Here's how to set up `unifi-protect-backup` for local devel
|
||||
be inside the `poetry shell` virtualenv or run it via poetry:
|
||||
|
||||
```
|
||||
$ poetry run unifi-protect-backup {args}
|
||||
$ uv run unifi-protect-backup {args}
|
||||
```
|
||||
|
||||
7. Install pre-commit git hooks to ensure all code commit to the repository
|
||||
is formatted correctly and meets coding standards:
|
||||
|
||||
```
|
||||
$ poetry run pre-commit install
|
||||
$ uv run pre-commit install
|
||||
```
|
||||
|
||||
8. When you're done making changes, check that your changes pass the
|
||||
tests:
|
||||
|
||||
```
|
||||
$ poetry run pytest
|
||||
$ uv run pytest
|
||||
```
|
||||
|
||||
8. Commit your changes and push your branch to GitHub:
|
||||
@@ -117,7 +116,7 @@ Before you submit a pull request, check that it meets these guidelines:
|
||||
## Tips
|
||||
|
||||
```
|
||||
$ poetry run pytest tests/test_unifi_protect_backup.py
|
||||
$ uv run pytest tests/test_unifi_protect_backup.py
|
||||
```
|
||||
|
||||
To run a subset of tests.
|
||||
@@ -130,7 +129,7 @@ Make sure all your changes are committed (including an entry in CHANGELOG.md).
|
||||
Then run:
|
||||
|
||||
```
|
||||
$ poetry run bump2version patch # possible: major / minor / patch
|
||||
$ uv run bump2version patch # possible: major / minor / patch
|
||||
$ git push
|
||||
$ git push --tags
|
||||
```
|
||||
|
||||
@@ -7,7 +7,7 @@ LABEL maintainer="ep1cman"
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY dist/unifi_protect_backup-0.12.0.tar.gz sdist.tar.gz
|
||||
COPY dist/unifi_protect_backup-0.14.0.tar.gz sdist.tar.gz
|
||||
|
||||
# https://github.com/rust-lang/cargo/issues/2808
|
||||
ENV CARGO_NET_GIT_FETCH_WITH_CLI=true
|
||||
|
||||
29
README.md
29
README.md
@@ -23,7 +23,7 @@ retention period.
|
||||
## Features
|
||||
|
||||
- Listens to events in real-time via the Unifi Protect websocket API
|
||||
- Ensures any previous and/or missed events within the retention period are also backed up
|
||||
- Ensures any previous and/or missed events within the missing range are also backed up
|
||||
- Supports uploading to a [wide range of storage systems using `rclone`](https://rclone.org/overview/)
|
||||
- Automatic pruning of old clips
|
||||
|
||||
@@ -90,8 +90,7 @@ docker run \
|
||||
-e UFP_ADDRESS='UNIFI_PROTECT_IP' \
|
||||
-e UFP_SSL_VERIFY='false' \
|
||||
-e RCLONE_DESTINATION='my_remote:/unifi_protect_backup' \
|
||||
-v '/path/to/save/clips':'/data' \
|
||||
-v '/path/to/rclone.conf':'/config/rclone/rclone.conf' \
|
||||
-v '/path/to/config/rclone':'/config/rclone/' \
|
||||
-v '/path/to/save/database':/config/database/ \
|
||||
ghcr.io/ep1cman/unifi-protect-backup
|
||||
```
|
||||
@@ -124,6 +123,10 @@ Options:
|
||||
`--max-age` argument of `rclone`
|
||||
(https://rclone.org/filtering/#max-age-don-t-transfer-any-file-
|
||||
older-than-this) [default: 7d]
|
||||
--missing-range TEXT How far back should missing events be checked for. Defaults to
|
||||
the same as the retention time. Format as per the `--max-age`
|
||||
argument of `rclone` (https://rclone.org/filtering/#max-age-don-
|
||||
t-transfer-any-file-older-than-this)
|
||||
--rclone-args TEXT Optional extra arguments to pass to `rclone rcat` directly.
|
||||
Common usage for this would be to set a bandwidth limit, for
|
||||
example.
|
||||
@@ -132,14 +135,21 @@ Options:
|
||||
instead of using the recycle bin on a destination. Google Drive
|
||||
example: `--drive-use-trash=false`
|
||||
--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]
|
||||
Valid options are: `motion`, `ring`, `line`, `fingerprint`,
|
||||
`nfc`, `person`, `animal`, `vehicle`, `licensePlate`, `package`,
|
||||
`face`, `car`, `pet`, `alrmSmoke`, `alrmCmonx`, `smoke_cmonx`,
|
||||
`alrmSiren`, `alrmBabyCry`, `alrmSpeak`, `alrmBark`,
|
||||
`alrmBurglar`, `alrmCarHorn`, `alrmGlassBreak` [default: motion
|
||||
,ring,line,fingerprint,nfc,person,animal,vehicle,licensePlate,pa
|
||||
ckage,face,car,pet,alrmSmoke,alrmCmonx,smoke_cmonx,alrmSiren,alr
|
||||
mBabyCry,alrmSpeak,alrmBark,alrmBurglar,alrmCarHorn,alrmGlassBre
|
||||
ak]
|
||||
--ignore-camera TEXT 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.
|
||||
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
|
||||
--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
|
||||
@@ -203,6 +213,7 @@ Options:
|
||||
--experimental-downloader If set, a new experimental download mechanism will be used to match
|
||||
what the web UI does. This might be more stable if you are experiencing
|
||||
a lot of failed downloads with the default downloader. [default: False]
|
||||
--parallel-uploads INTEGER Max number of parallel uploads to allow [default: 1]
|
||||
--help Show this message and exit.
|
||||
```
|
||||
|
||||
@@ -214,6 +225,7 @@ always take priority over environment variables):
|
||||
- `UFP_PORT`
|
||||
- `UFP_SSL_VERIFY`
|
||||
- `RCLONE_RETENTION`
|
||||
- `MISSING_RANGE`
|
||||
- `RCLONE_DESTINATION`
|
||||
- `RCLONE_ARGS`
|
||||
- `RCLONE_PURGE_ARGS`
|
||||
@@ -230,6 +242,7 @@ always take priority over environment variables):
|
||||
- `DOWNLOAD_RATELIMIT`
|
||||
- `MAX_EVENT_LENGTH`
|
||||
- `EXPERIMENTAL_DOWNLOADER`
|
||||
- `PARALLEL_UPLOADS`
|
||||
|
||||
## File path formatting
|
||||
|
||||
@@ -368,7 +381,7 @@ If you need to debug your rclone setup, you can invoke rclone directly like so:
|
||||
```
|
||||
docker run \
|
||||
--rm \
|
||||
-v /path/to/rclone.conf:/config/rclone/rclone.conf \
|
||||
-v /path/to/config/rclone:/config/rclone \
|
||||
-e RCLONE_CONFIG='/config/rclone/rclone.conf' \
|
||||
--entrypoint rclone \
|
||||
ghcr.io/ep1cman/unifi-protect-backup \
|
||||
@@ -379,7 +392,7 @@ For example to check that your config file is being read properly and list the c
|
||||
```
|
||||
docker run \
|
||||
--rm \
|
||||
-v /path/to/rclone.conf:/config/rclone/rclone.conf \
|
||||
-v /path/to/config/rclone:/config/rclone \
|
||||
-e RCLONE_CONFIG='/config/rclone/rclone.conf' \
|
||||
--entrypoint rclone \
|
||||
ghcr.io/ep1cman/unifi-protect-backup \
|
||||
|
||||
@@ -4,7 +4,7 @@ mkdir -p /config/rclone
|
||||
|
||||
# For backwards compatibility
|
||||
[[ -f "/root/.config/rclone/rclone.conf" ]] && \
|
||||
echo "DEPRECATED: Copying rclone conf from /root/.config/rclone/rclone.conf, please change your mount to /config/rclone/rclone.conf"
|
||||
echo "DEPRECATED: Copying rclone conf from /root/.config/rclone/rclone.conf, please change your mount to /config/rclone/rclone.conf" && \
|
||||
cp \
|
||||
/root/.config/rclone/rclone.conf \
|
||||
/config/rclone/rclone.conf
|
||||
|
||||
7
makefile
7
makefile
@@ -6,11 +6,10 @@ container_arches ?= linux/amd64,linux/arm64
|
||||
test: format lint unittest
|
||||
|
||||
format:
|
||||
isort $(sources) tests
|
||||
black $(sources) tests
|
||||
ruff format $(sources) tests
|
||||
|
||||
lint:
|
||||
flake8 $(sources) tests
|
||||
ruff check $(sources) tests
|
||||
mypy $(sources) tests
|
||||
|
||||
unittest:
|
||||
@@ -29,5 +28,5 @@ clean:
|
||||
rm -rf coverage.xml .coverage
|
||||
|
||||
docker:
|
||||
poetry build
|
||||
uv build
|
||||
docker buildx build . --platform $(container_arches) -t $(container_name) --push
|
||||
|
||||
2195
poetry.lock
generated
2195
poetry.lock
generated
File diff suppressed because it is too large
Load Diff
110
pyproject.toml
110
pyproject.toml
@@ -1,76 +1,82 @@
|
||||
[tool]
|
||||
[tool.poetry]
|
||||
[build-system]
|
||||
requires = ["hatchling"]
|
||||
build-backend = "hatchling.build"
|
||||
|
||||
[project]
|
||||
name = "unifi_protect_backup"
|
||||
version = "0.12.0"
|
||||
homepage = "https://github.com/ep1cman/unifi-protect-backup"
|
||||
version = "0.14.0"
|
||||
description = "Python tool to backup unifi event clips in realtime."
|
||||
authors = ["sebastian.goscik <sebastian@goscik.com>"]
|
||||
readme = "README.md"
|
||||
license = "MIT"
|
||||
classifiers=[
|
||||
'Development Status :: 5 - Production/Stable',
|
||||
'Intended Audience :: Information Technology',
|
||||
'License :: OSI Approved :: MIT License',
|
||||
'Natural Language :: English',
|
||||
'Programming Language :: Python :: 3',
|
||||
'Programming Language :: Python :: 3.10',
|
||||
license = {text = "MIT"}
|
||||
authors = [
|
||||
{name = "sebastian.goscik", email = "sebastian@goscik.com"}
|
||||
]
|
||||
packages = [
|
||||
{ include = "unifi_protect_backup" },
|
||||
{ include = "tests", format = "sdist" },
|
||||
classifiers = [
|
||||
"Development Status :: 5 - Production/Stable",
|
||||
"Intended Audience :: Information Technology",
|
||||
"License :: OSI Approved :: MIT License",
|
||||
"Natural Language :: English",
|
||||
"Programming Language :: Python :: 3",
|
||||
"Programming Language :: Python :: 3.10",
|
||||
]
|
||||
requires-python = ">=3.10.0,<4.0"
|
||||
dependencies = [
|
||||
"click==8.0.1",
|
||||
"aiorun>=2023.7.2",
|
||||
"aiosqlite>=0.17.0",
|
||||
"python-dateutil>=2.8.2",
|
||||
"apprise>=1.5.0",
|
||||
"expiring-dict>=1.1.0",
|
||||
"async-lru>=2.0.4",
|
||||
"aiolimiter>=1.1.0",
|
||||
"uiprotect==7.14.1",
|
||||
"aiohttp==3.12.14",
|
||||
]
|
||||
|
||||
[tool.poetry.dependencies]
|
||||
python = ">=3.10.0,<4.0"
|
||||
click = "8.0.1"
|
||||
aiorun = "^2023.7.2"
|
||||
aiosqlite = "^0.17.0"
|
||||
python-dateutil = "^2.8.2"
|
||||
apprise = "^1.5.0"
|
||||
expiring-dict = "^1.1.0"
|
||||
async-lru = "^2.0.4"
|
||||
aiolimiter = "^1.1.0"
|
||||
uiprotect = "^6.3.1"
|
||||
[project.urls]
|
||||
Homepage = "https://github.com/ep1cman/unifi-protect-backup"
|
||||
|
||||
[tool.poetry.group.dev]
|
||||
optional = true
|
||||
[project.scripts]
|
||||
unifi-protect-backup = "unifi_protect_backup.cli:main"
|
||||
|
||||
[tool.poetry.group.dev.dependencies]
|
||||
mypy = "^1.11.1"
|
||||
types-pytz = "^2021.3.5"
|
||||
types-cryptography = "^3.3.18"
|
||||
types-python-dateutil = "^2.8.19.10"
|
||||
bump2version = "^1.0.1"
|
||||
pre-commit = "^2.12.0"
|
||||
ruff = "^0.5.7"
|
||||
types-aiofiles = "^24.1.0.20241221"
|
||||
[dependency-groups]
|
||||
dev = [
|
||||
"mypy>=1.15.0",
|
||||
"types-pytz>=2021.3.5",
|
||||
"types-cryptography>=3.3.18",
|
||||
"types-python-dateutil>=2.8.19.10",
|
||||
"types-aiofiles>=24.1.0.20241221",
|
||||
"bump2version>=1.0.1",
|
||||
"pre-commit>=4.2.0",
|
||||
"ruff>=0.11.4",
|
||||
"pytest>=8.3.5",
|
||||
]
|
||||
|
||||
[tool.poetry.group.test]
|
||||
optional = true
|
||||
[tool.hatch.build.targets.wheel]
|
||||
packages = ["unifi_protect_backup"]
|
||||
|
||||
[tool.poetry.group.test.dependencies]
|
||||
pytest = "^6.2.4"
|
||||
pytest-cov = "^2.12.0"
|
||||
tox = "^3.20.1"
|
||||
tox-asdf = "^0.1.0"
|
||||
|
||||
[tool.poetry.scripts]
|
||||
unifi-protect-backup = 'unifi_protect_backup.cli:main'
|
||||
[tool.hatch.build.targets.sdist]
|
||||
include = ["unifi_protect_backup", "tests"]
|
||||
|
||||
[tool.ruff]
|
||||
line-length = 120
|
||||
target-version = "py310"
|
||||
|
||||
[tool.ruff.lint]
|
||||
select = ["E","F","D","B","W"]
|
||||
ignore = ["D203", "D213"]
|
||||
|
||||
[tool.ruff.format]
|
||||
quote-style = "double"
|
||||
indent-style = "space"
|
||||
line-ending = "lf"
|
||||
docstring-code-format = true
|
||||
|
||||
[tool.mypy]
|
||||
allow_redefinition=true
|
||||
allow_redefinition = true
|
||||
exclude = [
|
||||
'unifi_protect_backup/uiprotect_patch.py'
|
||||
]
|
||||
|
||||
[build-system]
|
||||
requires = ["poetry-core>=1.0.0"]
|
||||
build-backend = "poetry.core.masonry.api"
|
||||
[tool.uv]
|
||||
default-groups = []
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
__author__ = """sebastian.goscik"""
|
||||
__email__ = "sebastian@goscik.com"
|
||||
__version__ = "0.12.0"
|
||||
__version__ = "0.14.0"
|
||||
|
||||
from .downloader import VideoDownloader
|
||||
from .downloader_experimental import VideoDownloaderExperimental
|
||||
|
||||
@@ -7,11 +7,15 @@ import click
|
||||
from aiorun import run # type: ignore
|
||||
from dateutil.relativedelta import relativedelta
|
||||
|
||||
from uiprotect.data.types import SmartDetectObjectType, SmartDetectAudioType
|
||||
|
||||
from unifi_protect_backup import __version__
|
||||
from unifi_protect_backup.unifi_protect_backup_core import UnifiProtectBackup
|
||||
from unifi_protect_backup.utils import human_readable_to_float
|
||||
|
||||
DETECTION_TYPES = ["motion", "person", "vehicle", "ring", "line"]
|
||||
DETECTION_TYPES = ["motion", "ring", "line", "fingerprint", "nfc"]
|
||||
DETECTION_TYPES += [t for t in SmartDetectObjectType.values() if t not in SmartDetectAudioType.values()]
|
||||
DETECTION_TYPES += [f"{t}" for t in SmartDetectAudioType.values()]
|
||||
|
||||
|
||||
def _parse_detection_types(ctx, param, value):
|
||||
@@ -26,8 +30,11 @@ def _parse_detection_types(ctx, param, value):
|
||||
return types
|
||||
|
||||
|
||||
def parse_rclone_retention(ctx, param, retention) -> relativedelta:
|
||||
"""Parses the rclone `retention` parameter into a relativedelta which can then be used to calculate datetimes."""
|
||||
def parse_rclone_retention(ctx, param, retention) -> relativedelta | None:
|
||||
"""Parse the rclone `retention` parameter into a relativedelta which can then be used to calculate datetimes."""
|
||||
if retention is None:
|
||||
return None
|
||||
|
||||
matches = {k: int(v) for v, k in re.findall(r"([\d]+)(ms|s|m|h|d|w|M|y)", retention)}
|
||||
|
||||
# Check that we matched the whole string
|
||||
@@ -75,6 +82,15 @@ def parse_rclone_retention(ctx, param, retention) -> relativedelta:
|
||||
"(https://rclone.org/filtering/#max-age-don-t-transfer-any-file-older-than-this)",
|
||||
callback=parse_rclone_retention,
|
||||
)
|
||||
@click.option(
|
||||
"--missing-range",
|
||||
default=None,
|
||||
envvar="MISSING_RANGE",
|
||||
help="How far back should missing events be checked for. Defaults to the same as the retention time. "
|
||||
"Format as per the `--max-age` argument of `rclone` "
|
||||
"(https://rclone.org/filtering/#max-age-don-t-transfer-any-file-older-than-this)",
|
||||
callback=parse_rclone_retention,
|
||||
)
|
||||
@click.option(
|
||||
"--rclone-args",
|
||||
default="",
|
||||
@@ -113,7 +129,7 @@ def parse_rclone_retention(ctx, param, retention) -> relativedelta:
|
||||
"--camera",
|
||||
"cameras",
|
||||
multiple=True,
|
||||
envvar="ONLY_CAMERAS",
|
||||
envvar="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 "
|
||||
@@ -167,7 +183,7 @@ all warnings, and websocket data
|
||||
show_default=True,
|
||||
envvar="DOWNLOAD_BUFFER_SIZE",
|
||||
help='How big the download buffer should be (you can use suffixes like "B", "KiB", "MiB", "GiB")',
|
||||
callback=lambda ctx, param, value: human_readable_to_float(value),
|
||||
callback=lambda ctx, param, value: int(human_readable_to_float(value)),
|
||||
)
|
||||
@click.option(
|
||||
"--purge_interval",
|
||||
@@ -237,9 +253,16 @@ what the web UI does. This might be more stable if you are experiencing
|
||||
a lot of failed downloads with the default downloader.
|
||||
""",
|
||||
)
|
||||
@click.option(
|
||||
"--parallel-uploads",
|
||||
default=1,
|
||||
show_default=True,
|
||||
envvar="PARALLEL_UPLOADS",
|
||||
type=int,
|
||||
help="Max number of parallel uploads to allow",
|
||||
)
|
||||
def main(**kwargs):
|
||||
"""A Python based tool for backing up Unifi Protect event clips as they occur."""
|
||||
|
||||
"""Python based tool for backing up Unifi Protect event clips as they occur."""
|
||||
try:
|
||||
# Validate only one of the camera select arguments was given
|
||||
if kwargs.get("cameras") and kwargs.get("ignore_cameras"):
|
||||
@@ -250,6 +273,9 @@ def main(**kwargs):
|
||||
)
|
||||
raise SystemExit(200) # throw 200 = arg error, service will not be restarted (docker)
|
||||
|
||||
if kwargs.get("missing_range") is None:
|
||||
kwargs["missing_range"] = kwargs.get("retention")
|
||||
|
||||
# Only create the event listener and run if validation passes
|
||||
event_listener = UnifiProtectBackup(**kwargs)
|
||||
run(event_listener.start(), stop_on_unhandled_errors=True)
|
||||
|
||||
@@ -27,7 +27,7 @@ from unifi_protect_backup.utils import (
|
||||
|
||||
|
||||
async def get_video_length(video: bytes) -> float:
|
||||
"""Uses ffprobe to get the length of the video file passed in as a byte stream."""
|
||||
"""Use ffprobe to get the length of the video file passed in as a byte stream."""
|
||||
returncode, stdout, stderr = await run_command(
|
||||
"ffprobe -v quiet -show_streams -select_streams v:0 -of json -", video
|
||||
)
|
||||
@@ -62,6 +62,7 @@ class VideoDownloader:
|
||||
color_logging (bool): Whether or not to add color to logging output
|
||||
download_rate_limit (float): Limit how events can be downloaded in one minute",
|
||||
max_event_length (timedelta): Maximum length in seconds for an event to be considered valid and downloaded
|
||||
|
||||
"""
|
||||
self._protect: ProtectApiClient = protect
|
||||
self._db: aiosqlite.Connection = db
|
||||
@@ -86,7 +87,7 @@ class VideoDownloader:
|
||||
self._has_ffprobe = False
|
||||
|
||||
async def start(self):
|
||||
"""Main loop."""
|
||||
"""Run main loop."""
|
||||
self.logger.info("Starting Downloader")
|
||||
while True:
|
||||
if self._limiter:
|
||||
@@ -113,7 +114,7 @@ class VideoDownloader:
|
||||
output_queue_max_size = human_readable_size(self.upload_queue.maxsize)
|
||||
self.logger.debug(f"Video Download Buffer: {output_queue_current_size}/{output_queue_max_size}")
|
||||
self.logger.debug(f" Camera: {await get_camera_name(self._protect, event.camera_id)}")
|
||||
if event.type == EventType.SMART_DETECT:
|
||||
if event.type in [EventType.SMART_DETECT, EventType.SMART_AUDIO_DETECT]:
|
||||
self.logger.debug(f" Type: {event.type.value} ({', '.join(event.smart_detect_types)})")
|
||||
else:
|
||||
self.logger.debug(f" Type: {event.type.value}")
|
||||
@@ -174,7 +175,7 @@ class VideoDownloader:
|
||||
self.logger.error(f"Unexpected exception occurred, abandoning event {event.id}:", exc_info=e)
|
||||
|
||||
async def _download(self, event: Event) -> Optional[bytes]:
|
||||
"""Downloads the video clip for the given event."""
|
||||
"""Download the video clip for the given event."""
|
||||
self.logger.debug(" Downloading video...")
|
||||
for x in range(5):
|
||||
assert isinstance(event.camera_id, str)
|
||||
@@ -185,7 +186,7 @@ class VideoDownloader:
|
||||
assert isinstance(video, bytes)
|
||||
break
|
||||
except (AssertionError, ClientPayloadError, TimeoutError) as e:
|
||||
self.logger.warning(f" Failed download attempt {x+1}, retying in 1s", exc_info=e)
|
||||
self.logger.warning(f" Failed download attempt {x + 1}, retying in 1s", exc_info=e)
|
||||
await asyncio.sleep(1)
|
||||
else:
|
||||
self.logger.error(f"Download failed after 5 attempts, abandoning event {event.id}:")
|
||||
@@ -210,7 +211,7 @@ class VideoDownloader:
|
||||
"""
|
||||
try:
|
||||
downloaded_duration = await get_video_length(video)
|
||||
msg = f" Downloaded video length: {downloaded_duration:.3f}s" f"({downloaded_duration - duration:+.3f}s)"
|
||||
msg = f" Downloaded video length: {downloaded_duration:.3f}s ({downloaded_duration - duration:+.3f}s)"
|
||||
if downloaded_duration < duration:
|
||||
self.logger.warning(msg)
|
||||
else:
|
||||
|
||||
@@ -27,7 +27,7 @@ from unifi_protect_backup.utils import (
|
||||
|
||||
|
||||
async def get_video_length(video: bytes) -> float:
|
||||
"""Uses ffprobe to get the length of the video file passed in as a byte stream."""
|
||||
"""Use ffprobe to get the length of the video file passed in as a byte stream."""
|
||||
returncode, stdout, stderr = await run_command(
|
||||
"ffprobe -v quiet -show_streams -select_streams v:0 -of json -", video
|
||||
)
|
||||
@@ -62,6 +62,7 @@ class VideoDownloaderExperimental:
|
||||
color_logging (bool): Whether or not to add color to logging output
|
||||
download_rate_limit (float): Limit how events can be downloaded in one minute",
|
||||
max_event_length (timedelta): Maximum length in seconds for an event to be considered valid and downloaded
|
||||
|
||||
"""
|
||||
self._protect: ProtectApiClient = protect
|
||||
self._db: aiosqlite.Connection = db
|
||||
@@ -86,7 +87,7 @@ class VideoDownloaderExperimental:
|
||||
self._has_ffprobe = False
|
||||
|
||||
async def start(self):
|
||||
"""Main loop."""
|
||||
"""Run main loop."""
|
||||
self.logger.info("Starting Downloader")
|
||||
while True:
|
||||
if self._limiter:
|
||||
@@ -113,7 +114,7 @@ class VideoDownloaderExperimental:
|
||||
output_queue_max_size = human_readable_size(self.upload_queue.maxsize)
|
||||
self.logger.debug(f"Video Download Buffer: {output_queue_current_size}/{output_queue_max_size}")
|
||||
self.logger.debug(f" Camera: {await get_camera_name(self._protect, event.camera_id)}")
|
||||
if event.type == EventType.SMART_DETECT:
|
||||
if event.type in [EventType.SMART_DETECT, EventType.SMART_AUDIO_DETECT]:
|
||||
self.logger.debug(f" Type: {event.type.value} ({', '.join(event.smart_detect_types)})")
|
||||
else:
|
||||
self.logger.debug(f" Type: {event.type.value}")
|
||||
@@ -180,7 +181,7 @@ class VideoDownloaderExperimental:
|
||||
)
|
||||
|
||||
async def _download(self, event: Event) -> Optional[bytes]:
|
||||
"""Downloads the video clip for the given event."""
|
||||
"""Download the video clip for the given event."""
|
||||
self.logger.debug(" Downloading video...")
|
||||
for x in range(5):
|
||||
assert isinstance(event.camera_id, str)
|
||||
@@ -196,7 +197,7 @@ class VideoDownloaderExperimental:
|
||||
assert isinstance(video, bytes)
|
||||
break
|
||||
except (AssertionError, ClientPayloadError, TimeoutError) as e:
|
||||
self.logger.warning(f" Failed download attempt {x+1}, retying in 1s", exc_info=e)
|
||||
self.logger.warning(f" Failed download attempt {x + 1}, retying in 1s", exc_info=e)
|
||||
await asyncio.sleep(1)
|
||||
else:
|
||||
self.logger.error(f"Download failed after 5 attempts, abandoning event {event.id}:")
|
||||
@@ -221,7 +222,7 @@ class VideoDownloaderExperimental:
|
||||
"""
|
||||
try:
|
||||
downloaded_duration = await get_video_length(video)
|
||||
msg = f" Downloaded video length: {downloaded_duration:.3f}s" f"({downloaded_duration - duration:+.3f}s)"
|
||||
msg = f" Downloaded video length: {downloaded_duration:.3f}s ({downloaded_duration - duration:+.3f}s)"
|
||||
if downloaded_duration < duration:
|
||||
self.logger.warning(msg)
|
||||
else:
|
||||
|
||||
@@ -3,14 +3,15 @@
|
||||
import asyncio
|
||||
import logging
|
||||
from time import sleep
|
||||
from typing import List
|
||||
from typing import Set
|
||||
|
||||
from uiprotect.api import ProtectApiClient
|
||||
from uiprotect.websocket import WebsocketState
|
||||
from uiprotect.data.nvr import Event
|
||||
from uiprotect.data.types import EventType
|
||||
from uiprotect.data.websocket import WSAction, WSSubscriptionMessage
|
||||
|
||||
from unifi_protect_backup.utils import wanted_event_type
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -21,75 +22,52 @@ class EventListener:
|
||||
self,
|
||||
event_queue: asyncio.Queue,
|
||||
protect: ProtectApiClient,
|
||||
detection_types: List[str],
|
||||
ignore_cameras: List[str],
|
||||
cameras: List[str],
|
||||
detection_types: Set[str],
|
||||
ignore_cameras: Set[str],
|
||||
cameras: Set[str],
|
||||
):
|
||||
"""Init.
|
||||
|
||||
Args:
|
||||
event_queue (asyncio.Queue): Queue to place events to backup on
|
||||
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 (List[str]): Cameras IDs to ONLY include events from
|
||||
detection_types (Set[str]): Desired Event detection types to look for
|
||||
ignore_cameras (Set[str]): Cameras IDs to ignore events from
|
||||
cameras (Set[str]): Cameras IDs to ONLY include events from
|
||||
|
||||
"""
|
||||
self._event_queue: asyncio.Queue = event_queue
|
||||
self._protect: ProtectApiClient = protect
|
||||
self._unsub = None
|
||||
self._unsub_websocketstate = None
|
||||
self.detection_types: List[str] = detection_types
|
||||
self.ignore_cameras: List[str] = ignore_cameras
|
||||
self.cameras: List[str] = cameras
|
||||
self.detection_types: Set[str] = detection_types
|
||||
self.ignore_cameras: Set[str] = ignore_cameras
|
||||
self.cameras: Set[str] = cameras
|
||||
|
||||
async def start(self):
|
||||
"""Main Loop."""
|
||||
"""Run main Loop."""
|
||||
logger.debug("Subscribed to websocket")
|
||||
self._unsub_websocket_state = self._protect.subscribe_websocket_state(self._websocket_state_callback)
|
||||
self._unsub = self._protect.subscribe_websocket(self._websocket_callback)
|
||||
|
||||
def _websocket_callback(self, msg: WSSubscriptionMessage) -> None:
|
||||
"""Callback for "EVENT" websocket messages.
|
||||
"""'EVENT' websocket message callback.
|
||||
|
||||
Filters the incoming events, and puts completed events onto the download queue
|
||||
|
||||
Args:
|
||||
msg (Event): Incoming event data
|
||||
|
||||
"""
|
||||
logger.websocket_data(msg) # type: ignore
|
||||
|
||||
assert isinstance(msg.new_obj, Event)
|
||||
if msg.action != WSAction.UPDATE:
|
||||
return
|
||||
if msg.new_obj.camera_id in self.ignore_cameras:
|
||||
return
|
||||
if self.cameras 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 [
|
||||
EventType.MOTION,
|
||||
EventType.SMART_DETECT,
|
||||
EventType.RING,
|
||||
EventType.SMART_DETECT_LINE,
|
||||
]:
|
||||
if not wanted_event_type(msg.new_obj, self.detection_types, self.cameras, self.ignore_cameras):
|
||||
return
|
||||
if msg.new_obj.type is EventType.MOTION and "motion" not in self.detection_types:
|
||||
logger.extra_debug(f"Skipping unwanted motion detection event: {msg.new_obj.id}") # type: ignore
|
||||
return
|
||||
if msg.new_obj.type is EventType.RING and "ring" not in self.detection_types:
|
||||
logger.extra_debug(f"Skipping unwanted ring event: {msg.new_obj.id}") # type: ignore
|
||||
return
|
||||
if msg.new_obj.type is EventType.SMART_DETECT_LINE and "line" not in self.detection_types:
|
||||
logger.extra_debug(f"Skipping unwanted line event: {msg.new_obj.id}") # type: ignore
|
||||
return
|
||||
elif msg.new_obj.type is EventType.SMART_DETECT:
|
||||
for event_smart_detection_type in msg.new_obj.smart_detect_types:
|
||||
if event_smart_detection_type not in self.detection_types:
|
||||
logger.extra_debug( # type: ignore
|
||||
f"Skipping unwanted {event_smart_detection_type} detection event: {msg.new_obj.id}"
|
||||
)
|
||||
return
|
||||
|
||||
# TODO: Will this even work? I think it will block the async loop
|
||||
while self._event_queue.full():
|
||||
@@ -107,12 +85,13 @@ class EventListener:
|
||||
logger.debug(f"Adding event {msg.new_obj.id} to queue (Current download queue={self._event_queue.qsize()})")
|
||||
|
||||
def _websocket_state_callback(self, state: WebsocketState) -> None:
|
||||
"""Callback for websocket state messages.
|
||||
"""Websocket state message callback.
|
||||
|
||||
Flags the websocket for reconnection
|
||||
|
||||
Args:
|
||||
msg (WebsocketState): new state of the websocket
|
||||
state (WebsocketState): new state of the websocket
|
||||
|
||||
"""
|
||||
if state == WebsocketState.DISCONNECTED:
|
||||
logger.error("Unifi Protect Websocket lost connection. Reconnecting...")
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import asyncio
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from typing import AsyncIterator, List
|
||||
from typing import AsyncIterator, List, Set
|
||||
|
||||
import aiosqlite
|
||||
from dateutil.relativedelta import relativedelta
|
||||
@@ -12,6 +12,7 @@ from uiprotect.data.nvr import Event
|
||||
from uiprotect.data.types import EventType
|
||||
|
||||
from unifi_protect_backup import VideoDownloader, VideoUploader
|
||||
from unifi_protect_backup.utils import EVENT_TYPES_MAP, wanted_event_type
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -25,11 +26,11 @@ class MissingEventChecker:
|
||||
db: aiosqlite.Connection,
|
||||
download_queue: asyncio.Queue,
|
||||
downloader: VideoDownloader,
|
||||
uploader: VideoUploader,
|
||||
uploaders: List[VideoUploader],
|
||||
retention: relativedelta,
|
||||
detection_types: List[str],
|
||||
ignore_cameras: List[str],
|
||||
cameras: List[str],
|
||||
detection_types: Set[str],
|
||||
ignore_cameras: Set[str],
|
||||
cameras: Set[str],
|
||||
interval: int = 60 * 5,
|
||||
) -> None:
|
||||
"""Init.
|
||||
@@ -39,22 +40,23 @@ class MissingEventChecker:
|
||||
db (aiosqlite.Connection): Async SQLite database to check for missing events
|
||||
download_queue (asyncio.Queue): Download queue to check for on-going downloads
|
||||
downloader (VideoDownloader): Downloader to check for on-going downloads
|
||||
uploader (VideoUploader): Uploader to check for on-going uploads
|
||||
uploaders (List[VideoUploader]): Uploaders to check for on-going uploads
|
||||
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 (List[str]): Included (ONLY) camera IDs to limit search
|
||||
detection_types (Set[str]): Detection types wanted to limit search
|
||||
ignore_cameras (Set[str]): Ignored camera IDs to limit search
|
||||
cameras (Set[str]): Included (ONLY) camera IDs to limit search
|
||||
interval (int): How frequently, in seconds, to check for missing events,
|
||||
|
||||
"""
|
||||
self._protect: ProtectApiClient = protect
|
||||
self._db: aiosqlite.Connection = db
|
||||
self._download_queue: asyncio.Queue = download_queue
|
||||
self._downloader: VideoDownloader = downloader
|
||||
self._uploader: VideoUploader = uploader
|
||||
self._uploaders: List[VideoUploader] = uploaders
|
||||
self.retention: relativedelta = retention
|
||||
self.detection_types: List[str] = detection_types
|
||||
self.ignore_cameras: List[str] = ignore_cameras
|
||||
self.cameras: List[str] = cameras
|
||||
self.detection_types: Set[str] = detection_types
|
||||
self.ignore_cameras: Set[str] = ignore_cameras
|
||||
self.cameras: Set[str] = cameras
|
||||
self.interval: int = interval
|
||||
|
||||
async def _get_missing_events(self) -> AsyncIterator[Event]:
|
||||
@@ -68,12 +70,7 @@ class MissingEventChecker:
|
||||
events_chunk = await self._protect.get_events(
|
||||
start=start_time,
|
||||
end=end_time,
|
||||
types=[
|
||||
EventType.MOTION,
|
||||
EventType.SMART_DETECT,
|
||||
EventType.RING,
|
||||
EventType.SMART_DETECT_LINE,
|
||||
],
|
||||
types=list(EVENT_TYPES_MAP.keys()),
|
||||
limit=chunk_size,
|
||||
)
|
||||
|
||||
@@ -86,8 +83,8 @@ class MissingEventChecker:
|
||||
if not unifi_events:
|
||||
break # No completed events to process
|
||||
|
||||
# Next chunks start time should be the end of the oldest complete event in the current chunk
|
||||
start_time = max([event.end for event in unifi_events.values() if event.end is not None])
|
||||
# Next chunks start time should be the start of the oldest complete event in the current chunk
|
||||
start_time = max([event.start for event in unifi_events.values() if event.end is not None])
|
||||
|
||||
# Get list of events that have been backed up from the database
|
||||
|
||||
@@ -102,40 +99,28 @@ class MissingEventChecker:
|
||||
if current_download is not None:
|
||||
downloading_event_ids.add(current_download.id)
|
||||
|
||||
uploading_event_ids = {event.id for event, video in self._uploader.upload_queue._queue} # type: ignore
|
||||
current_upload = self._uploader.current_event
|
||||
if current_upload is not None:
|
||||
uploading_event_ids.add(current_upload.id)
|
||||
uploading_event_ids = {event.id for event, video in self._downloader.upload_queue._queue} # type: ignore
|
||||
for uploader in self._uploaders:
|
||||
current_upload = uploader.current_event
|
||||
if current_upload is not None:
|
||||
uploading_event_ids.add(current_upload.id)
|
||||
|
||||
missing_event_ids = set(unifi_events.keys()) - (db_event_ids | downloading_event_ids | uploading_event_ids)
|
||||
existing_ids = db_event_ids | downloading_event_ids | uploading_event_ids
|
||||
missing_events = {
|
||||
event_id: event for event_id, event in unifi_events.items() if event_id not in existing_ids
|
||||
}
|
||||
|
||||
# Exclude events of unwanted types
|
||||
def wanted_event_type(event_id):
|
||||
event = unifi_events[event_id]
|
||||
if event.start is None or event.end is None:
|
||||
return False # This event is still on-going
|
||||
if event.camera_id in self.ignore_cameras:
|
||||
return False
|
||||
if self.cameras 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:
|
||||
return False
|
||||
if event.type is EventType.SMART_DETECT_LINE and "line" not in self.detection_types:
|
||||
return False
|
||||
elif event.type is EventType.SMART_DETECT:
|
||||
for event_smart_detection_type in event.smart_detect_types:
|
||||
if event_smart_detection_type not in self.detection_types:
|
||||
return False
|
||||
return True
|
||||
|
||||
wanted_event_ids = set(filter(wanted_event_type, missing_event_ids))
|
||||
wanted_events = {
|
||||
event_id: event
|
||||
for event_id, event in missing_events.items()
|
||||
if wanted_event_type(event, self.detection_types, self.cameras, self.ignore_cameras)
|
||||
}
|
||||
|
||||
# Yeild events one by one to allow the async loop to start other task while
|
||||
# waiting on the full list of events
|
||||
for id in wanted_event_ids:
|
||||
yield unifi_events[id]
|
||||
for event in wanted_events.values():
|
||||
yield event
|
||||
|
||||
# Last chunk was in-complete, we can stop now
|
||||
if len(events_chunk) < chunk_size:
|
||||
@@ -155,7 +140,7 @@ class MissingEventChecker:
|
||||
await self._db.commit()
|
||||
|
||||
async def start(self):
|
||||
"""Main loop."""
|
||||
"""Run main loop."""
|
||||
logger.info("Starting Missing Event Checker")
|
||||
while True:
|
||||
try:
|
||||
|
||||
@@ -13,14 +13,14 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def delete_file(file_path, rclone_purge_args):
|
||||
"""Deletes `file_path` via rclone."""
|
||||
"""Delete `file_path` via rclone."""
|
||||
returncode, stdout, stderr = await run_command(f'rclone delete -vv "{file_path}" {rclone_purge_args}')
|
||||
if returncode != 0:
|
||||
logger.error(f" Failed to delete file: '{file_path}'")
|
||||
|
||||
|
||||
async def tidy_empty_dirs(base_dir_path):
|
||||
"""Deletes any empty directories in `base_dir_path` via rclone."""
|
||||
"""Delete any empty directories in `base_dir_path` via rclone."""
|
||||
returncode, stdout, stderr = await run_command(f'rclone rmdirs -vv --ignore-errors --leave-root "{base_dir_path}"')
|
||||
if returncode != 0:
|
||||
logger.error(" Failed to tidy empty dirs")
|
||||
@@ -34,7 +34,7 @@ class Purge:
|
||||
db: aiosqlite.Connection,
|
||||
retention: relativedelta,
|
||||
rclone_destination: str,
|
||||
interval: relativedelta = relativedelta(days=1),
|
||||
interval: relativedelta | None,
|
||||
rclone_purge_args: str = "",
|
||||
):
|
||||
"""Init.
|
||||
@@ -45,15 +45,16 @@ class Purge:
|
||||
rclone_destination (str): What rclone destination the clips are stored in
|
||||
interval (relativedelta): How often to purge old clips
|
||||
rclone_purge_args (str): Optional extra arguments to pass to `rclone delete` directly.
|
||||
|
||||
"""
|
||||
self._db: aiosqlite.Connection = db
|
||||
self.retention: relativedelta = retention
|
||||
self.rclone_destination: str = rclone_destination
|
||||
self.interval: relativedelta = interval
|
||||
self.interval: relativedelta = interval if interval is not None else relativedelta(days=1)
|
||||
self.rclone_purge_args: str = rclone_purge_args
|
||||
|
||||
async def start(self):
|
||||
"""Main loop - runs forever."""
|
||||
"""Run main loop."""
|
||||
while True:
|
||||
try:
|
||||
deleted_a_file = False
|
||||
@@ -63,14 +64,14 @@ class Purge:
|
||||
async with self._db.execute(
|
||||
f"SELECT * FROM events WHERE end < {retention_oldest_time}"
|
||||
) as event_cursor:
|
||||
async for event_id, event_type, camera_id, event_start, event_end in event_cursor:
|
||||
async for event_id, event_type, camera_id, event_start, event_end in event_cursor: # noqa: B007
|
||||
logger.info(f"Purging event: {event_id}.")
|
||||
|
||||
# For every backup for this event
|
||||
async with self._db.execute(f"SELECT * FROM backups WHERE id = '{event_id}'") as backup_cursor:
|
||||
async for _, remote, file_path in backup_cursor:
|
||||
logger.debug(f" Deleted: {remote}:{file_path}")
|
||||
await delete_file(f"{remote}:{file_path}", self.rclone_purge_args)
|
||||
logger.debug(f" Deleted: {remote}:{file_path}")
|
||||
deleted_a_file = True
|
||||
|
||||
# delete event from database
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
"""Monkey patch new download method into uiprotect till PR is merged."""
|
||||
|
||||
import enum
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
@@ -10,13 +12,15 @@ from uiprotect.exceptions import BadRequest
|
||||
from uiprotect.utils import to_js_time
|
||||
|
||||
|
||||
# First, let's add the new VideoExportType enum
|
||||
class VideoExportType(str, enum.Enum):
|
||||
"""Unifi Protect video export types."""
|
||||
|
||||
TIMELAPSE = "timelapse"
|
||||
ROTATING = "rotating"
|
||||
|
||||
|
||||
def monkey_patch_experimental_downloader():
|
||||
"""Apply patches to uiprotect to add new download method."""
|
||||
from uiprotect.api import ProtectApiClient
|
||||
|
||||
# Add the version constant
|
||||
|
||||
@@ -43,7 +43,7 @@ monkey_patch_experimental_downloader()
|
||||
|
||||
|
||||
async def create_database(path: str):
|
||||
"""Creates sqlite database and creates the events abd backups tables."""
|
||||
"""Create sqlite database and creates the events abd backups tables."""
|
||||
db = await aiosqlite.connect(path)
|
||||
await db.execute("CREATE TABLE events(id PRIMARY KEY, type, camera_id, start REAL, end REAL)")
|
||||
await db.execute(
|
||||
@@ -68,6 +68,7 @@ class UnifiProtectBackup:
|
||||
verify_ssl: bool,
|
||||
rclone_destination: str,
|
||||
retention: relativedelta,
|
||||
missing_range: relativedelta,
|
||||
rclone_args: str,
|
||||
rclone_purge_args: str,
|
||||
detection_types: List[str],
|
||||
@@ -85,6 +86,7 @@ class UnifiProtectBackup:
|
||||
download_rate_limit: float | None = None,
|
||||
port: int = 443,
|
||||
use_experimental_downloader: bool = False,
|
||||
parallel_uploads: int = 1,
|
||||
):
|
||||
"""Will configure logging settings and the Unifi Protect API (but not actually connect).
|
||||
|
||||
@@ -97,9 +99,13 @@ class UnifiProtectBackup:
|
||||
rclone_destination (str): `rclone` destination path in the format
|
||||
{rclone remote}:{path on remote}. E.g.
|
||||
`gdrive:/backups/unifi_protect`
|
||||
retention (str): How long should event clips be backed up for. Format as per the
|
||||
retention (relativedelta): How long should event clips be backed up for. Format as per the
|
||||
`--max-age` argument of `rclone`
|
||||
(https://rclone.org/filtering/#max-age-don-t-transfer-any-file-older-than-this)
|
||||
missing_range (relativedelta): How far back should missing events be checked for. Defaults to
|
||||
the same as the retention time. Format as per the
|
||||
`--max-age` argument of `rclone`
|
||||
(https://rclone.org/filtering/#max-age-don-t-transfer-any-file-older-than-this)
|
||||
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.
|
||||
@@ -116,7 +122,10 @@ class UnifiProtectBackup:
|
||||
color_logging (bool): Whether to add color to logging output or not
|
||||
download_rate_limit (float): Limit how events can be downloaded in one minute. Disabled by default",
|
||||
max_event_length (int): Maximum length in seconds for an event to be considered valid and downloaded
|
||||
use_experimental_downloader (bool): Use the new experimental downloader (the same method as used by the webUI)
|
||||
use_experimental_downloader (bool): Use the new experimental downloader (the same method as used by the
|
||||
webUI)
|
||||
parallel_uploads (int): Max number of parallel uploads to allow
|
||||
|
||||
"""
|
||||
self.color_logging = color_logging
|
||||
setup_logging(verbose, self.color_logging)
|
||||
@@ -140,6 +149,7 @@ class UnifiProtectBackup:
|
||||
logger.debug(f" {verify_ssl=}")
|
||||
logger.debug(f" {rclone_destination=}")
|
||||
logger.debug(f" {retention=}")
|
||||
logger.debug(f" {missing_range=}")
|
||||
logger.debug(f" {rclone_args=}")
|
||||
logger.debug(f" {rclone_purge_args=}")
|
||||
logger.debug(f" {ignore_cameras=}")
|
||||
@@ -155,9 +165,11 @@ class UnifiProtectBackup:
|
||||
logger.debug(f" {download_rate_limit=} events per minute")
|
||||
logger.debug(f" {max_event_length=}s")
|
||||
logger.debug(f" {use_experimental_downloader=}")
|
||||
logger.debug(f" {parallel_uploads=}")
|
||||
|
||||
self.rclone_destination = rclone_destination
|
||||
self.retention = retention
|
||||
self.missing_range = missing_range
|
||||
self.rclone_args = rclone_args
|
||||
self.rclone_purge_args = rclone_purge_args
|
||||
self.file_structure_format = file_structure_format
|
||||
@@ -176,11 +188,11 @@ class UnifiProtectBackup:
|
||||
verify_ssl=self.verify_ssl,
|
||||
subscribed_models={ModelType.EVENT},
|
||||
)
|
||||
self.ignore_cameras = ignore_cameras
|
||||
self.cameras = cameras
|
||||
self.ignore_cameras = set(ignore_cameras)
|
||||
self.cameras = set(cameras)
|
||||
self._download_queue: asyncio.Queue = asyncio.Queue()
|
||||
self._unsub: Callable[[], None]
|
||||
self.detection_types = detection_types
|
||||
self.detection_types = set(detection_types)
|
||||
self._has_ffprobe = False
|
||||
self._sqlite_path = sqlite_path
|
||||
self._db = None
|
||||
@@ -190,6 +202,7 @@ class UnifiProtectBackup:
|
||||
self._download_rate_limit = download_rate_limit
|
||||
self._max_event_length = timedelta(seconds=max_event_length)
|
||||
self._use_experimental_downloader = use_experimental_downloader
|
||||
self._parallel_uploads = parallel_uploads
|
||||
|
||||
async def start(self):
|
||||
"""Bootstrap the backup process and kick off the main loop.
|
||||
@@ -212,7 +225,7 @@ class UnifiProtectBackup:
|
||||
delay = 5 # Start with a 5 second delay
|
||||
max_delay = 3600 # 1 hour in seconds
|
||||
|
||||
for attempts in range(20):
|
||||
for _ in range(20):
|
||||
try:
|
||||
await self._protect.update()
|
||||
break
|
||||
@@ -272,18 +285,21 @@ class UnifiProtectBackup:
|
||||
)
|
||||
tasks.append(downloader.start())
|
||||
|
||||
# Create upload task
|
||||
# Create upload tasks
|
||||
# This will upload the videos in the downloader's buffer to the rclone remotes and log it in the database
|
||||
uploader = VideoUploader(
|
||||
self._protect,
|
||||
upload_queue,
|
||||
self.rclone_destination,
|
||||
self.rclone_args,
|
||||
self.file_structure_format,
|
||||
self._db,
|
||||
self.color_logging,
|
||||
)
|
||||
tasks.append(uploader.start())
|
||||
uploaders = []
|
||||
for _ in range(self._parallel_uploads):
|
||||
uploader = VideoUploader(
|
||||
self._protect,
|
||||
upload_queue,
|
||||
self.rclone_destination,
|
||||
self.rclone_args,
|
||||
self.file_structure_format,
|
||||
self._db,
|
||||
self.color_logging,
|
||||
)
|
||||
uploaders.append(uploader)
|
||||
tasks.append(uploader.start())
|
||||
|
||||
# Create event listener task
|
||||
# This will connect to the unifi protect websocket and listen for events. When one is detected it will
|
||||
@@ -305,15 +321,15 @@ class UnifiProtectBackup:
|
||||
tasks.append(purge.start())
|
||||
|
||||
# Create missing event task
|
||||
# This will check all the events within the retention period, if any have been missed and not backed up
|
||||
# they will be added to the event queue
|
||||
# This will check all the events within the missing_range period, if any have been missed and not
|
||||
# backed up. they will be added to the event queue
|
||||
missing = MissingEventChecker(
|
||||
self._protect,
|
||||
self._db,
|
||||
download_queue,
|
||||
downloader,
|
||||
uploader,
|
||||
self.retention,
|
||||
uploaders,
|
||||
self.missing_range,
|
||||
self.detection_types,
|
||||
self.ignore_cameras,
|
||||
self.cameras,
|
||||
|
||||
@@ -45,6 +45,7 @@ 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
|
||||
|
||||
"""
|
||||
self._protect: ProtectApiClient = protect
|
||||
self.upload_queue: VideoQueue = upload_queue
|
||||
@@ -59,7 +60,7 @@ class VideoUploader:
|
||||
self.logger = logging.LoggerAdapter(self.base_logger, {"event": ""})
|
||||
|
||||
async def start(self):
|
||||
"""Main loop.
|
||||
"""Run main loop.
|
||||
|
||||
Runs forever looking for video data in the video queue and then uploads it
|
||||
using rclone, finally it updates the database
|
||||
@@ -106,6 +107,7 @@ class VideoUploader:
|
||||
|
||||
Raises:
|
||||
RuntimeError: If rclone returns a non-zero exit code
|
||||
|
||||
"""
|
||||
returncode, stdout, stderr = await run_command(f'rclone rcat -vv {rclone_args} "{destination}"', video)
|
||||
if returncode != 0:
|
||||
@@ -131,7 +133,7 @@ class VideoUploader:
|
||||
await self._db.commit()
|
||||
|
||||
async def _generate_file_path(self, event: Event) -> pathlib.Path:
|
||||
"""Generates the rclone destination path for the provided event.
|
||||
"""Generate the rclone destination path for the provided event.
|
||||
|
||||
Generates rclone destination path for the given even based upon the format string
|
||||
in `self.file_structure_format`.
|
||||
|
||||
@@ -4,12 +4,13 @@ import asyncio
|
||||
import logging
|
||||
import re
|
||||
from datetime import datetime
|
||||
from typing import List, Optional
|
||||
from typing import Optional, Set
|
||||
|
||||
from apprise import NotifyType
|
||||
from async_lru import alru_cache
|
||||
from uiprotect import ProtectApiClient
|
||||
from uiprotect.data.nvr import Event
|
||||
from uiprotect.data.types import EventType, SmartDetectObjectType, SmartDetectAudioType
|
||||
|
||||
from unifi_protect_backup import notifications
|
||||
|
||||
@@ -109,6 +110,9 @@ class AppriseStreamHandler(logging.StreamHandler):
|
||||
|
||||
Args:
|
||||
color_logging (bool): If true logging levels will be colorized
|
||||
*args (): Positional arguments to pass to StreamHandler
|
||||
**kwargs: Keyword arguments to pass to StreamHandler
|
||||
|
||||
"""
|
||||
super().__init__(*args, **kwargs)
|
||||
self.color_logging = color_logging
|
||||
@@ -172,7 +176,7 @@ class AppriseStreamHandler(logging.StreamHandler):
|
||||
|
||||
|
||||
def create_logging_handler(format, color_logging):
|
||||
"""Constructs apprise logging handler for the given format."""
|
||||
"""Construct apprise logging handler for the given format."""
|
||||
date_format = "%Y-%m-%d %H:%M:%S"
|
||||
style = "{"
|
||||
|
||||
@@ -182,8 +186,8 @@ def create_logging_handler(format, color_logging):
|
||||
return sh
|
||||
|
||||
|
||||
def setup_logging(verbosity: int, color_logging: bool = False, apprise_notifiers: List[str] = []) -> None:
|
||||
"""Configures loggers to provided the desired level of verbosity.
|
||||
def setup_logging(verbosity: int, color_logging: bool = False) -> None:
|
||||
"""Configure loggers to provided the desired level of verbosity.
|
||||
|
||||
Verbosity 0: Only log info messages created by `unifi-protect-backup`, and all warnings
|
||||
verbosity 1: Only log info & debug messages created by `unifi-protect-backup`, and all warnings
|
||||
@@ -199,7 +203,6 @@ def setup_logging(verbosity: int, color_logging: bool = False, apprise_notifiers
|
||||
Args:
|
||||
verbosity (int): The desired level of verbosity
|
||||
color_logging (bool): If colors should be used in the log (default=False)
|
||||
apprise_notifiers (List[str]): Notification services to hook into the logger
|
||||
|
||||
"""
|
||||
add_logging_level(
|
||||
@@ -238,24 +241,31 @@ def setup_logging(verbosity: int, color_logging: bool = False, apprise_notifiers
|
||||
logger.setLevel(logging.WEBSOCKET_DATA) # type: ignore
|
||||
|
||||
|
||||
_initialized_loggers = []
|
||||
|
||||
|
||||
def setup_event_logger(logger, color_logging):
|
||||
"""Sets up a logger that also displays the event ID currently being processed."""
|
||||
format = "{asctime} [{levelname:^11s}] {name:<42} :{event} {message}"
|
||||
sh = create_logging_handler(format, color_logging)
|
||||
logger.addHandler(sh)
|
||||
logger.propagate = False
|
||||
"""Set up a logger that also displays the event ID currently being processed."""
|
||||
global _initialized_loggers
|
||||
if logger not in _initialized_loggers:
|
||||
format = "{asctime} [{levelname:^11s}] {name:<42} :{event} {message}"
|
||||
sh = create_logging_handler(format, color_logging)
|
||||
logger.addHandler(sh)
|
||||
logger.propagate = False
|
||||
_initialized_loggers.append(logger)
|
||||
|
||||
|
||||
_suffixes = ["B", "KiB", "MiB", "GiB", "TiB", "PiB", "EiB", "ZiB", "YiB"]
|
||||
|
||||
|
||||
def human_readable_size(num: float):
|
||||
"""Turns a number into a human readable number with ISO/IEC 80000 binary prefixes.
|
||||
"""Turn a number into a human readable number with ISO/IEC 80000 binary prefixes.
|
||||
|
||||
Based on: https://stackoverflow.com/a/1094933
|
||||
|
||||
Args:
|
||||
num (int): The number to be converted into human readable format
|
||||
|
||||
"""
|
||||
for unit in _suffixes:
|
||||
if abs(num) < 1024.0:
|
||||
@@ -265,7 +275,7 @@ def human_readable_size(num: float):
|
||||
|
||||
|
||||
def human_readable_to_float(num: str):
|
||||
"""Turns a human readable ISO/IEC 80000 suffix value to its full float value."""
|
||||
"""Turn a human readable ISO/IEC 80000 suffix value to its full float value."""
|
||||
pattern = r"([\d.]+)(" + "|".join(_suffixes) + ")"
|
||||
result = re.match(pattern, num)
|
||||
if result is None:
|
||||
@@ -281,7 +291,7 @@ def human_readable_to_float(num: str):
|
||||
# No max size, and a 6 hour ttl
|
||||
@alru_cache(None, ttl=60 * 60 * 6)
|
||||
async def get_camera_name(protect: ProtectApiClient, id: str):
|
||||
"""Returns the name for the camera with the given ID.
|
||||
"""Return the name for the camera with the given ID.
|
||||
|
||||
If the camera ID is not know, it tries refreshing the cached data
|
||||
"""
|
||||
@@ -316,6 +326,7 @@ class SubprocessException(Exception):
|
||||
stdout (str): What rclone output to stdout
|
||||
stderr (str): What rclone output to stderr
|
||||
returncode (str): The return code of the rclone process
|
||||
|
||||
"""
|
||||
super().__init__()
|
||||
self.stdout: str = stdout
|
||||
@@ -323,12 +334,12 @@ class SubprocessException(Exception):
|
||||
self.returncode: int = returncode
|
||||
|
||||
def __str__(self):
|
||||
"""Turns exception into a human readable form."""
|
||||
"""Turn exception into a human readable form."""
|
||||
return f"Return Code: {self.returncode}\nStdout:\n{self.stdout}\nStderr:\n{self.stderr}"
|
||||
|
||||
|
||||
async def run_command(cmd: str, data=None):
|
||||
"""Runs the given command returning the exit code, stdout and stderr."""
|
||||
"""Run the given command returning the exit code, stdout and stderr."""
|
||||
proc = await asyncio.create_subprocess_shell(
|
||||
cmd,
|
||||
stdin=asyncio.subprocess.PIPE,
|
||||
@@ -361,11 +372,11 @@ class VideoQueue(asyncio.Queue):
|
||||
self._bytes_sum = 0
|
||||
|
||||
def qsize(self):
|
||||
"""Number of items in the queue."""
|
||||
"""Get number of items in the queue."""
|
||||
return self._bytes_sum
|
||||
|
||||
def qsize_files(self):
|
||||
"""Number of items in the queue."""
|
||||
"""Get number of items in the queue."""
|
||||
return super().qsize()
|
||||
|
||||
def _get(self):
|
||||
@@ -407,7 +418,7 @@ class VideoQueue(asyncio.Queue):
|
||||
)
|
||||
|
||||
while self.full(item):
|
||||
putter = self._loop.create_future() # type: ignore
|
||||
putter = self._get_loop().create_future() # type: ignore
|
||||
self._putters.append(putter) # type: ignore
|
||||
try:
|
||||
await putter
|
||||
@@ -444,3 +455,38 @@ async def wait_until(dt):
|
||||
"""Sleep until the specified datetime."""
|
||||
now = datetime.now()
|
||||
await asyncio.sleep((dt - now).total_seconds())
|
||||
|
||||
|
||||
EVENT_TYPES_MAP = {
|
||||
EventType.MOTION: {"motion"},
|
||||
EventType.RING: {"ring"},
|
||||
EventType.SMART_DETECT_LINE: {"line"},
|
||||
EventType.FINGERPRINT_IDENTIFIED: {"fingerprint"},
|
||||
EventType.NFC_CARD_SCANNED: {"nfc"},
|
||||
EventType.SMART_DETECT: {t for t in SmartDetectObjectType.values() if t not in SmartDetectAudioType.values()},
|
||||
EventType.SMART_AUDIO_DETECT: {f"{t}" for t in SmartDetectAudioType.values()},
|
||||
}
|
||||
|
||||
|
||||
def wanted_event_type(event, wanted_detection_types: Set[str], cameras: Set[str], ignore_cameras: Set[str]):
|
||||
"""Return True if this event is one we want."""
|
||||
if event.start is None or event.end is None:
|
||||
return False # This event is still on-going
|
||||
|
||||
if event.camera_id in ignore_cameras:
|
||||
return False
|
||||
|
||||
if cameras and event.camera_id not in cameras:
|
||||
return False
|
||||
|
||||
if event.type not in EVENT_TYPES_MAP:
|
||||
return False
|
||||
|
||||
if event.type in [EventType.SMART_DETECT, EventType.SMART_AUDIO_DETECT]:
|
||||
detection_types = set(event.smart_detect_types)
|
||||
else:
|
||||
detection_types = EVENT_TYPES_MAP[event.type]
|
||||
if not detection_types & wanted_detection_types: # No intersection
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
Reference in New Issue
Block a user