updl 3.0.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- updl-3.0.0/LICENSE +22 -0
- updl-3.0.0/NOTICE +35 -0
- updl-3.0.0/PKG-INFO +214 -0
- updl-3.0.0/README.md +180 -0
- updl-3.0.0/protect_archiver/__init__.py +0 -0
- updl-3.0.0/protect_archiver/cli/__init__.py +43 -0
- updl-3.0.0/protect_archiver/cli/base.py +13 -0
- updl-3.0.0/protect_archiver/cli/download.py +333 -0
- updl-3.0.0/protect_archiver/cli/events.py +297 -0
- updl-3.0.0/protect_archiver/cli/resolve.py +27 -0
- updl-3.0.0/protect_archiver/cli/sync.py +319 -0
- updl-3.0.0/protect_archiver/cli/verify.py +263 -0
- updl-3.0.0/protect_archiver/client/__init__.py +109 -0
- updl-3.0.0/protect_archiver/client/legacy.py +72 -0
- updl-3.0.0/protect_archiver/client/unifi_os.py +214 -0
- updl-3.0.0/protect_archiver/config.py +36 -0
- updl-3.0.0/protect_archiver/dataclasses.py +24 -0
- updl-3.0.0/protect_archiver/downloader/__init__.py +81 -0
- updl-3.0.0/protect_archiver/downloader/download_file.py +262 -0
- updl-3.0.0/protect_archiver/downloader/download_footage.py +162 -0
- updl-3.0.0/protect_archiver/downloader/download_motion_event.py +61 -0
- updl-3.0.0/protect_archiver/downloader/download_snapshot.py +48 -0
- updl-3.0.0/protect_archiver/downloader/get_camera_list.py +57 -0
- updl-3.0.0/protect_archiver/downloader/get_motion_event_list.py +85 -0
- updl-3.0.0/protect_archiver/errors.py +44 -0
- updl-3.0.0/protect_archiver/manifest.py +353 -0
- updl-3.0.0/protect_archiver/reconcile.py +148 -0
- updl-3.0.0/protect_archiver/session_store.py +161 -0
- updl-3.0.0/protect_archiver/settings.py +160 -0
- updl-3.0.0/protect_archiver/sync.py +175 -0
- updl-3.0.0/protect_archiver/test_client.py +116 -0
- updl-3.0.0/protect_archiver/test_download_file.py +244 -0
- updl-3.0.0/protect_archiver/test_manifest.py +100 -0
- updl-3.0.0/protect_archiver/test_reconcile.py +101 -0
- updl-3.0.0/protect_archiver/test_session_store.py +96 -0
- updl-3.0.0/protect_archiver/test_settings.py +126 -0
- updl-3.0.0/protect_archiver/test_sync.py +346 -0
- updl-3.0.0/protect_archiver/test_unifi_os_client.py +212 -0
- updl-3.0.0/protect_archiver/test_utils.py +129 -0
- updl-3.0.0/protect_archiver/test_verify.py +183 -0
- updl-3.0.0/protect_archiver/utils.py +205 -0
- updl-3.0.0/protect_archiver/verify.py +255 -0
- updl-3.0.0/pyproject.toml +109 -0
updl-3.0.0/LICENSE
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2020 Daniel Fernau, David Cramer and individual contributors.
|
|
4
|
+
Copyright (c) 2026 Will Stone and contributors.
|
|
5
|
+
|
|
6
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
7
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
8
|
+
in the Software without restriction, including without limitation the rights
|
|
9
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
10
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
11
|
+
furnished to do so, subject to the following conditions:
|
|
12
|
+
|
|
13
|
+
The above copyright notice and this permission notice shall be included in all
|
|
14
|
+
copies or substantial portions of the Software.
|
|
15
|
+
|
|
16
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
17
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
18
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
19
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
20
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
21
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
22
|
+
SOFTWARE.
|
updl-3.0.0/NOTICE
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
updl
|
|
2
|
+
====
|
|
3
|
+
|
|
4
|
+
Copyright (c) 2026 Will Stone and contributors.
|
|
5
|
+
|
|
6
|
+
This product is a derivative work of:
|
|
7
|
+
|
|
8
|
+
unifi-protect-video-downloader
|
|
9
|
+
https://github.com/danielfernau/unifi-protect-video-downloader
|
|
10
|
+
Copyright (c) 2020 Daniel Fernau, David Cramer and individual contributors.
|
|
11
|
+
Licensed under the MIT License.
|
|
12
|
+
|
|
13
|
+
The original project's MIT License is reproduced in full in the LICENSE file and
|
|
14
|
+
continues to govern the portions of this work derived from it. The upstream commit
|
|
15
|
+
history is preserved in this repository, so authorship of the original code remains
|
|
16
|
+
attributable commit by commit.
|
|
17
|
+
|
|
18
|
+
The Protect API client, CLI structure, interval arithmetic and download machinery
|
|
19
|
+
originate with the upstream project. This fork exists to make the archive re-runnable and
|
|
20
|
+
verifiable, and adds:
|
|
21
|
+
|
|
22
|
+
* a SQLite manifest recording every archived hour, so a later run downloads only what is
|
|
23
|
+
genuinely missing rather than resuming from a single cursor per camera;
|
|
24
|
+
* distinct handling of hours the NVR reports as having no footage, so real recording
|
|
25
|
+
gaps settle once instead of being retried forever;
|
|
26
|
+
* integrity verification at four levels, from a size check to an ffprobe decode, with an
|
|
27
|
+
offline audit command;
|
|
28
|
+
* detection of exports that arrived without an audio track, which UniFi Protect returns
|
|
29
|
+
silently to an under-permissioned account;
|
|
30
|
+
* atomic downloads, exponential backoff on transient server errors, and session-token
|
|
31
|
+
caching for accounts behind Ubiquiti SSO multi-factor authentication.
|
|
32
|
+
|
|
33
|
+
Not affiliated with, endorsed by, or supported by Ubiquiti Inc. "UniFi", "Protect" and
|
|
34
|
+
"Ubiquiti" are trademarks of Ubiquiti Inc., used here solely to describe the system this
|
|
35
|
+
software interoperates with.
|
updl-3.0.0/PKG-INFO
ADDED
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: updl
|
|
3
|
+
Version: 3.0.0
|
|
4
|
+
Summary: Resumable, verifiable archiver for UniFi Protect footage
|
|
5
|
+
License-Expression: MIT
|
|
6
|
+
License-File: LICENSE
|
|
7
|
+
License-File: NOTICE
|
|
8
|
+
Keywords: unifi,protect,nvr,video,archive,backup,ubiquiti
|
|
9
|
+
Author: Will Stone
|
|
10
|
+
Author-email: willstone@gmail.com
|
|
11
|
+
Requires-Python: >=3.10,<4.0
|
|
12
|
+
Classifier: Development Status :: 4 - Beta
|
|
13
|
+
Classifier: Environment :: Console
|
|
14
|
+
Classifier: Intended Audience :: System Administrators
|
|
15
|
+
Classifier: Natural Language :: English
|
|
16
|
+
Classifier: Operating System :: OS Independent
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
21
|
+
Classifier: Topic :: Multimedia :: Video
|
|
22
|
+
Classifier: Topic :: System :: Archiving :: Backup
|
|
23
|
+
Classifier: Typing :: Typed
|
|
24
|
+
Requires-Dist: click (>=8.1.8,<9.0.0)
|
|
25
|
+
Requires-Dist: python-dateutil (>=2.9.0,<3.0.0)
|
|
26
|
+
Requires-Dist: requests[security] (>=2.33.0,<3.0.0)
|
|
27
|
+
Project-URL: Bug Tracker, https://github.com/tribixbite/updl/issues
|
|
28
|
+
Project-URL: Documentation, https://github.com/tribixbite/updl#readme
|
|
29
|
+
Project-URL: Homepage, https://github.com/tribixbite/updl
|
|
30
|
+
Project-URL: Repository, https://github.com/tribixbite/updl
|
|
31
|
+
Project-URL: Upstream project, https://github.com/danielfernau/unifi-protect-video-downloader
|
|
32
|
+
Description-Content-Type: text/markdown
|
|
33
|
+
|
|
34
|
+
# updl
|
|
35
|
+
|
|
36
|
+
[](https://github.com/tribixbite/updl/actions/workflows/pythonpackage.yml)
|
|
37
|
+
[](https://pypi.org/project/updl/)
|
|
38
|
+
[](https://pypi.org/project/updl/)
|
|
39
|
+
[](LICENSE)
|
|
40
|
+
|
|
41
|
+
**Resumable, verifiable archiver for UniFi Protect footage.**
|
|
42
|
+
|
|
43
|
+
Back up a UniFi Protect system to local storage in a way you can re-run at any time: it
|
|
44
|
+
downloads only what is genuinely missing, remembers the hours the NVR had no footage for,
|
|
45
|
+
retries the ones that failed, and can prove that what is already on disk is still intact.
|
|
46
|
+
|
|
47
|
+
```console
|
|
48
|
+
$ updl sync /srv/protect
|
|
49
|
+
Archive currently holds 367 segment(s) (122 empty, 245 ok)
|
|
50
|
+
0 files downloaded (0.0 b), 367 already archived, 0 files skipped, 0 files failed
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
That run took **0.35 seconds and made no requests to the NVR at all**, because everything
|
|
54
|
+
was already held. The first run of the same command downloaded 58.5 GB.
|
|
55
|
+
|
|
56
|
+
> Not affiliated with, endorsed by, or supported by Ubiquiti Inc. "UniFi" and "Protect"
|
|
57
|
+
> are trademarks of Ubiquiti Inc., used here only to describe what this tool talks to.
|
|
58
|
+
|
|
59
|
+
## Install
|
|
60
|
+
|
|
61
|
+
```console
|
|
62
|
+
pip install updl
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
Python 3.10+. `ffprobe` (from FFmpeg) is optional and only needed for `--verify=deep` and
|
|
66
|
+
`--require-audio`.
|
|
67
|
+
|
|
68
|
+
## Quick start
|
|
69
|
+
|
|
70
|
+
The destination must already exist. Then, on a new machine:
|
|
71
|
+
|
|
72
|
+
```console
|
|
73
|
+
updl -a 192.168.1.1 -u archiver -p '...' -d /srv/protect
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
If the account uses multi-factor authentication you are prompted for a code once. The
|
|
77
|
+
session token is cached, so you are not asked again until it expires.
|
|
78
|
+
|
|
79
|
+
**Every run after that needs no arguments at all:**
|
|
80
|
+
|
|
81
|
+
```console
|
|
82
|
+
updl
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
The console address, account and destination are remembered from the last successful run.
|
|
86
|
+
Your password is **never** written to disk — it is asked for again only when the cached
|
|
87
|
+
token expires.
|
|
88
|
+
|
|
89
|
+
Prefer the environment to flags for the password, since a command-line argument is visible
|
|
90
|
+
to every other process on the machine:
|
|
91
|
+
|
|
92
|
+
```console
|
|
93
|
+
export PROTECT_PASSWORD='...'
|
|
94
|
+
updl
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
Every option has a matching `PROTECT_*` environment variable. Precedence is: an explicit
|
|
98
|
+
flag, then the environment variable, then what was remembered, then the built-in default.
|
|
99
|
+
|
|
100
|
+
Where state is kept — override the first two with `UPDL_CONFIG` and `PROTECT_SESSION_STORE`:
|
|
101
|
+
|
|
102
|
+
| What | Windows | Linux / macOS |
|
|
103
|
+
| --- | --- | --- |
|
|
104
|
+
| Remembered settings | `%LOCALAPPDATA%\updl\config.json` | `~/.config/updl/config.json` |
|
|
105
|
+
| Session token | `%LOCALAPPDATA%\protect-archiver\sessions.json` | `~/.local/state/protect-archiver/sessions.json` |
|
|
106
|
+
| Archive index | `DEST\.protect-archive\manifest.db` | `DEST/.protect-archive/manifest.db` |
|
|
107
|
+
|
|
108
|
+
## Commands
|
|
109
|
+
|
|
110
|
+
`updl` on its own runs `sync`. Sub-commands can always be named explicitly:
|
|
111
|
+
|
|
112
|
+
| Command | What it does |
|
|
113
|
+
| --- | --- |
|
|
114
|
+
| `updl sync [DEST]` | Incremental mirror. Sweeps each camera's retention window and fetches only what is missing or damaged. |
|
|
115
|
+
| `updl verify [DEST]` | Audits an archive **offline** — never contacts the NVR, so it is safe to run against a backup copy. |
|
|
116
|
+
| `updl download DEST` | One-off download of an explicit `--start`/`--end` range. |
|
|
117
|
+
| `updl events DEST` | Motion and smart-detection event clips only. |
|
|
118
|
+
|
|
119
|
+
## How re-running avoids re-downloading
|
|
120
|
+
|
|
121
|
+
A SQLite manifest at `DEST/.protect-archive/manifest.db` holds one row per hour per
|
|
122
|
+
camera, keyed on `(camera_id, start_ms)`, recording the path, size, SHA-256 and a status:
|
|
123
|
+
|
|
124
|
+
- **`ok`** — downloaded and accounted for.
|
|
125
|
+
- **`empty`** — the NVR reported no footage for that hour, so it is never requested again
|
|
126
|
+
and the gap stays visible as a deliberate record rather than as an error.
|
|
127
|
+
- **`failed`** — the download failed; a later run retries exactly that hour.
|
|
128
|
+
|
|
129
|
+
Because the manifest is authoritative, `sync` sweeps the whole retention window every run
|
|
130
|
+
and still costs nothing when there is nothing to do. Gaps left by earlier failures are
|
|
131
|
+
filled automatically, and footage that has since aged off the NVR stays in the archive.
|
|
132
|
+
|
|
133
|
+
## Verifying what is on disk
|
|
134
|
+
|
|
135
|
+
`--verify` controls how hard `sync` works to prove an existing file is intact before
|
|
136
|
+
skipping it. `updl verify` accepts the same levels for an offline audit.
|
|
137
|
+
|
|
138
|
+
| Level | Checks | Cost |
|
|
139
|
+
| --- | --- | --- |
|
|
140
|
+
| `none` | trusts the manifest | touches no files |
|
|
141
|
+
| `quick` *(default)* | file exists at the recorded size | one `stat` per segment |
|
|
142
|
+
| `hash` | SHA-256 recomputed | reads the whole archive |
|
|
143
|
+
| `deep` | + `ffprobe` decodes it | catches a file that is the right size and hash but unplayable |
|
|
144
|
+
|
|
145
|
+
```console
|
|
146
|
+
# Audit, then queue anything damaged for the next sync to re-fetch.
|
|
147
|
+
updl verify /srv/protect --level hash --repair
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
Other `verify` flags: `--require-audio` (see below), `--rehash` to fill in hashes for rows
|
|
151
|
+
adopted from disk, `--fix-timestamps`, and `--clean-partials`.
|
|
152
|
+
|
|
153
|
+
## Things this handles that are easy to get wrong
|
|
154
|
+
|
|
155
|
+
**Audio is silently dropped for under-permissioned accounts.** If the Protect account
|
|
156
|
+
lacks `readmedia` on a camera, the export still returns 200 with intact video — just no
|
|
157
|
+
audio track. Nothing fails, so an archive can accumulate for weeks before anyone plays a
|
|
158
|
+
clip and finds it silent. `updl verify DEST --require-audio` inspects the streams and
|
|
159
|
+
reports it; add `--repair` to re-fetch. If it flags everything, grant the account camera
|
|
160
|
+
media permission and run it again.
|
|
161
|
+
|
|
162
|
+
**"No footage" is reported as an HTTP 404 carrying `{"error": 502}`.** Neither number
|
|
163
|
+
means what it looks like. Treated as a failure, every hour a camera was offline would be
|
|
164
|
+
re-requested forever; `updl` records it as `empty` instead.
|
|
165
|
+
|
|
166
|
+
**Downloads are atomic.** Bytes land in a `.part` file and are renamed into place only on
|
|
167
|
+
success, so an interrupted run cannot leave a truncated MP4 that later runs mistake for a
|
|
168
|
+
complete one. Stale partials are swept at startup.
|
|
169
|
+
|
|
170
|
+
**Transient server errors are retried.** The export endpoint routinely returns 500 on a
|
|
171
|
+
busy NVR. `updl` backs off exponentially on 5xx/429/408, does not retry other 4xx, and
|
|
172
|
+
refreshes an expired session on 401 without spending a retry.
|
|
173
|
+
|
|
174
|
+
**Files are dated by recording time, not download time.** Protect stamps each exported MP4
|
|
175
|
+
at the moment of export, so an archive fetched today would otherwise show today's date for
|
|
176
|
+
last week's footage.
|
|
177
|
+
|
|
178
|
+
## Accounts and multi-factor authentication
|
|
179
|
+
|
|
180
|
+
The account must be able to read camera media. A Protect account lacking `readmedia` on a
|
|
181
|
+
camera gets video without audio rather than an error — see above.
|
|
182
|
+
|
|
183
|
+
For consoles backed by Ubiquiti SSO with a second factor, `updl` handles the two-step
|
|
184
|
+
login and **caches the session token** (`--no-session-store` disables this), so a code is
|
|
185
|
+
needed occasionally rather than on every run. Supply one non-interactively with
|
|
186
|
+
`--mfa-code` / `PROTECT_MFA_CODE`; otherwise it prompts. With no terminal attached it
|
|
187
|
+
fails with an explanation rather than hanging, which is what a scheduled run needs.
|
|
188
|
+
|
|
189
|
+
Where the second factor is delivered by email there is no shared secret, so no run can
|
|
190
|
+
obtain a *new* token unattended — but an existing token is long-lived, so a scheduled run
|
|
191
|
+
works until it expires.
|
|
192
|
+
|
|
193
|
+
## Recovering an archive whose manifest was lost
|
|
194
|
+
|
|
195
|
+
`updl sync DEST --reconcile` reads the footage already on disk and rebuilds the manifest
|
|
196
|
+
rows from the filenames, rather than downloading terabytes again. Adopted rows carry no
|
|
197
|
+
hash until `updl verify DEST --rehash` records one.
|
|
198
|
+
|
|
199
|
+
## Credits
|
|
200
|
+
|
|
201
|
+
A fork of [`danielfernau/unifi-protect-video-downloader`](https://github.com/danielfernau/unifi-protect-video-downloader)
|
|
202
|
+
by Daniel Fernau, David Cramer and contributors, which does the hard work of talking to
|
|
203
|
+
the Protect API. See [NOTICE](NOTICE) for what this fork changed and why. MIT licensed;
|
|
204
|
+
the original copyright notice is retained in [LICENSE](LICENSE).
|
|
205
|
+
|
|
206
|
+
## Contributing
|
|
207
|
+
|
|
208
|
+
```console
|
|
209
|
+
poetry install
|
|
210
|
+
poetry run pytest
|
|
211
|
+
poetry run mypy .
|
|
212
|
+
poetry run flake8 protect_archiver conftest.py
|
|
213
|
+
```
|
|
214
|
+
|
updl-3.0.0/README.md
ADDED
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
# updl
|
|
2
|
+
|
|
3
|
+
[](https://github.com/tribixbite/updl/actions/workflows/pythonpackage.yml)
|
|
4
|
+
[](https://pypi.org/project/updl/)
|
|
5
|
+
[](https://pypi.org/project/updl/)
|
|
6
|
+
[](LICENSE)
|
|
7
|
+
|
|
8
|
+
**Resumable, verifiable archiver for UniFi Protect footage.**
|
|
9
|
+
|
|
10
|
+
Back up a UniFi Protect system to local storage in a way you can re-run at any time: it
|
|
11
|
+
downloads only what is genuinely missing, remembers the hours the NVR had no footage for,
|
|
12
|
+
retries the ones that failed, and can prove that what is already on disk is still intact.
|
|
13
|
+
|
|
14
|
+
```console
|
|
15
|
+
$ updl sync /srv/protect
|
|
16
|
+
Archive currently holds 367 segment(s) (122 empty, 245 ok)
|
|
17
|
+
0 files downloaded (0.0 b), 367 already archived, 0 files skipped, 0 files failed
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
That run took **0.35 seconds and made no requests to the NVR at all**, because everything
|
|
21
|
+
was already held. The first run of the same command downloaded 58.5 GB.
|
|
22
|
+
|
|
23
|
+
> Not affiliated with, endorsed by, or supported by Ubiquiti Inc. "UniFi" and "Protect"
|
|
24
|
+
> are trademarks of Ubiquiti Inc., used here only to describe what this tool talks to.
|
|
25
|
+
|
|
26
|
+
## Install
|
|
27
|
+
|
|
28
|
+
```console
|
|
29
|
+
pip install updl
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
Python 3.10+. `ffprobe` (from FFmpeg) is optional and only needed for `--verify=deep` and
|
|
33
|
+
`--require-audio`.
|
|
34
|
+
|
|
35
|
+
## Quick start
|
|
36
|
+
|
|
37
|
+
The destination must already exist. Then, on a new machine:
|
|
38
|
+
|
|
39
|
+
```console
|
|
40
|
+
updl -a 192.168.1.1 -u archiver -p '...' -d /srv/protect
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
If the account uses multi-factor authentication you are prompted for a code once. The
|
|
44
|
+
session token is cached, so you are not asked again until it expires.
|
|
45
|
+
|
|
46
|
+
**Every run after that needs no arguments at all:**
|
|
47
|
+
|
|
48
|
+
```console
|
|
49
|
+
updl
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
The console address, account and destination are remembered from the last successful run.
|
|
53
|
+
Your password is **never** written to disk — it is asked for again only when the cached
|
|
54
|
+
token expires.
|
|
55
|
+
|
|
56
|
+
Prefer the environment to flags for the password, since a command-line argument is visible
|
|
57
|
+
to every other process on the machine:
|
|
58
|
+
|
|
59
|
+
```console
|
|
60
|
+
export PROTECT_PASSWORD='...'
|
|
61
|
+
updl
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
Every option has a matching `PROTECT_*` environment variable. Precedence is: an explicit
|
|
65
|
+
flag, then the environment variable, then what was remembered, then the built-in default.
|
|
66
|
+
|
|
67
|
+
Where state is kept — override the first two with `UPDL_CONFIG` and `PROTECT_SESSION_STORE`:
|
|
68
|
+
|
|
69
|
+
| What | Windows | Linux / macOS |
|
|
70
|
+
| --- | --- | --- |
|
|
71
|
+
| Remembered settings | `%LOCALAPPDATA%\updl\config.json` | `~/.config/updl/config.json` |
|
|
72
|
+
| Session token | `%LOCALAPPDATA%\protect-archiver\sessions.json` | `~/.local/state/protect-archiver/sessions.json` |
|
|
73
|
+
| Archive index | `DEST\.protect-archive\manifest.db` | `DEST/.protect-archive/manifest.db` |
|
|
74
|
+
|
|
75
|
+
## Commands
|
|
76
|
+
|
|
77
|
+
`updl` on its own runs `sync`. Sub-commands can always be named explicitly:
|
|
78
|
+
|
|
79
|
+
| Command | What it does |
|
|
80
|
+
| --- | --- |
|
|
81
|
+
| `updl sync [DEST]` | Incremental mirror. Sweeps each camera's retention window and fetches only what is missing or damaged. |
|
|
82
|
+
| `updl verify [DEST]` | Audits an archive **offline** — never contacts the NVR, so it is safe to run against a backup copy. |
|
|
83
|
+
| `updl download DEST` | One-off download of an explicit `--start`/`--end` range. |
|
|
84
|
+
| `updl events DEST` | Motion and smart-detection event clips only. |
|
|
85
|
+
|
|
86
|
+
## How re-running avoids re-downloading
|
|
87
|
+
|
|
88
|
+
A SQLite manifest at `DEST/.protect-archive/manifest.db` holds one row per hour per
|
|
89
|
+
camera, keyed on `(camera_id, start_ms)`, recording the path, size, SHA-256 and a status:
|
|
90
|
+
|
|
91
|
+
- **`ok`** — downloaded and accounted for.
|
|
92
|
+
- **`empty`** — the NVR reported no footage for that hour, so it is never requested again
|
|
93
|
+
and the gap stays visible as a deliberate record rather than as an error.
|
|
94
|
+
- **`failed`** — the download failed; a later run retries exactly that hour.
|
|
95
|
+
|
|
96
|
+
Because the manifest is authoritative, `sync` sweeps the whole retention window every run
|
|
97
|
+
and still costs nothing when there is nothing to do. Gaps left by earlier failures are
|
|
98
|
+
filled automatically, and footage that has since aged off the NVR stays in the archive.
|
|
99
|
+
|
|
100
|
+
## Verifying what is on disk
|
|
101
|
+
|
|
102
|
+
`--verify` controls how hard `sync` works to prove an existing file is intact before
|
|
103
|
+
skipping it. `updl verify` accepts the same levels for an offline audit.
|
|
104
|
+
|
|
105
|
+
| Level | Checks | Cost |
|
|
106
|
+
| --- | --- | --- |
|
|
107
|
+
| `none` | trusts the manifest | touches no files |
|
|
108
|
+
| `quick` *(default)* | file exists at the recorded size | one `stat` per segment |
|
|
109
|
+
| `hash` | SHA-256 recomputed | reads the whole archive |
|
|
110
|
+
| `deep` | + `ffprobe` decodes it | catches a file that is the right size and hash but unplayable |
|
|
111
|
+
|
|
112
|
+
```console
|
|
113
|
+
# Audit, then queue anything damaged for the next sync to re-fetch.
|
|
114
|
+
updl verify /srv/protect --level hash --repair
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
Other `verify` flags: `--require-audio` (see below), `--rehash` to fill in hashes for rows
|
|
118
|
+
adopted from disk, `--fix-timestamps`, and `--clean-partials`.
|
|
119
|
+
|
|
120
|
+
## Things this handles that are easy to get wrong
|
|
121
|
+
|
|
122
|
+
**Audio is silently dropped for under-permissioned accounts.** If the Protect account
|
|
123
|
+
lacks `readmedia` on a camera, the export still returns 200 with intact video — just no
|
|
124
|
+
audio track. Nothing fails, so an archive can accumulate for weeks before anyone plays a
|
|
125
|
+
clip and finds it silent. `updl verify DEST --require-audio` inspects the streams and
|
|
126
|
+
reports it; add `--repair` to re-fetch. If it flags everything, grant the account camera
|
|
127
|
+
media permission and run it again.
|
|
128
|
+
|
|
129
|
+
**"No footage" is reported as an HTTP 404 carrying `{"error": 502}`.** Neither number
|
|
130
|
+
means what it looks like. Treated as a failure, every hour a camera was offline would be
|
|
131
|
+
re-requested forever; `updl` records it as `empty` instead.
|
|
132
|
+
|
|
133
|
+
**Downloads are atomic.** Bytes land in a `.part` file and are renamed into place only on
|
|
134
|
+
success, so an interrupted run cannot leave a truncated MP4 that later runs mistake for a
|
|
135
|
+
complete one. Stale partials are swept at startup.
|
|
136
|
+
|
|
137
|
+
**Transient server errors are retried.** The export endpoint routinely returns 500 on a
|
|
138
|
+
busy NVR. `updl` backs off exponentially on 5xx/429/408, does not retry other 4xx, and
|
|
139
|
+
refreshes an expired session on 401 without spending a retry.
|
|
140
|
+
|
|
141
|
+
**Files are dated by recording time, not download time.** Protect stamps each exported MP4
|
|
142
|
+
at the moment of export, so an archive fetched today would otherwise show today's date for
|
|
143
|
+
last week's footage.
|
|
144
|
+
|
|
145
|
+
## Accounts and multi-factor authentication
|
|
146
|
+
|
|
147
|
+
The account must be able to read camera media. A Protect account lacking `readmedia` on a
|
|
148
|
+
camera gets video without audio rather than an error — see above.
|
|
149
|
+
|
|
150
|
+
For consoles backed by Ubiquiti SSO with a second factor, `updl` handles the two-step
|
|
151
|
+
login and **caches the session token** (`--no-session-store` disables this), so a code is
|
|
152
|
+
needed occasionally rather than on every run. Supply one non-interactively with
|
|
153
|
+
`--mfa-code` / `PROTECT_MFA_CODE`; otherwise it prompts. With no terminal attached it
|
|
154
|
+
fails with an explanation rather than hanging, which is what a scheduled run needs.
|
|
155
|
+
|
|
156
|
+
Where the second factor is delivered by email there is no shared secret, so no run can
|
|
157
|
+
obtain a *new* token unattended — but an existing token is long-lived, so a scheduled run
|
|
158
|
+
works until it expires.
|
|
159
|
+
|
|
160
|
+
## Recovering an archive whose manifest was lost
|
|
161
|
+
|
|
162
|
+
`updl sync DEST --reconcile` reads the footage already on disk and rebuilds the manifest
|
|
163
|
+
rows from the filenames, rather than downloading terabytes again. Adopted rows carry no
|
|
164
|
+
hash until `updl verify DEST --rehash` records one.
|
|
165
|
+
|
|
166
|
+
## Credits
|
|
167
|
+
|
|
168
|
+
A fork of [`danielfernau/unifi-protect-video-downloader`](https://github.com/danielfernau/unifi-protect-video-downloader)
|
|
169
|
+
by Daniel Fernau, David Cramer and contributors, which does the hard work of talking to
|
|
170
|
+
the Protect API. See [NOTICE](NOTICE) for what this fork changed and why. MIT licensed;
|
|
171
|
+
the original copyright notice is retained in [LICENSE](LICENSE).
|
|
172
|
+
|
|
173
|
+
## Contributing
|
|
174
|
+
|
|
175
|
+
```console
|
|
176
|
+
poetry install
|
|
177
|
+
poetry run pytest
|
|
178
|
+
poetry run mypy .
|
|
179
|
+
poetry run flake8 protect_archiver conftest.py
|
|
180
|
+
```
|
|
File without changes
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
from typing import List
|
|
2
|
+
from typing import Sequence
|
|
3
|
+
from typing import Set
|
|
4
|
+
|
|
5
|
+
from .download import * # NOQA
|
|
6
|
+
from .events import * # NOQA
|
|
7
|
+
from .sync import * # NOQA
|
|
8
|
+
from .verify import * # NOQA
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def main() -> None:
|
|
12
|
+
import logging
|
|
13
|
+
import os
|
|
14
|
+
|
|
15
|
+
import urllib3
|
|
16
|
+
|
|
17
|
+
logging.basicConfig(format="%(message)s", level=logging.INFO)
|
|
18
|
+
|
|
19
|
+
os.environ.setdefault("PYTHONUNBUFFERED", "true")
|
|
20
|
+
|
|
21
|
+
# disable InsecureRequestWarning for unverified HTTPS requests
|
|
22
|
+
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
|
|
23
|
+
|
|
24
|
+
import sys
|
|
25
|
+
|
|
26
|
+
from .base import cli
|
|
27
|
+
|
|
28
|
+
cli.main(args=resolve_argv(sys.argv[1:], set(cli.commands)))
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def resolve_argv(argv: Sequence[str], commands: Set[str]) -> List[str]:
|
|
32
|
+
"""Let `updl` with no sub-command mean `updl sync`.
|
|
33
|
+
|
|
34
|
+
A machine that has run once before has its address, account and destination
|
|
35
|
+
remembered, so the useful default for a bare invocation is to get on with the sync.
|
|
36
|
+
Anything that already names a sub-command is left alone, and so is the top-level
|
|
37
|
+
help, which must not turn into `sync --help`.
|
|
38
|
+
"""
|
|
39
|
+
if not argv:
|
|
40
|
+
return ["sync"]
|
|
41
|
+
if argv[0] in commands or argv[0] in ("--help", "-h"):
|
|
42
|
+
return list(argv)
|
|
43
|
+
return ["sync", *argv]
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import click
|
|
2
|
+
|
|
3
|
+
from protect_archiver import settings
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
@click.group(invoke_without_command=True)
|
|
7
|
+
@click.pass_context
|
|
8
|
+
def cli(ctx: click.Context) -> None:
|
|
9
|
+
# Values remembered by a previous run become defaults for the options that were not
|
|
10
|
+
# supplied on the command line or through the environment. Click applies a
|
|
11
|
+
# default_map only after those two, which gives the precedence documented in
|
|
12
|
+
# protect_archiver.settings.
|
|
13
|
+
ctx.default_map = settings.as_default_map()
|