16 KiB
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+ffmpegpackage (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 upstarts a working app;GET /healthreturns 200.- Unauthenticated requests redirect to
/login. - Admin user created on first run from
ADMIN_USER/ADMIN_PASSenv vars. /admin/userslets admin add and delete users.- Footage root is populated from
FOOTAGE_ROOTenv 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— modulegithub.com/domagojzecevic/cammonitor, Go 1.22cmd/server/main.go—main(): callconfig.Load(),db.Open(),web.NewRouter(),http.ListenAndServe()internal/config/config.go—Configstruct fields:ListenAddr,FootageRoot,DBPath,AdminUser,AdminPass,SessionTTL,ScanInterval; loaded from env vars with defaultsinternal/web/router.go—NewRouter(cfg, db, idx)returnschi.Router; mounts/healthreturning{"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: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 varsREADME.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, runsmigrate()migrate()— idempotentCREATE TABLE IF NOT EXISTSforusersandsessions- Schema:
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:Storestruct wrapping*sql.DBEnsureAdmin(user, pass string)— creates admin if no users exist (called at startup)CreateUser(username, password string, isAdmin bool) errorDeleteUser(id int64) errorListUsers() ([]User, error)Authenticate(username, password string) (*User, error)— bcrypt compareCreateSession(userID int64, ttl time.Duration) (token string, err error)— crypto/rand tokenGetSession(token string) (*Session, error)— checks expiryDeleteSession(token string) errorPurgeExpiredSessions()— called periodically from main
-
internal/auth/middleware.go:RequireAuth(store *Store) func(http.Handler) http.Handler— readssessioncookie, validates, injects*Userinto context; redirects to/loginon failureRequireAdmin(next http.Handler) http.Handler— checksis_adminfrom context; returns 403
-
internal/auth/handler.go:GET /login— render login formPOST /login— authenticate, setsessioncookie, redirect to/POST /logout— delete session, clear cookie, redirect to/loginGET /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 asYYYYMMDD, walksimages/andrecord/sub-dirsparseImageFilename(name string) (ImageFile, error)— extracts timestamp fromA<YYYYMMDD><HHMMSS><seq>.jpgparseVideoFilename(name string) (VideoFile, error)— extracts start/end time fromA<YYYYMMDD>_<HHMMSS>_<HHMMSS>.265
-
internal/footage/index.go:Indexstruct:sync.RWMutex+map[string]*DayEntryNewIndex(root string, interval time.Duration) *Index— starts background rescan goroutineDayList() []string— sorted date strings descending (newest first)Day(date string) (*DayEntry, bool)Close()— stop background goroutine
-
Types:
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-900body,bg-slate-800sidebar/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<dialog>or CSS toggle; bottom tab bar (fixed bottom-0) with Images / Videos icons - Go template block slots:
{{block "title" .}},{{block "content" .}}
- Tailwind CSS CDN link (
-
internal/web/templates/day.html:- Extends base; shows date heading
- Two tab buttons: "Images (N)" and "Videos (N)" — links to
/day/{date}/imagesand/day/{date}/videos - Active tab highlighted
-
internal/web/handler.go(inwebpackage):HandleIndex— redirect to most recent day's imagesHandleDayOverview— renderday.htmlwith counts from index
-
Sidebar data — base template receives
DayList []stringfrom a context middleware that injects the index; days grouped byYYYY-MMmonth 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:Cachestruct:sync.Mutex+map[string][]byte(bounded, max 500 entries, simple FIFO eviction)Thumbnail(absPath string) ([]byte, error)— decode JPEG, resize to 160×90 usinggolang.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:Handlerstruct:cfg *config.Config,idx *footage.Index,thumbs *CacheServeRaw(w, r)—http.ServeFilefor/raw/image/{relpath...}; validates path is within footage rootServeThumb(w, r)— serve fromCacheor generate;Cache-Control: max-age=3600ServePage(w, r)— renderimages.html; pass image list +idxquery param
-
internal/web/templates/images.html:- Extends base
- Thumbnail strip (top, fixed height
h-28): horizontal scroll,<img>per image, active highlighted with indigo ring, click to navigate - Main viewer:
<img>tag with current image URL at full container width;object-fit: contain - Arrows: left/right
<button>overlays on the image; also listen forkeydownleft/right arrow keys - Current image URL:
/raw/image/{relpath}, thumbnail:/thumb/image/{relpath} - Navigation updates
?idx=Nvia vanilla JShistory.pushState(no full reload)
Routes added to router:
GET /raw/image/*
GET /thumb/image/*
GET /day/{date}/images
Tests: internal/image — thumbnail generated from test JPEG fits 160×90 bounds; raw handler returns correct Content-Type; path traversal (../) rejected with 400.
Phase 6 — T-006: Video browser
New files:
-
internal/video/stream.go:Stream(w http.ResponseWriter, r *http.Request, absPath string):- Set
Content-Type: video/mp4,Cache-Control: no-store,X-Content-Type-Options: nosniff - Spawn:
ffmpeg -loglevel error -i <absPath> -c:v copy -movflags frag_keyframe+empty_moov -f mp4 pipe:1 - Copy ffmpeg stdout →
win a goroutine - On
r.Context().Done()(client disconnect), kill the ffmpeg process - Log ffmpeg stderr only on non-zero exit
- Set
-
internal/video/thumb.go:Cachestruct:sync.Mutex+map[string][]byte(max 200 entries, FIFO eviction)Thumbnail(absPath string) ([]byte, error):ffmpeg -loglevel error -ss 0 -i <absPath> -vframes 1 -vf scale=160:90:force_original_aspect_ratio=decrease -f image2 -vcodec mjpeg pipe:1- Cache result
-
internal/video/handler.go:Handlerstruct:cfg,idx,thumbs *CacheServeStream(w, r)— delegates tostream.Stream; validates path within footage rootServeThumb(w, r)— serve fromCacheor generateServePage(w, r)— rendervideos.htmlwith video list +idxquery param
-
internal/web/templates/videos.html:- Extends base
- Thumbnail strip (top, fixed
h-28): same layout as images strip; each thumbnail shows clip duration overlay (e.g.00:14) - Main viewer:
<video controls autoplay>element;srcset to/stream/video/{relpath} - Arrows: same left/right pattern as images; keyboard
←/→ - On arrow navigation: update
<video src>+ call.load()+.play()via JS; update strip highlight;history.pushState
Routes added to router:
GET /stream/video/*
GET /thumb/video/*
GET /day/{date}/videos
Tests: internal/video — thumbnail cache stores and returns bytes; stream handler (mocked ffmpeg) returns correct headers; path traversal rejected; ffmpeg process killed on context cancel.
Note on test ffmpeg mock: use an
ffmpegPathfield on handlers defaulting to"ffmpeg"but overridable in tests to a small shell script that writes known bytes.
Environment Variables Reference
| Variable | Default | Description |
|---|---|---|
LISTEN_ADDR |
:8080 |
TCP address to listen on |
FOOTAGE_ROOT |
/footage |
Path to camera footage directory |
DB_PATH |
/data/cammonitor.db |
SQLite database file path |
ADMIN_USER |
admin |
Bootstrap admin username |
ADMIN_PASS |
changeme |
Bootstrap admin password |
SESSION_TTL |
24h |
Session cookie lifetime |
SCAN_INTERVAL |
5m |
Footage re-scan interval |
Go Dependencies
github.com/go-chi/chi/v5 v5.x # HTTP router
modernc.org/sqlite v1.x # Pure-Go SQLite (no CGO)
golang.org/x/crypto latest # bcrypt
golang.org/x/image latest # JPEG resize (image/draw)
No ORM. Use database/sql directly with named queries.
Validation
Run after every task:
go fmt ./...
go vet ./...
go test ./...
go test -race ./...