Commit 745623c
2026-01-26 12:48:48
README.md
@@ -0,0 +1,3 @@
+# `upvs`
+
+UniFi Protect Video Summary (`upvs`)
SPEC.md
@@ -0,0 +1,491 @@
+# UniFi Protect → Single-Day Timelapse (Gap-Skipping)
+
+## 1. Purpose
+
+Build a deterministic, resumable pipeline that:
+
+1. Downloads **all UniFi Protect recording clips** for **one known camera** and **one specific day**.
+2. Produces **one timelapse MP4** for that day by:
+
+ * concatenating clips in chronological order (gaps skipped implicitly)
+ * speeding up playback so the final video is **~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. Inputs
+
+### Required
+
+* `protect_host` — base URL, e.g. `https://unvr.local`
+* `username`, `password` — Protect admin credentials (may be provided via env/secret file)
+* `camera_selector` — camera ID **or** camera name (must resolve uniquely)
+* `date` — target calendar day in UTC: `YYYY-MM-DD`
+* `out_dir` — output root directory
+
+### Optional (defaults shown)
+
+* `target_duration` — `10m` (600 seconds)
+* `output_fps` — `30`
+* `video_codec` — `h264`
+* `crf` — `23`
+* `preset` — `medium`
+* `tls_insecure` — `false` (skip TLS verification only if explicitly enabled)
+* `max_workers` — `3` (download concurrency)
+* `retry_limit` — `5`
+* `retry_backoff` — exponential, capped
+
+---
+
+## 3. Outputs
+
+### Primary
+
+* `timelapse/<camera-name>_<YYYY-MM-DD>_timelapse.mp4`
+
+### Secondary (auditing + resuming)
+
+* `metadata/camera.json`
+* `metadata/day.json`
+* `manifests/clip_index.json`
+* `manifests/concat.txt`
+* `clips/<camera-name>/<YYYY-MM-DD>/clip_<start>_<end>_<eventId>.mp4`
+* `logs/run.log`
+
+---
+
+## 4. Deterministic Directory Layout
+
+```
+out/
+ metadata/
+ camera.json
+ day.json
+ clips/
+ <camera-name>/
+ YYYY-MM-DD/
+ clip_<startISO>_<endISO>_<eventId>.mp4
+ ...
+ manifests/
+ clip_index.json
+ concat.txt
+ timelapse/
+ <camera-name>_<YYYY-MM-DD>_timelapse.mp4
+ logs/
+ run.log
+```
+
+### Naming rules
+
+* `<camera-name>` is sanitized for filesystem safety (remove/replace `/ \ : * ? " < > |`, trim whitespace).
+* `<startISO>` and `<endISO>` are UTC timestamps formatted as `YYYYMMDDTHHMMSSZ`.
+* Output filenames must be stable across reruns given the same inputs.
+
+---
+
+## 5. Time Semantics
+
+### Day window (UTC)
+
+For input `date = YYYY-MM-DD`:
+
+* `day_start = YYYY-MM-DDT00:00:00.000Z`
+* `day_end = YYYY-MM-DDT23:59:59.999Z`
+
+Internally, times are stored as:
+
+* epoch milliseconds (`*_ms`) for Protect API interaction
+* RFC3339 UTC strings for human-facing metadata/artifacts
+
+---
+
+## 6. UniFi Protect API Interaction
+
+### 6.1 Authentication
+
+* Login via Protect API, capture session cookies.
+* All subsequent requests must include cookies.
+* Session expiry must be detected and retried via re-login.
+
+**Behavior requirements**
+
+* If any API call returns an auth failure (401/403), attempt re-login once and retry the call.
+* If re-login fails, abort.
+
+### 6.2 Bootstrap (camera resolution)
+
+Fetch bootstrap and resolve camera:
+
+* If `camera_selector` is an ID, match directly.
+* If it is a name, match by exact name; fail if 0 or >1 matches.
+
+Persist resolved camera metadata to `metadata/camera.json` including at minimum:
+
+* camera_id
+* camera_name
+* model (if available)
+* mac (if available)
+* host
+* resolution/fps (if available; informational)
+
+---
+
+## 7. Phase A: Enumerate Recording Events for the Day
+
+### Goal
+
+Build a complete ordered list of recording events for the camera within the day window.
+
+### Requirements
+
+* Query Protect events filtered to:
+
+ * `type = recording`
+ * `cameraId = resolved camera_id`
+ * `start` and `end` time bounds covering the day
+* Pagination must be supported until exhaustion.
+
+### Event fields to record (per event)
+
+* `event_id`
+* `camera_id`
+* `start_ms`
+* `end_ms`
+* `duration_ms = end_ms - start_ms` (non-negative; if negative, mark as invalid)
+* `sequence` (ordering index after sorting)
+
+Persist full index to `manifests/clip_index.json`.
+
+### Sorting
+
+* Sort events by `(start_ms, end_ms, event_id)` ascending.
+* Assign `sequence = 0..N-1` after sorting.
+
+### Validation
+
+* If an event is missing a valid `start_ms` or `end_ms`, record it with status `invalid` and exclude it from download/concat.
+
+---
+
+## 8. Phase B: Download Clips (Original MP4)
+
+### Goal
+
+Download one MP4 per valid event into the deterministic clip library.
+
+### Requirements
+
+* Download endpoint must yield the event’s MP4.
+* Stream download to a temporary path:
+
+ * `...mp4.partial`
+* On success:
+
+ * fsync/close
+ * atomic rename to final `.mp4`
+* If final `.mp4` already exists and is non-empty:
+
+ * skip download
+* If `.partial` exists:
+
+ * delete and retry download (simpler, deterministic)
+
+### Concurrency
+
+* Use a worker pool of size `max_workers` for downloads.
+* Workers must be bounded to avoid overloading Protect.
+
+### Retries
+
+* Retry transient failures up to `retry_limit` with exponential backoff:
+
+ * include network errors, 5xx, timeouts
+* Do not retry deterministic failures more than once:
+
+ * 404 on event download (record as missing)
+* On auth failures:
+
+ * re-login and retry once (see Authentication)
+
+### Recording status
+
+Update `manifests/clip_index.json` to reflect:
+
+* downloaded: true/false
+* local_path
+* bytes (if known)
+* last_error (if failed)
+
+(Implementation may also maintain a small local DB; this spec only requires the manifest be correct.)
+
+---
+
+## 9. Phase C: Build Concatenation Manifest (Gap-Skipping)
+
+### Goal
+
+Create a concat list that plays only recorded footage, in order, skipping gaps naturally.
+
+### Requirements
+
+* Include only clips that:
+
+ * are marked valid
+ * were successfully downloaded
+ * exist on disk and are non-empty
+* Ordered strictly by event sort order.
+
+### Output
+
+Write `manifests/concat.txt` in FFmpeg concat demuxer format:
+
+* One entry per clip, absolute or out_dir-relative paths (choose one and be consistent).
+* Must be safe for special characters (prefer quoting/escaping per FFmpeg concat file rules).
+
+### Validation
+
+* If zero clips are available, abort timelapse step with a clear message and preserve manifests/logs.
+
+---
+
+## 10. Phase D: Compute Speed to Target ~10 Minutes
+
+### Goal
+
+Determine a single speed factor so the concatenated footage becomes approximately `target_duration` (default 600s).
+
+### Definitions
+
+* `target_output_seconds = 600` (from `target_duration`)
+* `clip_duration_seconds_i` — preferred from event metadata:
+
+ * `duration_ms / 1000`
+* `total_recorded_seconds = Σ clip_duration_seconds_i` over included clips
+
+### Rules
+
+1. If `total_recorded_seconds <= target_output_seconds`:
+
+ * `speed = 1.0`
+2. Else:
+
+ * `speed = total_recorded_seconds / target_output_seconds`
+
+### Clamping (recommended)
+
+* `min_speed = 1.0`
+* `max_speed = 2000.0`
+* `speed = clamp(speed, min_speed, max_speed)`
+
+### Rounding
+
+* Round `speed` to 2 decimal places for stability:
+
+ * e.g. `123.46`
+
+### Persist
+
+Write `metadata/day.json` including:
+
+* date
+* day_start, day_end (RFC3339 UTC)
+* clip_count_total
+* clip_count_included
+* total_recorded_seconds
+* target_output_seconds
+* computed_speed (pre-clamp)
+* clamped_speed (final)
+* expected_output_seconds = total_recorded_seconds / clamped_speed
+
+---
+
+## 11. Phase E: Generate Timelapse with FFmpeg (Concat + Speed-Up)
+
+### Goal
+
+Produce a single MP4 timelapse that:
+
+* plays clips sequentially
+* skips gaps implicitly
+* accelerates video to meet the 10-minute target
+* preserves burned-in timestamps already present in frames
+* uses a constant output framerate for smooth playback
+
+### Requirements
+
+* Timelapse must be **re-encoded** (filters require it).
+* Output must be placed in:
+
+ * `timelapse/<camera-name>_<YYYY-MM-DD>_timelapse.mp4`
+
+### Video filter rule
+
+Apply playback speed-up via:
+
+* `setpts=PTS/<speed>`
+
+Where `<speed>` is the final computed clamped speed from `metadata/day.json`.
+
+### Output characteristics (defaults)
+
+* Codec: H.264
+* FPS: 30
+* Pixel format: yuv420p
+* Quality: CRF 23, preset medium
+* Audio: dropped/disabled (unless Protect clips include meaningful audio and user opts in; default is no audio)
+
+### VFR handling
+
+Because Protect clips may be variable framerate:
+
+* The pipeline must normalize the output to a constant FPS (`output_fps`).
+* Minor boundary stutter is acceptable; major artifacts should be flagged in validation.
+
+### Intermediate files
+
+Intermediate “full concatenated” file is optional.
+If created, it must be stored under `timelapse/` and named deterministically, but the pipeline must be able to run without it.
+
+---
+
+## 12. Verification & Acceptance Criteria
+
+### Clip verification
+
+* All included clips exist and are non-empty.
+* Number of downloaded clips matches number of included events (unless failures recorded).
+
+### Ordering verification
+
+* `concat.txt` ordering matches ascending `(start_ms, end_ms, event_id)`.
+
+### Timelapse verification
+
+* Output file exists and is non-empty.
+* Output duration:
+
+ * If `total_recorded_seconds >= target_output_seconds`:
+
+ * duration is approximately `target_output_seconds` (tolerance ± 10 seconds)
+ * Else:
+
+ * duration approximately `total_recorded_seconds` (tolerance ± 10 seconds)
+* Visual spot-check guidance (manual):
+
+ * Start: first clip boundary looks correct
+ * Middle: timestamps progress (with jumps at gaps)
+ * End: final clip plays and ends cleanly
+
+### Auditability
+
+* `metadata/day.json` must allow a reviewer to understand:
+
+ * what clips were included
+ * total recorded time
+ * why the timelapse is sped up by the given factor
+
+---
+
+## 13. Failure & Recovery Model
+
+### General
+
+The pipeline is restartable and must not require cleanup to resume.
+
+### Resume rules
+
+* Existing valid clip MP4s are never re-downloaded.
+* A failed download remains listed in `clip_index.json` with `last_error`.
+* Rerun attempts failed downloads again up to retry rules, unless explicitly disabled.
+
+### Partial downloads
+
+* `.partial` files are treated as incomplete and are deleted before retry.
+
+### Zero-data day
+
+If enumeration returns no valid downloadable events:
+
+* Do not attempt timelapse generation.
+* Produce `metadata/*` and manifests as usual.
+* Exit with a clear “no clips available” result.
+
+---
+
+## 14. Logging
+
+### Requirements
+
+* Write a run log to `logs/run.log`.
+* Log must include:
+
+ * resolved camera
+ * day window
+ * number of events enumerated
+ * number of clips downloaded/skipped/failed
+ * total recorded seconds
+ * computed speed
+ * ffmpeg invocation summary (arguments redacted if they contain secrets; no credentials logged)
+ * output path and resulting duration (if measured)
+
+---
+
+## 15. Security Considerations
+
+* Credentials must not be written to disk by default.
+* Logs must not include passwords or session cookies.
+* If TLS verification is disabled, it must be an explicit user choice and logged as such.
+
+---
+
+## 16. Configuration & CLI Contract
+
+### Core commands (conceptual)
+
+* `scan` — resolve camera, enumerate events, write `clip_index.json`
+* `fetch` — download clips listed in `clip_index.json`
+* `render` — create concat manifest, compute speed, run FFmpeg to generate timelapse
+* `run` — convenience: scan + fetch + render
+
+### Minimum flags (conceptual)
+
+* `--host`
+* `--user`
+* `--pass` or `--pass-file` or env-based equivalent
+* `--camera` (id or name)
+* `--date` (YYYY-MM-DD)
+* `--out`
+
+Optional:
+
+* `--target 10m`
+* `--fps 30`
+* `--workers 3`
+
+(Exact CLI syntax is left to implementation, but these inputs must be supported.)
+
+---
+
+## 17. Out-of-Scope Extensions
+
+* Multi-camera, multi-day
+* Automatic daily scheduling
+* Cloud upload/sharing
+* Motion-only timelapse
+* Adding timestamp overlays (already present)
+* Smart scene detection / per-hour chapters
+
+---
+
+## 18. Summary
+
+This spec defines a **simple and robust** one-day UniFi Protect timelapse workflow:
+
+* Enumerate recording events for the day
+* Download each event MP4 deterministically
+* Concatenate clips in order (skipping gaps naturally)
+* Compute a speed factor so output is ~10 minutes
+* Re-encode with FFmpeg using `setpts` and constant FPS
+