diff --git a/.env b/.env
index 7003095..bae861e 100644
--- a/.env
+++ b/.env
@@ -1,88 +1,23 @@
# ------------------------------------------------------------------------------
-# UniFi Time-Machine Environment Variables
+# UniFi Time-Machine — Bootstrap Configuration
#
-# This file configures the application when using Docker Compose.
-# Copy this file to .env and fill in the values for your environment.
+# These are the only values the app reads from the environment.
+# All operational settings (intervals, quality, retention, formats, etc.)
+# are managed at runtime via Admin → Settings and stored in the database.
# ------------------------------------------------------------------------------
-# --- Docker Container Settings ---
-# The external port to access the web UI.
+# --- Docker port ---
HTTP_PORT=8000
-# --- UniFi Protect Integration Settings ---
-# The IP address or hostname of your UniFi Protect controller.
+# --- UniFi Protect (required) ---
UFP_HOST=192.168.1.1
-# The API key from your UniFi Protect user account.
-# Go to your UniFi OS Console -> Integrations -> Create API Key
-# This should be set as an environment variable in your shell:
-# export UFP_API_KEY="your_key_here"
-# UFP_API_KEY=
-# The ID of the camera you want to capture snapshots from.
-# You can find this in the URL when viewing the camera in the Protect web UI.
-TARGET_CAMERA_ID=
+UFP_API_KEY= # API key from Protect → Integrations
+TARGET_CAMERA_ID= # Camera ID from the Protect URL
-# --- Authentication Settings ---
-# A mandatory, base64-encoded secret key for application security.
-# The application will not start without it.
-# This should be set as an environment variable in your shell:
-# export APP_KEY=$(head -c 32 /dev/urandom | base64)
-# APP_KEY=
+# --- Authentication (required) ---
+APP_KEY= # generate: head -c 32 /dev/urandom | base64
+ADMIN_PASSWORD= # initial admin password (can be changed in UI)
-
-# The password for the initial 'admin' user, created on first launch.
-# This is required to be set on the first run. It can be changed or removed later.
-ADMIN_PASSWORD=
-
-# --- Timelapse and Video Generation Settings ---
-# The interval, in seconds, between each snapshot.
-# Default is 3600 (1 hour). A value of 60 is every minute.
-TIMELAPSE_INTERVAL=60
-# The interval, in seconds, at which to generate a new timelapse video.
-# Default is 300 (5 minutes).
-VIDEO_CRON_INTERVAL=600
-# The quality of the generated video.
-# Options: low, medium, high, ultra ( ultra is recommended )
-VIDEO_QUALITY=ultra
-
-# --- Share Link Expiry ---
-# The number of hours a shared link is valid for. Set to 0 for unlimited.
-SHARE_LINK_EXPIRY_HOURS=4
-
-# --- Formatting Settings ---
-# The format for displaying dates (e.g. DD/MM/YYYY, MM/DD/YYYY, YYYY-MM-DD)
-# DATE_FORMAT=DD/MM/YYYY
-# The format for displaying times (e.g. 12h, 24h)
-# TIME_FORMAT=12h
-
-# --- Daylight Filtering Settings ---
-# Only images taken within this hour range are used in weekly, monthly, and yearly timelapses.
-# 24-hour daily timelapses are unaffected and always include all hours.
-# Default is 7–19 (7am to 7pm). Set both to 0 and 24 to disable filtering.
-# DAYLIGHT_START_HOUR=7
-# DAYLIGHT_END_HOUR=19
-# The target hour (0–23) used to pick the best image for each day in monthly timelapses.
-# The image whose capture time is closest to this hour is selected. Default is 12 (noon).
-# DAYLIGHT_TARGET_HOUR=12
-
-# --- Timelapse Retention Settings ---
-# Number of calendar-week timelapses to keep (one per Monday). Default: 4
-# WEEKLY_LAPSES_TO_KEEP=4
-# Number of calendar-month timelapses to keep. Default: 3
-# MONTHLY_LAPSES_TO_KEEP=3
-
-# --- Data Retention and Cleanup Settings ---
-# The directory inside the container for storing snapshots.
-SNAPSHOTS_DIR=snapshots
-# The directory inside the container for storing gallery images.
-GALLERY_DIR=gallery
-# The path to the ffmpeg log file.
-FFMPEG_LOG_PATH=ffmpeglogs
-
-# --- Gin Web Framework Settings ---
-# Set the Gin mode. Use 'release' for production, 'debug' for development.
+# --- Runtime ---
GIN_MODE=release
-
-# --- Timezone ---
-# The timezone to use for the container.
-# e.g., "Australia/Sydney", "America/New_York"
TZ=Australia/Sydney
diff --git a/DEVGUIDE.md b/DEVGUIDE.md
new file mode 100644
index 0000000..f0b496c
--- /dev/null
+++ b/DEVGUIDE.md
@@ -0,0 +1,120 @@
+# Developer Guide
+
+Notes for building, running locally, and contributing to UniFi Time-Machine.
+
+---
+
+## Tech stack
+
+- **Go 1.26+** — single binary, CGO enabled for SQLite
+- **SQLite** — embedded database via `modernc.org/sqlite` (no system dependency)
+- **Gin** — HTTP framework
+- **HTMX + Bootstrap** — frontend, vanilla JS, no build step
+- **FFmpeg** — video encoding (injected at runtime via the Docker image)
+
+The app follows 12-factor principles: config at the boundary (env vars for bootstrap, DB for everything else), stateless process, data in a mounted volume.
+
+---
+
+## Project structure
+
+```
+cmd/server/ main entrypoint
+pkg/config/ bootstrap config (env vars only)
+pkg/services/ business logic — snapshots, timelapse, settings, auth
+pkg/handlers/ HTTP handlers
+pkg/database/ SQLite helpers and migrations
+web/ HTML templates, static assets
+```
+
+---
+
+## Running locally
+
+You'll need Go 1.26+ and FFmpeg installed.
+
+```bash
+go mod tidy
+export UFP_API_KEY="..."
+export TARGET_CAMERA_ID="..."
+export APP_KEY="$(head -c 32 /dev/urandom | base64)"
+export ADMIN_PASSWORD="dev"
+export GIN_MODE=debug
+go run ./cmd/server
+```
+
+The web UI will be at `http://localhost:8080`.
+
+> The app expects a `web/` directory relative to the working directory. Run from the repo root or set `DATA_DIR` explicitly.
+
+---
+
+## Building the Docker image
+
+`build.sh` produces a multi-arch image for `linux/amd64` and `linux/arm64` and pushes it to Docker Hub.
+
+```bash
+bash build.sh [tag]
+```
+
+Tag defaults to `latest`. The script also tags with the current date (`YYYYMMDD`).
+
+Requires `docker buildx` and a configured builder instance. The script creates one named `mybuilder` if it doesn't exist.
+
+### Dockerfiles
+
+| File | Base | Notes |
+|---|---|---|
+| `Dockerfile` | `debian:bookworm-slim` | Standard image, recommended |
+| `Dockerfile_chainguard` | Chainguard | Minimal/hardened alternative |
+
+The build pipeline runs tests (`go test -v ./...`) in a separate stage before compiling, so a failing test will abort the image build.
+
+---
+
+## Releasing
+
+1. Merge to `main`
+2. Tag the commit: `git tag v1.2.3 && git push origin v1.2.3`
+3. Run `bash build.sh v1.2.3` to build and push the versioned + dated tags
+
+---
+
+## Settings architecture
+
+Bootstrap config (things the app needs before the DB is open) lives in env vars — see `pkg/config/config.go`.
+
+All operational settings (snapshot interval, video quality, retention counts, daylight hours, etc.) are seeded into SQLite on first launch and managed at runtime via the Admin → Settings UI. The seed table is in `pkg/services/settings/settings.go` (`KnownSettings`). Env vars listed there only take effect on the very first run; after that the DB value wins.
+
+---
+
+## Tests
+
+```bash
+go test ./...
+```
+
+All packages should have a corresponding `_test.go`. New features should ship with tests.
+
+---
+
+## Timelapse file naming
+
+Current naming convention (calendar-based):
+
+| Type | Filename pattern |
+|---|---|
+| Daily (24 h) | `timelapse_24_hour_YYYY-MM-DD.webm` |
+| Weekly | `timelapse_week_YYYY-MM-DD.webm` (Monday date) |
+| Monthly | `timelapse_month_YYYY-MM.webm` |
+| Yearly | `timelapse_year_YYYY.webm` |
+
+Older installs may have rolling-window files named `timelapse_1_week.webm`, `timelapse_1_month.webm`, `timelapse_1_year.webm`. These are no longer generated and can be safely deleted.
+
+---
+
+## Style notes
+
+- UK/Australian English spelling
+- No third-party packages without discussion — prefer stdlib
+- Dates/times in UTC internally; display timezone applied in the UI layer
diff --git a/README.md b/README.md
index 82c55ed..9ceace7 100644
--- a/README.md
+++ b/README.md
@@ -1,205 +1,127 @@
# UniFi Time-Machine
-UniFi Time-Machine is a Go application that creates beautiful timelapse videos from your UniFi Protect cameras. It provides a web interface you can access directly, or behind a reverse proxy/load balancer.
+Automatic timelapse videos from your UniFi Protect cameras, with a clean web interface to watch them.
+## Screenshots
+**Daily View**
+
-## Web Console - Daily View
-
+**Gallery**
+
-## Web Console - Gallery
-
-
-## Web Console - Admin Panel
-
-
-## Web Console - Share Time Lapse
+**Admin Panel**
+
+---
## Features
-- **Automatic Timelapse Generation**: Periodically generates timelapse videos from your UniFi Protect camera snapshots.
-- **Calendar-Based Timelapses**: Four timelapse types — daily (24 h), weekly (Mon–Sun), monthly, and yearly — each with configurable retention counts. No rolling-window drift.
-- **Daytime-First Image Selection**: Weekly and monthly lapses use gallery images filtered to your configured daylight hours, and daily pattern picks the image closest to noon — no more night images.
-- **Daily Gallery**: Takes hourly images of your target camera and builds a 24-hour gallery; sort and filter by date.
-- **Web Interface**: A simple, clean web UI to view the latest snapshots, watch timelapses, and check system status.
-- **Multi-Arch Support**: Docker images are available for both x86 (amd64) and ARM64 architectures.
-- **Configurable**: Most settings can be configured using environment variables.
-- **Efficient**: Uses a background worker to process jobs and a caching mechanism to keep the UI responsive.
-
-## Getting Started
-
-There are several ways to run UniFi Time-Machine. The easiest way is to use Docker and highly recommended.
-
-### Versions
-Versions are linked to Git Tags on this repo such as `v0.0.1` and pushed to `Dockerhub`. Other tags of interest;
-
-- dev ( latest build on dev branches )
-- latest ( builds off main branch ) - will be deprecated in favour of git hash/sha256 builds.
-- tags eg: `v1.2.3` ( recommended )
-
-From the initial release, a security update was made to the container `user` which is now `appuser` with a UID/GID of `1000`, this may break your existing database on disk and require a `chown` to fix permissions. See release notes for details.
-
-If leveraging a docker bind mount or mapping, ensure you set this. `chown -R 1000:1000 data/` on the host directory!
-
-### Running with Docker Compose (Recommended)
-
-This is the recommended way to run the application, as it simplifies the management of the container and its configuration.
-
-1. **Create a `.env` file**: Modify the provided `.env` file and fill in the values for your environment.
-
- At a minimum, you must set `UFP_API_KEY`, `TARGET_CAMERA_ID`, `APP_KEY`, and `ADMIN_PASSWORD`.
-
- `UFP_API_KEY` is generated in your UI Console for the site. Integrations -> `New API Key`.
-
- Note the API calls used within only leverage the `Protect` API Endpoints on `/v1/cameras/{id}` and `/v1/cameras/{id}/snapshot`.
-
- `TARGET_CAMERA_ID` is found my accessing your UI console, selecting the camera -> Settings and refer to the URL in your browser such as `https://192.168.1.1/protect/dashboard/all/sidepanel/device//manage`
-
-2. **Start the container**:
-
- ```bash
- docker-compose up -d
- ```
+- Captures hourly snapshots and builds **daily, weekly, monthly, and yearly** timelapses automatically
+- **24-hour gallery** — browse any day's images, sort and filter by date
+- **Share links** — generate a time-limited public link to any timelapse
+- **Daylight filtering** — weekly and monthly lapses skip night images automatically
+- **HLS adaptive streaming** — smooth playback on any connection
+- All settings configured in the **Admin → Settings** panel — no restarts needed
+- Multi-arch Docker image (amd64 + ARM64)
-3. **Access the web UI**: The web UI will be available at `http://localhost:8000` (or the port you specified in `HTTP_PORT`). Login with `admin` as the user, and the password you defined in the startup variables.
+---
-### Running with `start.sh`
+## Quick Start
-The `start.sh` script is a convenient way to run the application with Docker without using Docker Compose.
+### Option A — Docker Compose (recommended)
-1. **Configure the script**: Open the `start.sh` script and edit the configuration variables at the top of the file. Note that variables like APP_KEY and UFP KEY are read from your environment. Pending you shell env, this is usually set via `export UFP_API_KEY` in files like your `~/.bash_profile` or `~/.bashrc`.
+1. Copy `.env` and fill in the four required values:
-2. **Make the script executable**:
+ ```
+ UFP_HOST=192.168.1.1
+ UFP_API_KEY=
+ TARGET_CAMERA_ID=
+ APP_KEY=
+ ADMIN_PASSWORD=
+ ```
- ```bash
- chmod +x start.sh
- ```
+2. Start:
-3. **Run the script**:
+ ```bash
+ docker compose up -d
+ ```
- ```bash
- ./start.sh
- ```
+3. Open `http://localhost:8000` and log in with `admin` / your `ADMIN_PASSWORD`.
-### Building the Docker Image
+### Option B — start.sh
-If you want to build the Docker image yourself, you can use the `build.sh` script. This script builds a multi-arch image for `linux/amd64` and `linux/arm64`.
+Edit the values at the top of `start.sh`, then:
-1. **Make the script executable**:
+```bash
+bash start.sh
+```
- ```bash
- chmod +x build.sh
- ```
+`UFP_API_KEY` and `APP_KEY` can also be exported in your shell before running rather than written into the script.
-2. **Run the script**:
+---
- ```bash
- ./build.sh [tag]
- ```
+## Finding your values
- The optional `tag` argument specifies the Docker image tag. If not provided, it defaults to `latest`.
+**`UFP_API_KEY`** — In your UniFi OS console go to **Integrations → New API Key**.
-### Building from Source
+**`TARGET_CAMERA_ID`** — Open the camera in the Protect web UI. The ID is in the URL:
+```
+https://192.168.1.1/protect/dashboard/all/sidepanel/device//manage
+```
-If you want to build the application from source, you'll need Go 1.25 or later installed. Using docker is recommended as we need CGO with SQLite and other dependencies that's much cleaner.
+**`APP_KEY`** — A random secret used to sign sessions. Generate one:
+```bash
+head -c 32 /dev/urandom | base64
+```
-1. **Install dependencies**:
-
- ```bash
- go mod tidy
- ```
-
-2. **Build the binary**:
-
- ```bash
- CGO_ENABLED=1 GOOS=$GOOS GOARCH=$GOARCH go build -ldflags '-s -w -extldflags "-static"' -tags osusergo,netgo -o /unifi-time-machine ./cmd/server
- ```
-
-3. **Run the application**:
-
- ```bash
- ./unifi-time-machine
- ```
-
- You will need to set the required environment variables before running the application. Web folder must be accessible etc.
+---
## Configuration
-The application is configured using environment variables. The `.env` file contains the full list with comments. Key variables are described below.
-
-### Required
-
-| Variable | Description |
-|---|---|
-| `UFP_HOST` | IP or hostname of your UniFi Protect controller |
-| `UFP_API_KEY` | API key from UniFi OS Console → Integrations |
-| `TARGET_CAMERA_ID` | Camera ID found in the Protect URL when viewing the camera |
-| `APP_KEY` | Base64-encoded secret key (`head -c 32 /dev/urandom \| base64`) |
-| `ADMIN_PASSWORD` | Password for the initial `admin` account (required on first run) |
+Only these values need to be set in the environment. Everything else (snapshot interval, video quality, retention, formats, timezones, etc.) is configured in the **Admin → Settings** panel after first launch.
-### Snapshot & Video
-
-| Variable | Default | Description |
-|---|---|---|
-| `TIMELAPSE_INTERVAL` | `3600` | Seconds between snapshots (e.g. `3600` = hourly) |
-| `VIDEO_CRON_INTERVAL` | `300` | Seconds between timelapse generation passes |
-| `VIDEO_QUALITY` | `medium` | Encoding quality: `low`, `medium`, `high`, `ultra` |
-| `DAYS_OF_24_HOUR_SNAPSHOTS` | `30` | How many daily 24-hour timelapses to keep |
-| `SNAPSHOT_RETENTION_DAYS` | `30` | How long to keep raw snapshot files |
-| `GALLERY_RETENTION_DAYS` | `365` | How long to keep hourly gallery images |
-| `SHARE_LINK_EXPIRY_HOURS` | `4` | Shared link lifetime in hours (`0` = unlimited) |
-
-### Timelapse Types & Retention
-
-UniFi Time-Machine generates four categories of timelapse, all sourced from the 365-day hourly gallery so they are not limited by raw snapshot retention.
-
-| Type | Source | Frame selection | Named as |
-|---|---|---|---|
-| **Daily** | Raw snapshots | Every captured image for that calendar day | `timelapse_24_hour_YYYY-MM-DD.webm` |
-| **Weekly** | Gallery (hourly) | One image per daylight hour, Mon–Sun | `timelapse_week_YYYY-MM-DD.webm` (Monday date) |
-| **Monthly** | Gallery (hourly) | One image per day, closest to noon | `timelapse_month_YYYY-MM.webm` |
-| **Yearly** | Gallery (hourly) | ~5 images per day (every 3 h) | `timelapse_year_YYYY.webm` |
-
-| Variable | Default | Description |
+| Variable | Required | Description |
|---|---|---|
-| `WEEKLY_LAPSES_TO_KEEP` | `4` | Number of calendar-week timelapses to retain |
-| `MONTHLY_LAPSES_TO_KEEP` | `3` | Number of calendar-month timelapses to retain |
+| `UFP_HOST` | Yes | IP or hostname of your UniFi Protect controller |
+| `UFP_API_KEY` | Yes | API key from UniFi OS → Integrations |
+| `TARGET_CAMERA_ID` | Yes | Camera ID from the Protect URL |
+| `APP_KEY` | Yes | Base64 secret for session signing |
+| `ADMIN_PASSWORD` | Yes | Initial password for the `admin` account |
+| `TZ` | No | Container timezone (e.g. `Australia/Sydney`) |
+| `GIN_MODE` | No | Set to `release` for production (default) |
-### Daylight Filtering
+---
-These settings apply to weekly, monthly, and yearly timelapses. The 24-hour daily timelapse always includes all hours.
+## Docker image tags
-| Variable | Default | Description |
-|---|---|---|
-| `DAYLIGHT_START_HOUR` | `7` | Earliest hour (0–23) included in non-daily lapses |
-| `DAYLIGHT_END_HOUR` | `19` | Latest hour (exclusive) included in non-daily lapses |
-| `DAYLIGHT_TARGET_HOUR` | `12` | Preferred hour for daily-pattern selection (monthly lapse picks the image closest to this hour each day) |
+| Tag | Description |
+|---|---|
+| `v1.2.3` | Specific release — recommended for production |
+| `latest` | Latest build from `main` |
+| `dev` | Latest build from development branches |
-### Display
+Pull from Docker Hub: `mbern/unifi-time-machine`
-| Variable | Default | Description |
-|---|---|---|
-| `DATE_FORMAT` | `DD/MM/YYYY` | Date display format: `DD/MM/YYYY`, `MM/DD/YYYY`, `YYYY-MM-DD` |
-| `TIME_FORMAT` | `12h` | Time display format: `12h` or `24h` |
-| `TZ` | — | Container timezone (e.g. `Australia/Sydney`, `America/New_York`) |
+---
+
+## Permissions
-### Migrating from older versions
+The container runs as `appuser` (UID/GID `1000`). If you're using a bind-mounted data directory, set ownership on the host:
-Versions prior to this release used rolling-window timelapses named `timelapse_1_week.webm`, `timelapse_1_month.webm`, and `timelapse_1_year.webm`. These files are no longer generated or cleaned up automatically. You can safely delete them from your data directory — the new calendar-named files will appear automatically on the next generation cycle.
+```bash
+chown -R 1000:1000 ./data
+```
-## Caveats
-This project is still in its early days and bugs etc are expected alongside major changes. A 1.0.0 release would represent something of more mature stability after more field testing and feedback.
+---
+## Contributing
+Issues and PRs are welcome. See [DEVGUIDE.md](DEVGUIDE.md) for build instructions and developer notes.
-## Next Features
-* GPU Support
-* More than one camera in your Protect app?
-* Cloud Backups & Tiered Storage for Edge/IOT deployments
-* Public URL Sharing - Done!
-* AI/Video Summary - summarise an uploaded video such as a mp4 from other systems and create a summary of detected objects, events etc
-* Payment/Cashier tracker for retail environments based on payment terminal transactions outside of shopify for the rest of the world
+## Roadmap
-## Contributing
-I welcome any contributions or ideas.
+- GPU encoding support
+- Multi-camera support
+- Cloud / tiered storage for edge deployments
+- AI video summaries
diff --git a/pkg/config/config.go b/pkg/config/config.go
index c1fe03f..66fd463 100644
--- a/pkg/config/config.go
+++ b/pkg/config/config.go
@@ -5,7 +5,6 @@ import (
"log"
"os"
"path/filepath"
- "strconv"
"strings"
)
@@ -75,10 +74,3 @@ func getEnv(key, defaultValue string) string {
return defaultValue
}
-func getEnvAsInt(key string, defaultValue int) int {
- valueStr := getEnv(key, "")
- if value, err := strconv.Atoi(valueStr); err == nil {
- return value
- }
- return defaultValue
-}
diff --git a/pkg/config/config_test.go b/pkg/config/config_test.go
index 69c7169..e0b256a 100644
--- a/pkg/config/config_test.go
+++ b/pkg/config/config_test.go
@@ -41,17 +41,3 @@ func TestLoadConfig(t *testing.T) {
assert.True(t, strings.HasSuffix(AppConfig.SnapshotsDir, "snapshots"))
assert.True(t, strings.HasSuffix(AppConfig.GalleryDir, "gallery"))
}
-
-func TestGetEnvAsInt(t *testing.T) {
- os.Setenv("TEST_INT", "123")
- val := getEnvAsInt("TEST_INT", 456)
- assert.Equal(t, 123, val)
-
- os.Unsetenv("TEST_INT")
- val = getEnvAsInt("TEST_INT", 456)
- assert.Equal(t, 456, val)
-
- os.Setenv("TEST_INT", "abc")
- val = getEnvAsInt("TEST_INT", 456)
- assert.Equal(t, 456, val)
-}
diff --git a/pkg/services/snapshot/snapshot.go b/pkg/services/snapshot/snapshot.go
index 3e863d0..be00e53 100644
--- a/pkg/services/snapshot/snapshot.go
+++ b/pkg/services/snapshot/snapshot.go
@@ -18,6 +18,15 @@ import (
"time-machine/pkg/util"
)
+// minSnapshotBytes is the smallest a valid camera JPEG is expected to be.
+// An NVR that is up but whose camera is offline can return HTTP 200 with an empty
+// or near-empty body. Snapshots below this threshold are discarded on capture.
+const minSnapshotBytes int64 = 2048
+
+// consecutiveFailureWarnThreshold is the number of back-to-back snapshot failures
+// that triggers a loud log warning about potential NVR/camera connectivity issues.
+const consecutiveFailureWarnThreshold = 3
+
// hqCapable is the camera's auto-detected HQ snapshot capability, set at startup.
// It is only written once during InitSnapshotSettings and is safe to read concurrently.
var hqCapable bool
@@ -122,16 +131,24 @@ func GetEffectiveSnapshotQuality() string {
// --- CORE LOGIC (Scheduler and API calls) ---
func StartSnapshotScheduler() {
+ var consecutiveFailures int
for {
- TakeSnapshot()
+ if TakeSnapshot() {
+ consecutiveFailures = 0
+ } else {
+ consecutiveFailures++
+ if consecutiveFailures >= consecutiveFailureWarnThreshold {
+ log.Printf("WARNING: %d consecutive snapshot failures — NVR may be unreachable or returning invalid data; check connectivity", consecutiveFailures)
+ }
+ }
time.Sleep(time.Duration(settings.GetInt("snapshot.interval_sec", 3600)) * time.Second)
}
}
-func TakeSnapshot() {
+func TakeSnapshot() bool {
if config.AppConfig.UFPHost == "" || config.AppConfig.UFPAPIKey == "" || config.AppConfig.TargetCameraID == "" {
log.Println("Snapshot Error: UniFi Protect credentials missing.")
- return
+ return false
}
apiURL := fmt.Sprintf("%s/proxy/protect/integration/v1/cameras/%s/snapshot", config.AppConfig.UFPHost, config.AppConfig.TargetCameraID)
@@ -150,53 +167,68 @@ func TakeSnapshot() {
req, err := http.NewRequest("GET", apiURL, nil)
if err != nil {
log.Printf("Error creating snapshot request: %v", err)
- return
+ return false
}
req.Header.Set("X-Api-Key", config.AppConfig.UFPAPIKey)
resp, err := client.Do(req)
if err != nil {
log.Printf("Snapshot API request failed: %v", err)
- return
+ return false
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
bodyBytes, _ := io.ReadAll(resp.Body)
log.Printf("UniFi API returned status code %d: %s", resp.StatusCode, string(bodyBytes))
- return
+ return false
}
now := time.Now()
- // --- New Directory Structure Logic ---
// Path: snapshots/YYYY-MM/DD/HH/
- // AI did this date, I hate it... will change later
snapshotDir := filepath.Join(config.AppConfig.SnapshotsDir, now.Format("2006-01"), now.Format("02"), now.Format("15"))
if err := os.MkdirAll(snapshotDir, 0755); err != nil {
log.Printf("Error creating snapshot directory %s: %v", snapshotDir, err)
- return
+ return false
}
- // Save the snapshot for the timelapse
fileName := now.Format("2006-01-02-15-04-05") + ".jpg"
snapshotPath := filepath.Join(snapshotDir, fileName)
out, err := os.Create(snapshotPath)
if err != nil {
log.Printf("Error creating file %s: %v", snapshotPath, err)
- return
+ return false
}
- defer out.Close()
- // Tee the response body to write to multiple places if needed
- if _, err = io.Copy(out, resp.Body); err != nil {
- log.Printf("Error saving snapshot to file %s: %v", snapshotPath, err)
- return
+ _, copyErr := io.Copy(out, resp.Body)
+ out.Close() // close before stat so the OS flushes metadata
+
+ if copyErr != nil {
+ log.Printf("Error saving snapshot to file %s: %v", snapshotPath, copyErr)
+ os.Remove(snapshotPath)
+ return false
}
+
+ // Reject snapshots that are too small to be a real camera JPEG.
+ // An NVR that is up but whose camera is offline can return HTTP 200
+ // with an empty or near-empty body that would produce corrupt video frames.
+ info, statErr := os.Stat(snapshotPath)
+ if statErr != nil {
+ log.Printf("Snapshot %s: stat failed after write: %v", snapshotPath, statErr)
+ os.Remove(snapshotPath)
+ return false
+ }
+ if info.Size() < minSnapshotBytes {
+ log.Printf("Snapshot %s discarded: %d bytes is below minimum threshold (%d) — NVR may be returning empty/placeholder data",
+ snapshotPath, info.Size(), minSnapshotBytes)
+ os.Remove(snapshotPath)
+ return false
+ }
+
log.Printf("Snapshot saved: %s", snapshotPath)
- // --- New Gallery Logic ---
- // Save the first snapshot of the hour to the gallery
+ // Save the first snapshot of the hour to the gallery.
galleryFileName := now.Format("2006-01-02-15") + ".jpg"
galleryPath := filepath.Join(config.AppConfig.GalleryDir, galleryFileName)
@@ -208,11 +240,12 @@ func TakeSnapshot() {
}
}
- // Update the latest_snapshot.jpg for the video player poster
+ // Update the latest_snapshot.jpg for the video player poster.
latestPath := filepath.Join(config.AppConfig.DataDir, "latest_snapshot.jpg")
if err := util.CopyFile(snapshotPath, latestPath); err != nil {
log.Printf("Error copying snapshot to latest_snapshot.jpg: %v", err)
}
+ return true
}
func GetCameraStatus() map[string]interface{} {
diff --git a/pkg/services/snapshot/snapshot_test.go b/pkg/services/snapshot/snapshot_test.go
index 020b0c9..c52766a 100644
--- a/pkg/services/snapshot/snapshot_test.go
+++ b/pkg/services/snapshot/snapshot_test.go
@@ -1,6 +1,7 @@
package snapshot
import (
+ "bytes"
"encoding/json"
"net/http"
"net/http/httptest"
@@ -16,13 +17,19 @@ import (
"time-machine/pkg/services/settings"
)
+// fakeJPEGBody returns a byte slice of at least minSnapshotBytes that looks like
+// camera data. Real content doesn't matter for unit tests — only the size does.
+func fakeJPEGBody() []byte {
+ return bytes.Repeat([]byte("X"), int(minSnapshotBytes)+100)
+}
+
var mockServer *httptest.Server
func setupMockServer() {
mockServer = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if strings.Contains(r.URL.Path, "snapshot") {
w.WriteHeader(http.StatusOK)
- w.Write([]byte("jpeg_image_data"))
+ w.Write(fakeJPEGBody())
} else if strings.Contains(r.URL.Path, "cameras") {
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(map[string]interface{}{
@@ -96,7 +103,8 @@ func TestTakeSnapshot(t *testing.T) {
// Force HQ on so the snapshot URL uses ?highQuality=true
settings.Set("snapshot.hq_params", "true")
hqCapable = true
- TakeSnapshot()
+ ok := TakeSnapshot()
+ assert.True(t, ok, "TakeSnapshot should return true when NVR returns a valid-sized body")
now := time.Now()
snapshotDir := filepath.Join(config.AppConfig.SnapshotsDir, now.Format("2006-01"), now.Format("02"), now.Format("15"))
@@ -110,6 +118,102 @@ func TestTakeSnapshot(t *testing.T) {
assert.FileExists(t, latestPath)
}
+func setupSnapshotDirs(t *testing.T) {
+ t.Helper()
+ tempDir := t.TempDir()
+ config.AppConfig.DataDir = tempDir
+ config.AppConfig.SnapshotsDir = filepath.Join(tempDir, "snapshots")
+ config.AppConfig.GalleryDir = filepath.Join(tempDir, "gallery")
+ os.MkdirAll(config.AppConfig.SnapshotsDir, 0755)
+ os.MkdirAll(config.AppConfig.GalleryDir, 0755)
+}
+
+func TestTakeSnapshot_EmptyBodyDiscarded(t *testing.T) {
+ // NVR returns HTTP 200 with an empty body (camera offline but NVR is up).
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.WriteHeader(http.StatusOK)
+ // write nothing
+ }))
+ defer srv.Close()
+
+ setupSnapshotDirs(t)
+ config.AppConfig.UFPHost = srv.URL
+ config.AppConfig.UFPAPIKey = "key"
+ config.AppConfig.TargetCameraID = "cam"
+
+ ok := TakeSnapshot()
+ assert.False(t, ok, "empty response body should be rejected")
+
+ // No snapshot file should remain on disk.
+ snaps, _ := filepath.Glob(filepath.Join(config.AppConfig.SnapshotsDir, "*/*/*/*.jpg"))
+ assert.Empty(t, snaps, "no snapshot file should be left after an empty-body rejection")
+}
+
+func TestTakeSnapshot_TinyBodyDiscarded(t *testing.T) {
+ // NVR returns HTTP 200 with a body smaller than minSnapshotBytes.
+ tinyBody := bytes.Repeat([]byte("x"), int(minSnapshotBytes)-1)
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.WriteHeader(http.StatusOK)
+ w.Write(tinyBody)
+ }))
+ defer srv.Close()
+
+ setupSnapshotDirs(t)
+ config.AppConfig.UFPHost = srv.URL
+ config.AppConfig.UFPAPIKey = "key"
+ config.AppConfig.TargetCameraID = "cam"
+
+ ok := TakeSnapshot()
+ assert.False(t, ok, "below-threshold body should be rejected")
+
+ snaps, _ := filepath.Glob(filepath.Join(config.AppConfig.SnapshotsDir, "*/*/*/*.jpg"))
+ assert.Empty(t, snaps, "no snapshot file should be left after a size rejection")
+}
+
+func TestTakeSnapshot_NonOKStatusRejected(t *testing.T) {
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.WriteHeader(http.StatusServiceUnavailable)
+ w.Write([]byte("service unavailable"))
+ }))
+ defer srv.Close()
+
+ setupSnapshotDirs(t)
+ config.AppConfig.UFPHost = srv.URL
+ config.AppConfig.UFPAPIKey = "key"
+ config.AppConfig.TargetCameraID = "cam"
+
+ ok := TakeSnapshot()
+ assert.False(t, ok, "non-200 status should be rejected")
+
+ snaps, _ := filepath.Glob(filepath.Join(config.AppConfig.SnapshotsDir, "*/*/*/*.jpg"))
+ assert.Empty(t, snaps, "no snapshot file should be created for a non-200 response")
+}
+
+func TestTakeSnapshot_ValidBodyAccepted(t *testing.T) {
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.WriteHeader(http.StatusOK)
+ w.Write(fakeJPEGBody())
+ }))
+ defer srv.Close()
+
+ setupSnapshotDirs(t)
+ database.InitDB()
+ settings.Init()
+ config.AppConfig.UFPHost = srv.URL
+ config.AppConfig.UFPAPIKey = "key"
+ config.AppConfig.TargetCameraID = "cam"
+
+ ok := TakeSnapshot()
+ assert.True(t, ok, "valid-sized body should be accepted")
+
+ snaps, _ := filepath.Glob(filepath.Join(config.AppConfig.SnapshotsDir, "*/*/*/*.jpg"))
+ assert.Len(t, snaps, 1, "exactly one snapshot file should be saved")
+
+ info, err := os.Stat(snaps[0])
+ assert.NoError(t, err)
+ assert.GreaterOrEqual(t, info.Size(), minSnapshotBytes, "saved snapshot must meet minimum size")
+}
+
func TestGetCameraStatus(t *testing.T) {
setupMockServer()
defer teardownMockServer()
diff --git a/pkg/services/video/video.go b/pkg/services/video/video.go
index 084a1ca..a3d01a9 100644
--- a/pkg/services/video/video.go
+++ b/pkg/services/video/video.go
@@ -30,6 +30,15 @@ var (
onceDetectCapabilities sync.Once
)
+// minValidSnapshotBytes is the minimum file size accepted as a valid JPEG snapshot.
+// Matches the threshold used at capture time in the snapshot package.
+const minValidSnapshotBytes int64 = 2048
+
+// defaultMaxBatchFrames caps the number of frames fed to a single FFmpeg invocation.
+// A large batch of corrupt-but-non-zero files can cause FFmpeg to OOM; this is the
+// safety net after the per-frame size filter.
+const defaultMaxBatchFrames = 5000
+
// getFFmpegThreads returns the configured FFmpeg thread count.
// A setting of 0 means auto-detect: use CPU count capped at 8.
func getFFmpegThreads() int {
@@ -89,8 +98,8 @@ func computeBufSize(maxBitrate string) string {
var createVideoSegment = func(imagePath, segmentPath string) error {
// 1. Input Validation
info, err := os.Stat(imagePath)
- if err != nil || info.Size() == 0 {
- return fmt.Errorf("invalid snapshot file (not found or zero size): %s", imagePath)
+ if err != nil || info.Size() < minValidSnapshotBytes {
+ return fmt.Errorf("invalid snapshot file (below minimum size %d bytes): %s", minValidSnapshotBytes, imagePath)
}
log.Printf("Creating video segment for %s using codec %s with %d threads...", filepath.Base(imagePath), PreferredVideoCodec, getFFmpegThreads())
@@ -540,7 +549,7 @@ func buildConcatList(name string, snapshots []string) (string, error) {
var valid []string
for _, s := range snapshots {
info, err := os.Stat(s)
- if err == nil && info.Size() > 0 {
+ if err == nil && info.Size() >= minValidSnapshotBytes {
valid = append(valid, s)
}
}
@@ -775,6 +784,31 @@ var filterSnapshots = func(allFiles []string, cfg models.TimelapseConfig, target
sort.Strings(filtered)
return filtered
}
+// prepareSnapshotsForBatch filters out missing or undersized snapshots and caps the
+// result to maxFrames (keeping the most recent), preventing FFmpeg OOM on large batches.
+// Pass maxFrames ≤ 0 to disable the cap.
+func prepareSnapshotsForBatch(snapshotFiles []string, maxFrames int) []string {
+ var valid []string
+ for _, snapshot := range snapshotFiles {
+ info, err := os.Stat(snapshot)
+ if err != nil || info.Size() < minValidSnapshotBytes {
+ if err == nil {
+ log.Printf("Skipping snapshot below minimum size (%d bytes): %s", info.Size(), snapshot)
+ } else {
+ log.Printf("Skipping missing snapshot: %s", snapshot)
+ }
+ continue
+ }
+ valid = append(valid, snapshot)
+ }
+ if maxFrames > 0 && len(valid) > maxFrames {
+ log.Printf("WARNING: %d valid frames exceeds batch limit (%d); keeping most recent %d frames to prevent OOM",
+ len(valid), maxFrames, maxFrames)
+ valid = valid[len(valid)-maxFrames:]
+ }
+ return valid
+}
+
var regenerateFullTimelapse = func(snapshotFiles []string, outputFileName string, archive bool) error {
if len(snapshotFiles) == 0 {
log.Println("No snapshots to generate timelapse.")
@@ -784,16 +818,8 @@ var regenerateFullTimelapse = func(snapshotFiles []string, outputFileName string
tempVideoPath := filepath.Join(config.AppConfig.DataDir, "temp_"+outputFileName)
finalVideoPath := filepath.Join(config.AppConfig.DataDir, outputFileName)
- // Validate files with a fast stat check — no FFmpeg call per frame.
- var validSnapshots []string
- for _, snapshot := range snapshotFiles {
- info, err := os.Stat(snapshot)
- if err != nil || info.Size() == 0 {
- log.Printf("Skipping missing or empty snapshot: %s", snapshot)
- continue
- }
- validSnapshots = append(validSnapshots, snapshot)
- }
+ maxFrames := settings.GetInt("video.max_batch_frames", defaultMaxBatchFrames)
+ validSnapshots := prepareSnapshotsForBatch(snapshotFiles, maxFrames)
if len(validSnapshots) == 0 {
log.Printf("No valid snapshots found to generate timelapse %s.", outputFileName)
return nil
@@ -904,15 +930,16 @@ var CleanupSnapshots = func() {
corruptFiles := 0
for _, file := range allSnapshots {
- // Check for 0-byte files
+ // Remove files too small to be a real JPEG — these are placeholder responses
+ // saved during NVR outages before the minimum-size guard was applied.
info, err := os.Stat(file)
- if err == nil && info.Size() == 0 {
- log.Printf("Found zero-byte snapshot, deleting: %s", file)
+ if err == nil && info.Size() < minValidSnapshotBytes {
+ log.Printf("Found undersized snapshot (%d bytes), deleting: %s", info.Size(), file)
if err := os.Remove(file); err != nil {
- log.Printf("Warning: failed to remove zero-byte snapshot %s: %v", file, err)
+ log.Printf("Warning: failed to remove undersized snapshot %s: %v", file, err)
} else {
corruptFiles++
- continue // Don't process further
+ continue
}
}
diff --git a/pkg/services/video/video_test.go b/pkg/services/video/video_test.go
index 34c9e0d..c8cc97b 100644
--- a/pkg/services/video/video_test.go
+++ b/pkg/services/video/video_test.go
@@ -1,6 +1,7 @@
package video
import (
+ "bytes"
"fmt"
"os"
"path/filepath"
@@ -18,6 +19,11 @@ import (
"github.com/stretchr/testify/assert"
)
+// validSnapshotData returns a byte slice large enough to pass the minValidSnapshotBytes guard.
+func validSnapshotData() []byte {
+ return bytes.Repeat([]byte("J"), int(minValidSnapshotBytes)+100)
+}
+
func setupTest(t *testing.T) (string, func()) {
tempDir, err := os.MkdirTemp("", "video-test")
assert.NoError(t, err)
@@ -31,13 +37,13 @@ func setupTest(t *testing.T) (string, func()) {
settings.Set("video.daylight_end_hour", "24")
settings.Invalidate()
- // Create some dummy snapshot files
+ // Create snapshot files large enough to pass minValidSnapshotBytes
for i := 0; i < 5; i++ {
now := time.Now().Add(-time.Duration(i) * time.Hour)
snapshotDir := filepath.Join(config.AppConfig.SnapshotsDir, now.Format("2006-01"), now.Format("02"), now.Format("15"))
os.MkdirAll(snapshotDir, 0755)
dummyFile := filepath.Join(snapshotDir, now.Format("2006-01-02-15-04-05")+".jpg")
- os.WriteFile(dummyFile, []byte("dummy"), 0644)
+ os.WriteFile(dummyFile, validSnapshotData(), 0644)
}
return tempDir, func() {
@@ -76,13 +82,14 @@ func TestFilterSnapshots(t *testing.T) {
testTime := time.Date(2025, 12, 22, 12, 0, 0, 0, time.UTC)
// Create hourly snapshots for Dec 21, 22, 23
+ data := validSnapshotData()
for dayOffset := -1; dayOffset <= 1; dayOffset++ {
currentDay := testTime.AddDate(0, 0, dayOffset)
for hour := 0; hour < 24; hour++ {
tm := time.Date(currentDay.Year(), currentDay.Month(), currentDay.Day(), hour, 0, 0, 0, time.UTC)
snapshotDir := filepath.Join(config.AppConfig.SnapshotsDir, tm.Format("2006-01"), tm.Format("02"), tm.Format("15"))
os.MkdirAll(snapshotDir, 0755)
- os.WriteFile(filepath.Join(snapshotDir, tm.Format("2006-01-02-15-04-05")+".jpg"), []byte("dummy"), 0644)
+ os.WriteFile(filepath.Join(snapshotDir, tm.Format("2006-01-02-15-04-05")+".jpg"), data, 0644)
}
}
@@ -119,45 +126,51 @@ func TestCleanupSnapshots(t *testing.T) {
settings.Set("snapshot.retention_days", "10")
settings.Invalidate()
- // Create an old file that should be deleted
+ // Old file (past retention) should be deleted.
oldTime := time.Now().Add(-11 * 24 * time.Hour)
oldDir := filepath.Join(config.AppConfig.SnapshotsDir, oldTime.Format("2006-01"), oldTime.Format("02"), oldTime.Format("15"))
os.MkdirAll(oldDir, 0755)
oldFile := filepath.Join(oldDir, oldTime.Format("2006-01-02-15-04-05")+".jpg")
- os.WriteFile(oldFile, []byte("old"), 0644)
+ os.WriteFile(oldFile, validSnapshotData(), 0644)
- // Create a newer file that should be kept
+ // New, valid-sized file should be kept.
newTime := time.Now().Add(-5 * 24 * time.Hour)
newDir := filepath.Join(config.AppConfig.SnapshotsDir, newTime.Format("2006-01"), newTime.Format("02"), newTime.Format("15"))
os.MkdirAll(newDir, 0755)
newFile := filepath.Join(newDir, newTime.Format("2006-01-02-15-04-05")+".jpg")
- os.WriteFile(newFile, []byte("new"), 0644)
+ os.WriteFile(newFile, validSnapshotData(), 0644)
- // Create a malformed file that should be skipped and kept
+ // Malformed filename cannot be parsed — skipped (kept).
malformedFile := filepath.Join(config.AppConfig.SnapshotsDir, "malformed-file.jpg")
- os.WriteFile(malformedFile, []byte("malformed"), 0644)
+ os.WriteFile(malformedFile, validSnapshotData(), 0644)
- // Create a zero-byte file that should be deleted
+ // Zero-byte file should be deleted regardless of age.
zeroByteFile := filepath.Join(config.AppConfig.SnapshotsDir, "zero-byte.jpg")
os.WriteFile(zeroByteFile, []byte{}, 0644)
+ // Below-minimum-size file should also be deleted regardless of age.
+ tinyDir := filepath.Join(config.AppConfig.SnapshotsDir, newTime.Format("2006-01"), newTime.Format("02"), newTime.Format("15"))
+ os.MkdirAll(tinyDir, 0755)
+ tinyTime := newTime.Add(time.Minute)
+ tinyFile := filepath.Join(tinyDir, tinyTime.Format("2006-01-02-15-04-05")+".jpg")
+ os.WriteFile(tinyFile, bytes.Repeat([]byte("x"), int(minValidSnapshotBytes)-1), 0644)
+
CleanupSnapshots()
- // Assert old file is deleted
_, err := os.Stat(oldFile)
- assert.True(t, os.IsNotExist(err), "Old snapshot file should be deleted")
+ assert.True(t, os.IsNotExist(err), "old snapshot file should be deleted")
- // Assert new file still exists
_, err = os.Stat(newFile)
- assert.False(t, os.IsNotExist(err), "New snapshot file should not be deleted")
+ assert.False(t, os.IsNotExist(err), "new valid snapshot file should not be deleted")
- // Assert malformed file still exists
_, err = os.Stat(malformedFile)
- assert.False(t, os.IsNotExist(err), "Malformed snapshot file should not be deleted")
+ assert.False(t, os.IsNotExist(err), "malformed-name file should not be deleted (unparseable)")
- // Assert zero-byte file is deleted
_, err = os.Stat(zeroByteFile)
- assert.True(t, os.IsNotExist(err), "Zero-byte snapshot file should be deleted")
+ assert.True(t, os.IsNotExist(err), "zero-byte snapshot file should be deleted")
+
+ _, err = os.Stat(tinyFile)
+ assert.True(t, os.IsNotExist(err), "below-minimum-size snapshot file should be deleted")
}
func TestCreateVideoSegment_ErrorHandling(t *testing.T) {
@@ -176,51 +189,33 @@ func TestCreateVideoSegment_ErrorHandling(t *testing.T) {
err = createVideoSegment(zeroByteFile, segmentPath)
assert.Error(t, err)
- assert.Contains(t, err.Error(), "invalid snapshot file (not found or zero size)")
+ assert.Contains(t, err.Error(), "below minimum size")
})
- t.Run("FFmpeg timeout", func(t *testing.T) {
- // This test is a bit tricky. We can't easily make ffmpeg hang,
- // but we can simulate the context timeout by using a very short timeout.
- // The principle is the same: the context should cancel the command.
-
- // Let's create a dummy ffmpeg command that sleeps for a while
- originalCreateVideoSegment := createVideoSegment
- defer func() { createVideoSegment = originalCreateVideoSegment }()
-
- // The test relies on a fake `ffmpeg` that is a shell script sleeping.
- // This is complex to set up in Go's test environment without external scripts.
- // An alternative is to trust the `context.WithTimeout` functionality
- // and that it's being used correctly, which our code change shows it is.
- // A simpler test is to check if the error contains "context deadline exceeded".
-
- // For this test, we can't guarantee a specific ffmpeg command will hang.
- // Instead, we will assume that if we provide a non-existent file, ffmpeg will error out,
- // but the test is for the timeout.
- // A better approach would be to mock exec.Command, but that's a larger refactor.
-
- // Let's stick to a conceptual test: ensure the error for a failing command is correct.
- // A true timeout test is more of an integration test.
- snapshotFile := filepath.Join(tempDir, "good_snapshot.jpg")
- os.WriteFile(snapshotFile, []byte("dummy-data-so-its-not-zero"), 0644)
+ t.Run("Below-minimum-size file", func(t *testing.T) {
+ tinyFile := filepath.Join(tempDir, "tiny_snapshot.jpg")
+ err := os.WriteFile(tinyFile, bytes.Repeat([]byte("x"), int(minValidSnapshotBytes)-1), 0644)
+ assert.NoError(t, err)
- segmentPath := filepath.Join(tempDir, "timeout_segment.webm")
+ segmentPath := filepath.Join(tempDir, "tiny_segment.webm")
+ err = createVideoSegment(tinyFile, segmentPath)
- // Let's assume for this test we can replace the ffmpeg command.
- // Since we can't do that easily, we'll test the principle.
- // The error from a timeout is `context deadline exceeded`.
+ assert.Error(t, err)
+ assert.Contains(t, err.Error(), "below minimum size")
+ })
- // Let's check our logging part of the fix.
- // If ffmpeg fails, we should get a descriptive log.
+ t.Run("FFmpeg rejects non-JPEG content above size threshold", func(t *testing.T) {
+ // Write enough bytes to pass the size guard but with non-JPEG content.
+ // This exercises the FFmpeg error path after the size check passes.
badSnapshot := filepath.Join(tempDir, "bad_snapshot.jpg")
- // Writing non-jpeg data to cause an error
- os.WriteFile(badSnapshot, []byte("this is not a jpeg"), 0644)
+ os.WriteFile(badSnapshot, bytes.Repeat([]byte("this is not a jpeg "), int(minValidSnapshotBytes)/19+1), 0644)
+ segmentPath := filepath.Join(tempDir, "bad_segment.webm")
err := createVideoSegment(badSnapshot, segmentPath)
assert.Error(t, err)
assert.Contains(t, err.Error(), "ffmpeg (create segment) execution failed")
- // Check that the error was written to the DB log
+ // Confirm the error was written to the DB log.
today := time.Now().Format("2006-01-02")
logContent, readErr := database.GetFFmpegLogContent(today)
assert.NoError(t, readErr)
@@ -496,12 +491,13 @@ func TestCalendarWeekMonday(t *testing.T) {
func setupGalleryFiles(t *testing.T, galleryDir string, start time.Time, days int) {
t.Helper()
os.MkdirAll(galleryDir, 0755)
+ data := validSnapshotData()
for d := 0; d < days; d++ {
day := start.AddDate(0, 0, d)
for hour := 0; hour < 24; hour++ {
tm := time.Date(day.Year(), day.Month(), day.Day(), hour, 0, 0, 0, time.UTC)
name := tm.Format("2006-01-02-15") + ".jpg"
- os.WriteFile(filepath.Join(galleryDir, name), []byte("g"), 0644)
+ os.WriteFile(filepath.Join(galleryDir, name), data, 0644)
}
}
}
@@ -767,3 +763,97 @@ func TestCleanOldVideos_Yearly(t *testing.T) {
assert.Contains(t, remaining, filepath.Join(tempDir, "timelapse_year_2026.webm"))
assert.Contains(t, remaining, filepath.Join(tempDir, "timelapse_year_2025.webm"))
}
+
+// --- prepareSnapshotsForBatch tests ---
+
+func makeSnapshotFile(t *testing.T, dir, name string, size int) string {
+ t.Helper()
+ path := filepath.Join(dir, name)
+ os.WriteFile(path, bytes.Repeat([]byte("J"), size), 0644)
+ return path
+}
+
+func TestPrepareSnapshotsForBatch_FiltersSmallFiles(t *testing.T) {
+ dir := t.TempDir()
+
+ valid1 := makeSnapshotFile(t, dir, "valid1.jpg", int(minValidSnapshotBytes))
+ valid2 := makeSnapshotFile(t, dir, "valid2.jpg", int(minValidSnapshotBytes)+500)
+ _ = makeSnapshotFile(t, dir, "tiny.jpg", int(minValidSnapshotBytes)-1)
+ _ = makeSnapshotFile(t, dir, "zero.jpg", 0)
+
+ result := prepareSnapshotsForBatch([]string{valid1, valid2,
+ filepath.Join(dir, "tiny.jpg"),
+ filepath.Join(dir, "zero.jpg"),
+ filepath.Join(dir, "missing.jpg"),
+ }, 0)
+
+ assert.Equal(t, []string{valid1, valid2}, result, "only files at or above minValidSnapshotBytes should pass")
+}
+
+func TestPrepareSnapshotsForBatch_CapsAtMaxFrames(t *testing.T) {
+ dir := t.TempDir()
+
+ var files []string
+ for i := 0; i < 10; i++ {
+ f := makeSnapshotFile(t, dir, fmt.Sprintf("snap%02d.jpg", i), int(minValidSnapshotBytes)+i)
+ files = append(files, f)
+ }
+
+ result := prepareSnapshotsForBatch(files, 5)
+
+ assert.Len(t, result, 5, "result should be capped at maxFrames")
+ // Should keep the most recent (last) 5
+ assert.Equal(t, files[5:], result, "should keep the most recent frames when capping")
+}
+
+func TestPrepareSnapshotsForBatch_NoCap(t *testing.T) {
+ dir := t.TempDir()
+
+ var files []string
+ for i := 0; i < 8; i++ {
+ f := makeSnapshotFile(t, dir, fmt.Sprintf("snap%02d.jpg", i), int(minValidSnapshotBytes)+i)
+ files = append(files, f)
+ }
+
+ result := prepareSnapshotsForBatch(files, 0)
+ assert.Len(t, result, 8, "maxFrames=0 disables the cap")
+}
+
+func TestPrepareSnapshotsForBatch_EmptyInput(t *testing.T) {
+ result := prepareSnapshotsForBatch(nil, 100)
+ assert.Empty(t, result)
+}
+
+// --- buildConcatList size filter test ---
+
+func TestBuildConcatList_FiltersSmallFiles(t *testing.T) {
+ _, cleanup := setupTest(t)
+ defer cleanup()
+
+ dir := config.AppConfig.DataDir
+ validFile := makeSnapshotFile(t, dir, "v.jpg", int(minValidSnapshotBytes))
+ _ = makeSnapshotFile(t, dir, "tiny.jpg", int(minValidSnapshotBytes)-1)
+ missing := filepath.Join(dir, "missing.jpg")
+
+ path, err := buildConcatList("test", []string{validFile, filepath.Join(dir, "tiny.jpg"), missing})
+ assert.NoError(t, err)
+ defer os.Remove(path)
+
+ content, err := os.ReadFile(path)
+ assert.NoError(t, err)
+ assert.Contains(t, string(content), filepath.ToSlash(validFile), "valid file must appear in concat list")
+ assert.NotContains(t, string(content), "tiny.jpg", "below-min file must not appear in concat list")
+ assert.NotContains(t, string(content), "missing.jpg", "missing file must not appear in concat list")
+}
+
+func TestBuildConcatList_AllInvalid(t *testing.T) {
+ _, cleanup := setupTest(t)
+ defer cleanup()
+
+ dir := config.AppConfig.DataDir
+ _ = makeSnapshotFile(t, dir, "tiny.jpg", int(minValidSnapshotBytes)-1)
+
+ _, err := buildConcatList("test", []string{filepath.Join(dir, "tiny.jpg")})
+ assert.Error(t, err, "all-invalid input should return an error")
+ assert.Contains(t, err.Error(), "no valid snapshots")
+}