You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 

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 + 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.gomain(): call config.Load(), db.Open(), web.NewRouter(), http.ListenAndServe()
  • internal/config/config.goConfig struct fields: ListenAddr, FootageRoot, DBPath, AdminUser, AdminPass, SessionTTL, ScanInterval; loaded from env vars with defaults
  • internal/web/router.goNewRouter(cfg, db, idx) returns chi.Router; mounts /health returning {"status":"ok"}
  • Dockerfile:
    FROM golang:1.25-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 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:
      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<YYYYMMDD><HHMMSS><seq>.jpg
    • parseVideoFilename(name string) (VideoFile, error) — extracts start/end time from A<YYYYMMDD>_<HHMMSS>_<HHMMSS>.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:

    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 <dialog> 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, <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 for keydown left/right arrow keys
    • Current image URL: /raw/image/{relpath}, thumbnail: /thumb/image/{relpath}
    • Navigation updates ?idx=N via vanilla JS history.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 → w in a goroutine
      • On r.Context().Done() (client disconnect), kill the ffmpeg process
      • Log ffmpeg stderr only on non-zero exit
  • internal/video/thumb.go:

    • Cache struct: 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:

    • Handler struct: cfg, idx, thumbs *Cache
    • ServeStream(w, r) — delegates to stream.Stream; validates path within footage root
    • ServeThumb(w, r) — serve from Cache or generate
    • ServePage(w, r) — render videos.html with video list + idx query 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; src set 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 ffmpegPath field 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 ./...