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.
Files changed (43) hide show
  1. updl-3.0.0/LICENSE +22 -0
  2. updl-3.0.0/NOTICE +35 -0
  3. updl-3.0.0/PKG-INFO +214 -0
  4. updl-3.0.0/README.md +180 -0
  5. updl-3.0.0/protect_archiver/__init__.py +0 -0
  6. updl-3.0.0/protect_archiver/cli/__init__.py +43 -0
  7. updl-3.0.0/protect_archiver/cli/base.py +13 -0
  8. updl-3.0.0/protect_archiver/cli/download.py +333 -0
  9. updl-3.0.0/protect_archiver/cli/events.py +297 -0
  10. updl-3.0.0/protect_archiver/cli/resolve.py +27 -0
  11. updl-3.0.0/protect_archiver/cli/sync.py +319 -0
  12. updl-3.0.0/protect_archiver/cli/verify.py +263 -0
  13. updl-3.0.0/protect_archiver/client/__init__.py +109 -0
  14. updl-3.0.0/protect_archiver/client/legacy.py +72 -0
  15. updl-3.0.0/protect_archiver/client/unifi_os.py +214 -0
  16. updl-3.0.0/protect_archiver/config.py +36 -0
  17. updl-3.0.0/protect_archiver/dataclasses.py +24 -0
  18. updl-3.0.0/protect_archiver/downloader/__init__.py +81 -0
  19. updl-3.0.0/protect_archiver/downloader/download_file.py +262 -0
  20. updl-3.0.0/protect_archiver/downloader/download_footage.py +162 -0
  21. updl-3.0.0/protect_archiver/downloader/download_motion_event.py +61 -0
  22. updl-3.0.0/protect_archiver/downloader/download_snapshot.py +48 -0
  23. updl-3.0.0/protect_archiver/downloader/get_camera_list.py +57 -0
  24. updl-3.0.0/protect_archiver/downloader/get_motion_event_list.py +85 -0
  25. updl-3.0.0/protect_archiver/errors.py +44 -0
  26. updl-3.0.0/protect_archiver/manifest.py +353 -0
  27. updl-3.0.0/protect_archiver/reconcile.py +148 -0
  28. updl-3.0.0/protect_archiver/session_store.py +161 -0
  29. updl-3.0.0/protect_archiver/settings.py +160 -0
  30. updl-3.0.0/protect_archiver/sync.py +175 -0
  31. updl-3.0.0/protect_archiver/test_client.py +116 -0
  32. updl-3.0.0/protect_archiver/test_download_file.py +244 -0
  33. updl-3.0.0/protect_archiver/test_manifest.py +100 -0
  34. updl-3.0.0/protect_archiver/test_reconcile.py +101 -0
  35. updl-3.0.0/protect_archiver/test_session_store.py +96 -0
  36. updl-3.0.0/protect_archiver/test_settings.py +126 -0
  37. updl-3.0.0/protect_archiver/test_sync.py +346 -0
  38. updl-3.0.0/protect_archiver/test_unifi_os_client.py +212 -0
  39. updl-3.0.0/protect_archiver/test_utils.py +129 -0
  40. updl-3.0.0/protect_archiver/test_verify.py +183 -0
  41. updl-3.0.0/protect_archiver/utils.py +205 -0
  42. updl-3.0.0/protect_archiver/verify.py +255 -0
  43. 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
+ [![CI](https://github.com/tribixbite/updl/actions/workflows/pythonpackage.yml/badge.svg)](https://github.com/tribixbite/updl/actions/workflows/pythonpackage.yml)
37
+ [![PyPI](https://img.shields.io/pypi/v/updl?color=blue)](https://pypi.org/project/updl/)
38
+ [![Python](https://img.shields.io/pypi/pyversions/updl)](https://pypi.org/project/updl/)
39
+ [![License: MIT](https://img.shields.io/badge/license-MIT-green)](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
+ [![CI](https://github.com/tribixbite/updl/actions/workflows/pythonpackage.yml/badge.svg)](https://github.com/tribixbite/updl/actions/workflows/pythonpackage.yml)
4
+ [![PyPI](https://img.shields.io/pypi/v/updl?color=blue)](https://pypi.org/project/updl/)
5
+ [![Python](https://img.shields.io/pypi/pyversions/updl)](https://pypi.org/project/updl/)
6
+ [![License: MIT](https://img.shields.io/badge/license-MIT-green)](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()