UniFi Protect Single-Day Timelapse Specification
1. Purpose
Build a deterministic, resumable pipeline that:
- Downloads all motion/smart detection event clips for one camera and one day
- Produces one timelapse MP4 by concatenating clips chronologically and speeding up playback to ~10 minutes
- Relies on the camera’s existing burned-in timestamps (no additional overlay)
Non-goals: multi-camera, multi-day batching, motion-only exports, UI automation, cloud uploading.
2. Authentication
Session-Based Authentication
UniFi Protect uses cookie-based session authentication:
-
Login:
POST /api/auth/loginwith JSON body:{"username": "...", "password": "...", "rememberMe": false, "token": ""} -
Session Cookie: The response sets a session cookie used for subsequent requests
-
CSRF Token: The login response includes an
X-Csrf-Tokenheader that must be sent with subsequent requests
POST /api/auth/login
Content-Type: application/json
{"username": "protect-api", "password": "secret", "rememberMe": false, "token": ""}
Implementation Notes
- Create a dedicated local account in UniFi OS for API access
- Use a cookie jar to persist session cookies across requests
- Store and send the CSRF token from the login response
- TLS verification should be enabled; use
--tls-insecureonly for self-signed certs - Never log or persist credentials
Documentation References
- Official Getting Started: https://developer.ui.com/protect/v6.2.83/gettingstarted
- Ubiquiti Help Center: https://help.ui.com/hc/en-us/articles/30076656117655-Getting-Started-with-the-Official-UniFi-API
3. Inputs
Required
| Input | Description |
|---|---|
host |
UniFi Protect URL, e.g. https://192.168.1.1 |
username |
Local UniFi OS account username |
password |
Account password (or use password_file) |
camera |
Camera ID or name (must resolve uniquely) |
date |
Target day: YYYY-MM-DD |
out_dir |
Output root directory |
Optional (defaults shown)
| Input | Default | Description |
|---|---|---|
target_duration |
600 |
Target output seconds (~10 min) |
output_fps |
30 |
Output frame rate |
crf |
23 |
FFmpeg quality (0-51, lower=better) |
preset |
medium |
FFmpeg encoding preset |
tls_insecure |
false |
Skip TLS verification |
direct_api |
false |
Use /api path (direct NVR, not UniFi OS) |
max_workers |
4 |
Download concurrency |
retry_limit |
3 |
Retries per download |
4. Output Structure
out/
├── metadata/
│ ├── camera.json # Resolved camera info
│ └── day.json # Speed calculation, clip counts
├── clips/<camera>/<date>/
│ └── clip_<start>_<end>_<eventId>.mp4
├── manifests/
│ ├── clip_index.json # All events with download status
│ └── concat.txt # FFmpeg concat demuxer file
├── timelapse/
│ └── <camera>_<date>_timelapse.mp4
└── logs/
└── run.log
Naming Rules
- Camera name is sanitized: remove
/ \ : * ? " < > |, collapse underscores, trim - Timestamps use milliseconds for API compatibility
- Filenames must be stable across reruns
5. UniFi Protect API
Documentation: The official API reference is at https://developer.ui.com/protect/. Additional implementation details have been documented by community projects including unifi-protect and pyunifiprotect.
Base URL
All endpoints are prefixed with /proxy/protect/api/ when accessing via UniFi OS.
5.1 Bootstrap
Endpoint: GET /proxy/protect/api/bootstrap
Returns system information including all cameras.
Response structure:
{
"nvr": {
"id": "string",
"name": "string",
"version": "string"
},
"cameras": [
{
"id": "string",
"name": "string",
"type": "string",
"state": "string",
"isConnected": true,
"host": "string",
"mac": "string",
"modelKey": "string",
"channels": [
{
"id": 0,
"width": 1920,
"height": 1080,
"enabled": true,
"fps": 30
}
]
}
]
}
Camera Resolution:
- If
camerainput matches a camera ID exactly, use that camera - Otherwise, search by name (case-insensitive)
- Fail if zero matches or multiple matches
5.2 Events
Endpoint: GET /proxy/protect/api/events
Query Parameters:
| Parameter | Type | Description |
|---|---|---|
cameras |
string | Camera ID |
start |
int64 | Start time (Unix ms) |
end |
int64 | End time (Unix ms) |
types |
string[] | Event types: motion, smartDetect |
limit |
int | Max results per page (default 100) |
orderDirection |
string | ASC or DESC |
after |
string | Pagination cursor (last event ID) |
Response: Array of events
[
{
"id": "string",
"type": "motion",
"start": 1705334400000,
"end": 1705334460000,
"score": 85,
"camera": "camera-id",
"smartDetectTypes": ["person", "vehicle"]
}
]
Pagination:
- Request with
limit=100andorderDirection=ASC - If response contains
limitevents, fetch next page using the last event’sendtimestamp + 1ms as the newstartparameter - Continue until fewer than
limitevents returned
Day Window:
- For date
2024-01-15, query:start: midnight local time as Unix msend: midnight + 24 hours as Unix ms
5.3 Video Export
See also: How the API for video downloads works
Endpoint: GET /proxy/protect/api/video/export
Query Parameters:
| Parameter | Type | Description |
|---|---|---|
camera |
string | Camera ID |
start |
int64 | Start time (Unix ms) |
end |
int64 | End time (Unix ms) |
Response: Binary MP4 stream
Important: Use the camera ID, not event ID, for downloads. The start/end times come from the event.
6. Pipeline Phases
Phase A: Scan (Enumerate Events)
- Resolve camera by ID or name via bootstrap
- Query events for the day with pagination
- Build clip index with all events
- Sort by
(start_ms, end_ms, event_id)for deterministic ordering - Write
manifests/clip_index.json
Clip Index Entry:
{
"event_id": "string",
"start_ms": 1705334400000,
"end_ms": 1705334460000,
"duration_ms": 60000,
"event_type": "motion",
"smart_types": ["person"],
"score": 85,
"status": "pending",
"file_path": "clips/camera/2024-01-15/clip_xxx.mp4"
}
Phase B: Fetch (Download Clips)
- Read clip index
- For each pending clip:
- Skip if final
.mp4exists and is non-empty - Delete any existing
.partialfile - Download to
.partialfile - fsync and close
- Atomic rename to final
.mp4
- Skip if final
- Update clip index with status
Concurrency:
- Use bounded worker pool (default 4 workers)
- Each worker processes clips independently
Retry:
- Exponential backoff with jitter: base 1s, max 30s, multiplier 2x
- Jitter: 75-125% of delay
- Max attempts configurable (default 3)
Status Values:
pending: Not yet attemptedin_progress: Currently downloadingcomplete: Successfully downloadedfailed: All retries exhausted
Phase C: Build Manifest
- Read clip index
- Filter to
completestatus clips - Verify each file exists and is valid (optional: ffprobe check)
- Write
manifests/concat.txtin FFmpeg format:
file '/absolute/path/to/clip1.mp4'
file '/absolute/path/to/clip2.mp4'
Path Escaping: Single quotes in paths must be escaped as '\''
Phase D: Compute Speed
Formula:
speed = total_recorded_seconds / target_output_seconds
speed = clamp(speed, 1.0, 2000.0)
Example:
- 2 hours of footage (7200s) targeting 10 minutes (600s)
- Speed = 7200 / 600 = 12x
Edge Cases:
- Less footage than target: speed = 1.0 (no slowdown)
- Extremely long footage: clamp to 2000x max
Write results to metadata/day.json:
{
"camera_id": "string",
"camera_name": "string",
"date": "2024-01-15",
"total_clips": 150,
"valid_clips": 148,
"total_duration_secs": 7200.0,
"speed_factor": 12.0,
"target_secs": 600,
"output_duration_secs": 600.0
}
Phase E: Render (FFmpeg)
Command:
ffmpeg -f concat -safe 0 -i concat.txt \
-vf "setpts=PTS/<speed>" \
-r 30 \
-c:v libx264 \
-crf 23 \
-preset medium \
-pix_fmt yuv420p \
-an \
-y \
output.mp4
Filter Explanation:
setpts=PTS/Ndivides presentation timestamps by N, making video N times faster- Example:
setpts=PTS/12plays at 12x speed
Output Settings:
-r 30: Constant 30 FPS output-c:v libx264: H.264 codec-crf 23: Quality (0=lossless, 51=worst)-preset medium: Encoding speed/quality tradeoff-pix_fmt yuv420p: Compatibility-an: No audio
7. Resumability
The pipeline is designed to be restartable:
- Scan: Always re-enumerates events (fast operation)
- Fetch: Skips clips where final
.mp4exists and is non-empty - Render: Regenerates concat.txt and re-renders (clips are preserved)
Partial Downloads:
.partialfiles indicate interrupted downloads- Delete before retry to ensure clean state
- Never trust partial file contents
8. Error Handling
HTTP Errors
| Status | Action |
|---|---|
| 401/403 | Fail immediately - invalid credentials |
| 404 | Mark clip as failed, continue with others |
| 429 | Retry with backoff |
| 5xx | Retry with backoff |
Sentinel Errors
Define inspectable errors for programmatic handling:
ErrUnauthorized: Invalid credentialsErrCameraNotFound: Camera doesn’t existErrMultipleCameras: Ambiguous camera nameErrMaxAttemptsExceeded: Retries exhausted
Zero-Data Day
If no events found:
- Write empty clip index
- Skip fetch and render phases
- Exit cleanly with message
9. CLI Interface
upvs [global flags] <command>
Global Flags:
--host UniFi Protect URL (env: UPVS_HOST)
--username Username (env: UPVS_USERNAME)
--password Password (env: UPVS_PASSWORD)
--password-file Path to file containing password
--camera Camera ID or name
--out Output directory
--tls-insecure Skip TLS verification
--direct-api Use /api path (direct NVR, not UniFi OS)
--verbose Enable debug logging
Commands:
scan Enumerate events for a day
fetch Download clips from clip index
render Generate timelapse from downloaded clips
run Full pipeline: scan + fetch + render
Scan/Fetch/Render/Run Flags:
--date Target date YYYY-MM-DD (required)
Fetch Flags:
--workers Download concurrency (default 4)
--retries Retry attempts per clip (default 3)
Render Flags:
--target Target duration seconds (default 600)
--fps Output frame rate (default 30)
--crf FFmpeg CRF quality (default 23)
--preset FFmpeg preset (default "medium")
10. Verification
Automated Checks
- All included clips exist and are non-empty
- Clip count matches events (minus failures)
- concat.txt ordering matches sorted events
- Output file exists and is non-empty
Duration Verification
- If total >= target: output duration ≈ target (±10s tolerance)
- If total < target: output duration ≈ total (±10s tolerance)
Manual Spot-Check
- Start: First clip boundary correct
- Middle: Timestamps progress (with gaps)
- End: Final clip ends cleanly
11. Security
- Credentials must not be logged or written to disk
- Prefer
--password-fileor env vars over--passwordflag (avoids process list exposure) - Use
log/slogstructured logging (no credential fields) - TLS verification enabled by default
--tls-insecuremust be explicitly set and logged as warning
12. Dependencies
- CLI Framework: github.com/spf13/cobra (flags, subcommands, env binding)
- HTTP: Standard library
net/http - Logging: Standard library
log/slog - FFmpeg: External binary (must be in PATH)
13. Implementation Notes
Time Handling
- Store times as Unix milliseconds (
int64) for API compatibility - Use
time.UnixMilli()for conversion - Parse dates with
time.Parse("2006-01-02", dateStr)
Atomic File Operations
// Write to .partial
f, _ := os.Create(path + ".partial")
// ... write content ...
f.Sync()
f.Close()
// Atomic rename
os.Rename(path + ".partial", path)
Worker Pool Pattern
- Fixed number of workers (goroutines)
- Tasks sent via channel
- Results collected via channel
- Context cancellation stops workers
Error Wrapping (per uber-go/guide)
// Separate call and check
result, err := doSomething()
if err != nil {
return fmt.Errorf("doing something (id=%s): %w", id, err)
}
14. Out-of-Scope
- Multi-camera support
- Multi-day batching
- Automatic scheduling
- Cloud upload
- Motion-only filtering
- Timestamp overlay (already burned in)
- Smart scene detection
15. References
Official Documentation
- UniFi Protect API Reference - Official API documentation
- Getting Started Guide - API key setup and basics
- Ubiquiti Help Center - Official API - Overview of UniFi APIs
Community Resources
- unifi-protect (Node.js) - Complete TypeScript implementation
- pyunifiprotect (Python) - Python API client with CLI
- Video Download API Wiki - Video export endpoint details