updl
Resumable, verifiable archiver for UniFi Protect footage.
Back up a UniFi Protect system to local storage in a way you can re-run at any time: it downloads only what is genuinely missing, remembers the hours the NVR had no footage for, retries the ones that failed, and can prove that what is already on disk is still intact.
$ updl sync /srv/protect
Archive currently holds 367 segment(s) (122 empty, 245 ok)
0 files downloaded (0.0 b), 367 already archived, 0 files skipped, 0 files failed
That run took 0.35 seconds and made no requests to the NVR at all, because everything was already held. The first run of the same command downloaded 58.5 GB.
Not affiliated with, endorsed by, or supported by Ubiquiti Inc. "UniFi" and "Protect" are trademarks of Ubiquiti Inc., used here only to describe what this tool talks to.
Install
pip install updl
Python 3.10+. ffprobe (from FFmpeg) is optional and only needed for --verify=deep and
--require-audio.
Quick start
The destination must already exist. Then, on a new machine:
updl -a 192.168.1.1 -u archiver -p '...' -d /srv/protect
If the account uses multi-factor authentication you are prompted for a code once. The session token is cached, so you are not asked again until it expires.
Every run after that needs no arguments at all:
updl
The console address, account and destination are remembered from the last successful run. Your password is never written to disk — it is asked for again only when the cached token expires.
Prefer the environment to flags for the password, since a command-line argument is visible to every other process on the machine:
export PROTECT_PASSWORD='...'
updl
Every option has a matching PROTECT_* environment variable. Precedence is: an explicit
flag, then the environment variable, then what was remembered, then the built-in default.
Where state is kept — override the first two with UPDL_CONFIG and PROTECT_SESSION_STORE:
| What | Windows | Linux / macOS |
|---|---|---|
| Remembered settings | %LOCALAPPDATA%\updl\config.json |
~/.config/updl/config.json |
| Session token | %LOCALAPPDATA%\protect-archiver\sessions.json |
~/.local/state/protect-archiver/sessions.json |
| Archive index | DEST\.protect-archive\manifest.db |
DEST/.protect-archive/manifest.db |
Commands
updl on its own runs sync. Sub-commands can always be named explicitly:
| Command | What it does |
|---|---|
updl sync [DEST] |
Incremental mirror. Sweeps each camera's retention window and fetches only what is missing or damaged. |
updl verify [DEST] |
Audits an archive offline — never contacts the NVR, so it is safe to run against a backup copy. |
updl download DEST |
One-off download of an explicit --start/--end range. |
updl events DEST |
Motion and smart-detection event clips only. |
How re-running avoids re-downloading
A SQLite manifest at DEST/.protect-archive/manifest.db holds one row per hour per
camera, keyed on (camera_id, start_ms), recording the path, size, SHA-256 and a status:
ok— downloaded and accounted for.empty— the NVR reported no footage for that hour, so it is never requested again and the gap stays visible as a deliberate record rather than as an error.failed— the download failed; a later run retries exactly that hour.
Because the manifest is authoritative, sync sweeps the whole retention window every run
and still costs nothing when there is nothing to do. Gaps left by earlier failures are
filled automatically, and footage that has since aged off the NVR stays in the archive.
Verifying what is on disk
--verify controls how hard sync works to prove an existing file is intact before
skipping it. updl verify accepts the same levels for an offline audit.
| Level | Checks | Cost |
|---|---|---|
none |
trusts the manifest | touches no files |
quick (default) |
file exists at the recorded size | one stat per segment |
hash |
SHA-256 recomputed | reads the whole archive |
deep |
+ ffprobe decodes it |
catches a file that is the right size and hash but unplayable |
# Audit, then queue anything damaged for the next sync to re-fetch.
updl verify /srv/protect --level hash --repair
Other verify flags: --require-audio (see below), --rehash to fill in hashes for rows
adopted from disk, --fix-timestamps, and --clean-partials.
Things this handles that are easy to get wrong
Audio is silently dropped for under-permissioned accounts. If the Protect account
lacks readmedia on a camera, the export still returns 200 with intact video — just no
audio track. Nothing fails, so an archive can accumulate for weeks before anyone plays a
clip and finds it silent. updl verify DEST --require-audio inspects the streams and
reports it; add --repair to re-fetch. If it flags everything, grant the account camera
media permission and run it again.
"No footage" is reported as an HTTP 404 carrying {"error": 502}. Neither number
means what it looks like. Treated as a failure, every hour a camera was offline would be
re-requested forever; updl records it as empty instead.
Downloads are atomic. Bytes land in a .part file and are renamed into place only on
success, so an interrupted run cannot leave a truncated MP4 that later runs mistake for a
complete one. Stale partials are swept at startup.
Transient server errors are retried. The export endpoint routinely returns 500 on a
busy NVR. updl backs off exponentially on 5xx/429/408, does not retry other 4xx, and
refreshes an expired session on 401 without spending a retry.
Files are dated by recording time, not download time. Protect stamps each exported MP4 at the moment of export, so an archive fetched today would otherwise show today's date for last week's footage.
Accounts and multi-factor authentication
The account must be able to read camera media. A Protect account lacking readmedia on a
camera gets video without audio rather than an error — see above.
For consoles backed by Ubiquiti SSO with a second factor, updl handles the two-step
login and caches the session token (--no-session-store disables this), so a code is
needed occasionally rather than on every run. Supply one non-interactively with
--mfa-code / PROTECT_MFA_CODE; otherwise it prompts. With no terminal attached it
fails with an explanation rather than hanging, which is what a scheduled run needs.
Where the second factor is delivered by email there is no shared secret, so no run can obtain a new token unattended — but an existing token is long-lived, so a scheduled run works until it expires.
Recovering an archive whose manifest was lost
updl sync DEST --reconcile reads the footage already on disk and rebuilds the manifest
rows from the filenames, rather than downloading terabytes again. Adopted rows carry no
hash until updl verify DEST --rehash records one.
Credits
A fork of danielfernau/unifi-protect-video-downloader
by Daniel Fernau, David Cramer and contributors, which does the hard work of talking to
the Protect API. See NOTICE for what this fork changed and why. MIT licensed;
the original copyright notice is retained in LICENSE.
Contributing
poetry install
poetry run pytest
poetry run mypy .
poetry run flake8 protect_archiver conftest.py
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file updl-3.0.0.tar.gz.
File metadata
- Download URL: updl-3.0.0.tar.gz
- Upload date:
- Size: 57.4 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via:
twine/7.0.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
c33cf7180ad988371c893f5d57b3f065c740e90898e7dea0b9b509af264623ae
|
|
| MD5 |
59b18f74fccffe524de4046d01cde5cc
|
|
| BLAKE2b-256 |
049e27a647f77f78a7bffb654a6b234609d6b588830beef0eff07fc74a097e7b
|
Provenance
The following attestation bundles were made for updl-3.0.0.tar.gz:
Publisher:
publish.yml on tribixbite/updl
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
updl-3.0.0.tar.gz -
Subject digest:
c33cf7180ad988371c893f5d57b3f065c740e90898e7dea0b9b509af264623ae - Sigstore transparency entry: 2765394784
- Sigstore integration time:
-
Permalink:
tribixbite/updl@38492b4b2e98172f677af8e915a7c2f269a8fa38 -
Branch / Tag:
refs/tags/v3.0.0 - Owner: https://github.com/tribixbite
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish.yml@38492b4b2e98172f677af8e915a7c2f269a8fa38 -
Trigger Event:
release
-
Statement type:
File details
Details for the file updl-3.0.0-py3-none-any.whl.
File metadata
- Download URL: updl-3.0.0-py3-none-any.whl
- Upload date:
- Size: 77.3 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via:
twine/7.0.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
c282f8ebdfeacdd06a5dc2e1c931bb797256771f1773b699b7a4ec82429f1b01
|
|
| MD5 |
84e876482dbce6aaddbc06d32d354d9e
|
|
| BLAKE2b-256 |
5a8f19d15847dec4ce591f0e098a8c40f32adf5f73bb2fecd7c16eef32b24ddf
|
Provenance
The following attestation bundles were made for updl-3.0.0-py3-none-any.whl:
Publisher:
publish.yml on tribixbite/updl
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
updl-3.0.0-py3-none-any.whl -
Subject digest:
c282f8ebdfeacdd06a5dc2e1c931bb797256771f1773b699b7a4ec82429f1b01 - Sigstore transparency entry: 2765394805
- Sigstore integration time:
-
Permalink:
tribixbite/updl@38492b4b2e98172f677af8e915a7c2f269a8fa38 -
Branch / Tag:
refs/tags/v3.0.0 - Owner: https://github.com/tribixbite
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish.yml@38492b4b2e98172f677af8e915a7c2f269a8fa38 -
Trigger Event:
release
-
Statement type: