# Plan Status: **ready_for_implement** Goal: Build CamMonitor — a self-hosted security camera footage viewer. Go backend, dark responsive UI, Docker Compose deployment, minimal CPU use on Intel Atom. --- ## Constraints - No CGO. Use `modernc.org/sqlite` (pure Go) so the binary cross-compiles cleanly. - No Node.js build step. Tailwind CSS loaded from CDN only. - Minimal transcoding. Videos are remuxed (stream copy), never re-encoded. - Thumbnails cached in memory only — no writes to the footage tree. - Target image: `debian:bookworm-slim` + `ffmpeg` package (needed for video stream + thumbnails). --- ## Scope | Task | What is delivered | |------|-------------------| | T-001 | Runnable skeleton: Go module, chi router, health endpoint, Dockerfile, docker-compose | | T-002 | Auth: login/logout, session cookies, SQLite users + sessions, admin user-management | | T-003 | Footage scanner: in-memory index of days/images/videos, periodic rescan | | T-004 | UI shell: dark Tailwind layout, sidebar (desktop), mobile drawer, day overview page | | T-005 | Image browser: raw serve, thumbnail cache, strip, full viewer, arrow nav | | T-006 | Video browser: ffmpeg remux stream, thumbnail extraction, strip, HTML5 player, arrow nav | --- ## Repository layout ``` CamMonitor/ ├── cmd/ │ └── server/ │ └── main.go # Entry point: load config, init DB, start scanner, run server ├── internal/ │ ├── config/ │ │ └── config.go # Read env vars into Config struct │ ├── db/ │ │ └── db.go # Open SQLite, run schema migrations │ ├── auth/ │ │ ├── store.go # User + session CRUD │ │ ├── handler.go # /login, /logout, /admin/users handlers │ │ └── middleware.go # RequireAuth, RequireAdmin middleware │ ├── footage/ │ │ ├── scanner.go # Walk FOOTAGE_ROOT, parse filenames │ │ └── index.go # In-memory index, DayList(), DayEntry() │ ├── image/ │ │ ├── handler.go # /raw/image/{path}, /thumb/image/{path}, /day/{date}/images │ │ └── thumb.go # LRU cache, JPEG resize (golang.org/x/image/draw) │ ├── video/ │ │ ├── handler.go # /stream/video/{path}, /thumb/video/{path}, /day/{date}/videos │ │ ├── stream.go # ffmpeg remux: .265 → fragmented MP4 pipe │ │ └── thumb.go # ffmpeg first-frame extract, LRU cache │ └── web/ │ ├── router.go # Mount all sub-routers, static files │ └── templates/ │ ├── base.html # Dark shell: header, sidebar, mobile nav, slot for content │ ├── login.html # Login form │ ├── admin_users.html # User list, add/delete form │ ├── day.html # Day overview: tabs (Images N / Videos N) │ ├── images.html # Thumbnail strip + full-image viewer │ └── videos.html # Thumbnail strip + HTML5 player ├── testdata/ │ └── footage/ # Minimal fixture tree for unit tests │ └── 20260101/ │ ├── images/A26010112000001.jpg │ └── record/A260101_120000_120015.265 ├── Dockerfile ├── docker-compose.yml ├── .env.example ├── go.mod └── README.md ``` --- ## Acceptance Criteria - `docker compose up` starts a working app; `GET /health` returns 200. - Unauthenticated requests redirect to `/login`. - Admin user created on first run from `ADMIN_USER` / `ADMIN_PASS` env vars. - `/admin/users` lets admin add and delete users. - Footage root is populated from `FOOTAGE_ROOT` env var / volume mount. - Images load at original quality directly from disk (no processing in the hot path). - Selecting a video in the browser starts streaming within ~2 s. - Thumbnail strip is visible and horizontally scrollable on both desktop and mobile. - Arrow nav (on-screen buttons and keyboard `←`/`→`) cycles through items on the same day. - Desktop (≥768 px): sidebar lists available days grouped by month. - Mobile (<768 px): sidebar replaced by hamburger drawer + bottom tab bar. - `go fmt ./...`, `go vet ./...`, `go test ./...` pass cleanly. - `go test -race ./...` reports no data races. --- ## Implementation Phases ### Phase 1 — T-001: Project scaffold **New files:** - `go.mod` — module `github.com/domagojzecevic/cammonitor`, Go 1.22 - `cmd/server/main.go` — `main()`: call `config.Load()`, `db.Open()`, `web.NewRouter()`, `http.ListenAndServe()` - `internal/config/config.go` — `Config` struct fields: `ListenAddr`, `FootageRoot`, `DBPath`, `AdminUser`, `AdminPass`, `SessionTTL`, `ScanInterval`; loaded from env vars with defaults - `internal/web/router.go` — `NewRouter(cfg, db, idx)` returns `chi.Router`; mounts `/health` returning `{"status":"ok"}` - `Dockerfile`: ``` FROM golang:1.22-bookworm AS builder WORKDIR /src COPY go.mod go.sum ./ RUN go mod download COPY . . RUN CGO_ENABLED=0 go build -o /cammonitor ./cmd/server FROM debian:bookworm-slim RUN apt-get update && apt-get install -y --no-install-recommends ffmpeg ca-certificates && rm -rf /var/lib/apt/lists/* COPY --from=builder /cammonitor /usr/local/bin/cammonitor ENTRYPOINT ["cammonitor"] ``` - `docker-compose.yml`: ```yaml services: app: build: . ports: - "${PORT:-8080}:8080" environment: FOOTAGE_ROOT: /footage DB_PATH: /data/cammonitor.db ADMIN_USER: ${ADMIN_USER:-admin} ADMIN_PASS: ${ADMIN_PASS:-changeme} SESSION_TTL: ${SESSION_TTL:-24h} SCAN_INTERVAL: ${SCAN_INTERVAL:-5m} volumes: - ${FOOTAGE_ROOT:-./testdata/footage}:/footage:ro - cammonitor_data:/data volumes: cammonitor_data: ``` - `.env.example` — documents all env vars - `README.md` — quick-start, env var reference, footage directory format **Tests:** `internal/web` — GET /health returns 200 and `{"status":"ok"}`. --- ### Phase 2 — T-002: Auth **New files:** - `internal/db/db.go`: - `Open(path string) (*sql.DB, error)` — opens SQLite with WAL mode, runs `migrate()` - `migrate()` — idempotent `CREATE TABLE IF NOT EXISTS` for `users` and `sessions` - Schema: ```sql CREATE TABLE IF NOT EXISTS users ( id INTEGER PRIMARY KEY AUTOINCREMENT, username TEXT NOT NULL UNIQUE, password_hash TEXT NOT NULL, is_admin INTEGER NOT NULL DEFAULT 0, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE IF NOT EXISTS sessions ( token TEXT PRIMARY KEY, user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, expires_at DATETIME NOT NULL ); ``` - `internal/auth/store.go`: - `Store` struct wrapping `*sql.DB` - `EnsureAdmin(user, pass string)` — creates admin if no users exist (called at startup) - `CreateUser(username, password string, isAdmin bool) error` - `DeleteUser(id int64) error` - `ListUsers() ([]User, error)` - `Authenticate(username, password string) (*User, error)` — bcrypt compare - `CreateSession(userID int64, ttl time.Duration) (token string, err error)` — crypto/rand token - `GetSession(token string) (*Session, error)` — checks expiry - `DeleteSession(token string) error` - `PurgeExpiredSessions()` — called periodically from main - `internal/auth/middleware.go`: - `RequireAuth(store *Store) func(http.Handler) http.Handler` — reads `session` cookie, validates, injects `*User` into context; redirects to `/login` on failure - `RequireAdmin(next http.Handler) http.Handler` — checks `is_admin` from context; returns 403 - `internal/auth/handler.go`: - `GET /login` — render login form - `POST /login` — authenticate, set `session` cookie, redirect to `/` - `POST /logout` — delete session, clear cookie, redirect to `/login` - `GET /admin/users` — list users (admin only) - `POST /admin/users` — create user (admin only) - `POST /admin/users/{id}/delete` — delete user (admin only) - `internal/web/templates/login.html` — dark-themed form, username + password, submit - `internal/web/templates/admin_users.html` — user table, add-user form, delete button per row **Tests:** `internal/auth` — happy-path login, wrong password, expired session, admin-only gate. --- ### Phase 3 — T-003: Footage scanner **New files:** - `internal/footage/scanner.go`: - `Scan(root string) ([]DayEntry, error)` — walks root, parses directory names as `YYYYMMDD`, walks `images/` and `record/` sub-dirs - `parseImageFilename(name string) (ImageFile, error)` — extracts timestamp from `A.jpg` - `parseVideoFilename(name string) (VideoFile, error)` — extracts start/end time from `A__.265` - `internal/footage/index.go`: - `Index` struct: `sync.RWMutex` + `map[string]*DayEntry` - `NewIndex(root string, interval time.Duration) *Index` — starts background rescan goroutine - `DayList() []string` — sorted date strings descending (newest first) - `Day(date string) (*DayEntry, bool)` - `Close()` — stop background goroutine - Types: ```go type DayEntry struct { Date string // "20260518" Images []ImageFile Videos []VideoFile } type ImageFile struct { RelPath string // relative to FOOTAGE_ROOT Filename string Timestamp time.Time } type VideoFile struct { RelPath string Filename string StartTime time.Time EndTime time.Time Duration time.Duration } ``` - `testdata/footage/` — fixture directory with a couple of synthetic filenames (empty files are fine for scanner tests) **Tests:** `internal/footage` — scan fixture dir, assert day list, assert image/video counts and parsed timestamps; race test for concurrent `DayList()` + background rescan. --- ### Phase 4 — T-004: UI shell & day navigation **New files:** - `internal/web/templates/base.html`: - Tailwind CSS CDN link (`https://cdn.tailwindcss.com`) - Dark palette: `bg-slate-900` body, `bg-slate-800` sidebar/header, `text-slate-100` - Header: app name "CamMonitor", logged-in username, logout button - Desktop (≥768 px): fixed left sidebar `w-56`, main content fills remainder - Mobile (<768 px): sidebar hidden (`hidden md:block`); hamburger button opens a full-height drawer via `` or CSS toggle; bottom tab bar (`fixed bottom-0`) with Images / Videos icons - Go template block slots: `{{block "title" .}}`, `{{block "content" .}}` - `internal/web/templates/day.html`: - Extends base; shows date heading - Two tab buttons: "Images (N)" and "Videos (N)" — links to `/day/{date}/images` and `/day/{date}/videos` - Active tab highlighted - `internal/web/handler.go` (in `web` package): - `HandleIndex` — redirect to most recent day's images - `HandleDayOverview` — render `day.html` with counts from index - **Sidebar data** — base template receives `DayList []string` from a context middleware that injects the index; days grouped by `YYYY-MM` month heading **Tests:** render `base.html` + `day.html` with mock data, assert key HTML elements present (sidebar date entry, tab links). --- ### Phase 5 — T-005: Image browser **New files:** - `internal/image/thumb.go`: - `Cache` struct: `sync.Mutex` + `map[string][]byte` (bounded, max 500 entries, simple FIFO eviction) - `Thumbnail(absPath string) ([]byte, error)` — decode JPEG, resize to 160×90 using `golang.org/x/image/draw.BiLinear`, encode to JPEG quality 75, store in cache - Uses stdlib `image/jpeg` + `golang.org/x/image/draw` (no external resize library needed) - `internal/image/handler.go`: - `Handler` struct: `cfg *config.Config`, `idx *footage.Index`, `thumbs *Cache` - `ServeRaw(w, r)` — `http.ServeFile` for `/raw/image/{relpath...}`; validates path is within footage root - `ServeThumb(w, r)` — serve from `Cache` or generate; `Cache-Control: max-age=3600` - `ServePage(w, r)` — render `images.html`; pass image list + `idx` query param - `internal/web/templates/images.html`: - Extends base - **Thumbnail strip** (top, fixed height `h-28`): horizontal scroll, `` per image, active highlighted with indigo ring, click to navigate - **Main viewer**: `` tag with current image URL at full container width; `object-fit: contain` - **Arrows**: left/right `