main
Raw Download raw file

UniFi Protect Single-Day Timelapse Specification

1. Purpose

Build a deterministic, resumable pipeline that:

  1. Downloads all motion/smart detection event clips for one camera and one day
  2. Produces one timelapse MP4 by concatenating clips chronologically and speeding up playback to ~10 minutes
  3. 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:

  1. Login: POST /api/auth/login with JSON body:

    {"username": "...", "password": "...", "rememberMe": false, "token": ""}
    
  2. Session Cookie: The response sets a session cookie used for subsequent requests

  3. CSRF Token: The login response includes an X-Csrf-Token header 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-insecure only for self-signed certs
  • Never log or persist credentials

Documentation References


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:

  1. If camera input matches a camera ID exactly, use that camera
  2. Otherwise, search by name (case-insensitive)
  3. 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=100 and orderDirection=ASC
  • If response contains limit events, fetch next page using the last event’s end timestamp + 1ms as the new start parameter
  • Continue until fewer than limit events returned

Day Window:

  • For date 2024-01-15, query:
    • start: midnight local time as Unix ms
    • end: 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)

  1. Resolve camera by ID or name via bootstrap
  2. Query events for the day with pagination
  3. Build clip index with all events
  4. Sort by (start_ms, end_ms, event_id) for deterministic ordering
  5. 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)

  1. Read clip index
  2. For each pending clip:
    • Skip if final .mp4 exists and is non-empty
    • Delete any existing .partial file
    • Download to .partial file
    • fsync and close
    • Atomic rename to final .mp4
  3. 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 attempted
  • in_progress: Currently downloading
  • complete: Successfully downloaded
  • failed: All retries exhausted

Phase C: Build Manifest

  1. Read clip index
  2. Filter to complete status clips
  3. Verify each file exists and is valid (optional: ffprobe check)
  4. Write manifests/concat.txt in 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/N divides presentation timestamps by N, making video N times faster
  • Example: setpts=PTS/12 plays 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:

  1. Scan: Always re-enumerates events (fast operation)
  2. Fetch: Skips clips where final .mp4 exists and is non-empty
  3. Render: Regenerates concat.txt and re-renders (clips are preserved)

Partial Downloads:

  • .partial files 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 credentials
  • ErrCameraNotFound: Camera doesn’t exist
  • ErrMultipleCameras: Ambiguous camera name
  • ErrMaxAttemptsExceeded: 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

  1. All included clips exist and are non-empty
  2. Clip count matches events (minus failures)
  3. concat.txt ordering matches sorted events
  4. 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-file or env vars over --password flag (avoids process list exposure)
  • Use log/slog structured logging (no credential fields)
  • TLS verification enabled by default
  • --tls-insecure must 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

Community Resources