Browse Source

feat(scaffold): add runnable CamMonitor health service

main
Domagoj Zecevic 3 days ago
parent
commit
d4318c71b9
  1. 58
      .ai/HANDOFF.md
  2. 382
      .ai/PLAN.md
  3. 47
      .ai/REVIEW.md
  4. 28
      .ai/TASKS.md
  5. 7
      .dockerignore
  6. 6
      .env.example
  7. 2
      .gitignore
  8. 17
      Dockerfile
  9. 51
      README.md
  10. 155
      ROADMAP.md
  11. 30
      cmd/server/main.go
  12. 18
      docker-compose.yml
  13. 20
      go.mod
  14. 45
      go.sum
  15. 60
      internal/config/config.go
  16. 38
      internal/db/db.go
  17. 22
      internal/web/router.go
  18. 30
      internal/web/router_test.go
  19. 1
      testdata/footage/.gitkeep

58
.ai/HANDOFF.md

@ -0,0 +1,58 @@
# HANDOFF
Append-only role handoff log. Each role adds one entry when its step is complete.
---
### T-001 — implement — 2026-06-18T08:20:22Z
| Field | Value |
|-------|-------|
| Agent | codex |
| Summary | Committed T-001 scaffold after review approval. |
| Files Changed | .ai/TASKS.md, .ai/HANDOFF.md |
| Validation | n/a (commit_task) |
| Commit | `pending feat(scaffold): add runnable CamMonitor health service` |
| Next Role | none |
---
### T-001 — review — 2026-06-18T10:20:00Z
| Field | Value |
|-------|-------|
| Agent | claude |
| Summary | Reviewed T-001 project scaffold; all acceptance criteria met; four nit-level findings, none blocking. |
| Files Changed | .ai/REVIEW.md, .ai/TASKS.md, .ai/HANDOFF.md |
| Validation | `go fmt ./...`, `go vet ./...`, `go test ./...`, `go test -race ./...` all pass; E2E evidence from implementer accepted. |
| Verdict | PASS_WITH_NOTES |
| Blocking Findings | none |
| Next Role | implement |
---
### T-001 — implement — 2026-06-18T08:12:05Z
| Field | Value |
|-------|-------|
| Agent | codex |
| Summary | Added the runnable Go scaffold with environment config, SQLite startup, chi health endpoint, Docker Compose deployment, and project documentation. |
| Files Changed | .dockerignore, .env.example, .gitignore, Dockerfile, README.md, cmd/server/main.go, docker-compose.yml, go.mod, go.sum, internal/config/config.go, internal/db/db.go, internal/web/router.go, internal/web/router_test.go, testdata/footage/.gitkeep, .ai/TASKS.md, .ai/HANDOFF.md |
| Validation | `go fmt ./...` passed; `go vet ./...` passed; `go test ./...` passed; `go test -race ./...` passed; `docker compose build` passed; `curl -i http://127.0.0.1:18080/health` returned HTTP 200 and `{"status":"ok"}` |
| Commit | `feat(scaffold): add runnable CamMonitor health service` |
| Next Role | review |
---
### CYCLE-START — plan — 2026-06-18T00:00:00Z
| Field | Value |
|-------|-------|
| Agent | claude |
| Summary | Initial planning complete for CamMonitor: 6 tasks covering scaffold, auth, footage scanner, UI shell, image browser, and video browser. All tasks moved to ready_for_implement. |
| Files Changed | ROADMAP.md, .ai/PLAN.md, .ai/TASKS.md, .ai/HANDOFF.md |
| Validation | n/a (plan role) |
| Commit | n/a (plan role) |
| Next Role | implement |
---

382
.ai/PLAN.md

@ -0,0 +1,382 @@
# 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<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:
```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 `<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 ./...
```

47
.ai/REVIEW.md

@ -0,0 +1,47 @@
# REVIEW
Append-only reviewer log. Each reviewed task gets one section.
---
## T-001 — Project scaffold
**Verdict:** PASS_WITH_NOTES
### Findings
| # | Severity | File | Description | Required fix? |
|---|----------|------|-------------|---------------|
| 1 | nit | `.env.example` | Missing `DB_PATH` and `LISTEN_ADDR` entries. The plan requires `.env.example` to document all env vars; the README does cover them but the example file operators copy is incomplete. | no |
| 2 | nit | `docker-compose.yml` | `LISTEN_ADDR` env var is not forwarded into the container; the port mapping hardcodes `8080` as the inner port. If an operator sets `LISTEN_ADDR` to something other than `:8080` only locally editing the compose file would fix the mismatch. | no |
| 3 | nit | `internal/db/db.go` | SQLite connection pool is not limited to a single open connection. Concurrent writes (introduced in T-002) risk `SQLITE_BUSY` / "database is locked" errors. Consider adding `database.SetMaxOpenConns(1)` before the health check in a future task. | no |
| 4 | nit | `README.md` | Contains extensive AI workflow boilerplate (workflow profiles, session commands, file map, etc.) that is unrelated to the application's end-user docs. The plan scope for README was quick-start, env var reference, and footage directory format — all of which are present. | no |
### Required fixes
None — all findings are nits. No blocking issues.
### Verification
**Steps performed:**
1. Read all T-001 files: `cmd/server/main.go`, `internal/config/config.go`, `internal/db/db.go`, `internal/web/router.go`, `internal/web/router_test.go`, `Dockerfile`, `docker-compose.yml`, `.env.example`, `go.mod`, `README.md`, `.dockerignore`.
2. Cross-checked implementation against `.ai/PLAN.md` Phase 1 scope.
3. Ran `go fmt ./...` — clean (no output).
4. Ran `go vet ./...` — clean.
5. Ran `go test ./...``internal/web` PASS, other packages report no test files (correct for T-001 scope).
6. Ran `go test -race ./...` — PASS, no data races.
7. `docker compose build` and `curl -i http://127.0.0.1:18080/health` verified by implementer evidence; both passed.
**Findings:**
- All acceptance criteria met: `docker compose build` succeeds (per evidence), `GET /health` returns 200 and `{"status":"ok"}` (covered by test + E2E evidence), `go vet ./...` passes.
- Health handler correctly sets `Content-Type: application/json` before writing the body.
- Router test covers both status code and JSON body parsing — adequate for T-001 scope.
- Multi-stage Dockerfile matches plan exactly (`golang:1.22-bookworm` builder → `debian:bookworm-slim` + ffmpeg runtime).
- `CGO_ENABLED=0` build flag is correct; aligns with the no-CGO constraint.
- `modernc.org/sqlite` (pure-Go) is the only SQLite driver imported — constraint satisfied.
**Risks:**
- SQLite connection pool (finding #3) will matter in T-002; surfaced early so the implementer can address it then.
- `.env.example` gaps (finding #1) are low-risk for a scaffold task but should be cleaned up before shipping.
---

28
.ai/TASKS.md

@ -0,0 +1,28 @@
# TASKS
Use this board to coordinate handoff between planner, implementer, and reviewer.
Status values:
- `in_planning`
- `ready_for_implement`
- `in_implementation`
- `ready_for_review`
- `in_review`
- `ready_to_commit`
- `changes_requested`
- `done`
Command expectations:
- planner moves tasks into `in_planning` and `ready_for_implement`
- implementer moves tasks into `in_implementation`, `ready_for_review`, and `done`, and resumes work from `changes_requested` and `ready_to_commit`
- reviewer moves tasks into `in_review`, `ready_to_commit`, or `changes_requested`
- `status_cycle` should report deterministic task status, current owner role, and next recommended action based on this board
| Task ID | Scope | Status | Acceptance Criteria | Evidence | Next Role |
| --- | --- | --- | --- | --- | --- |
| T-001 | Project scaffold: Go module, chi router, health endpoint, multi-stage Dockerfile (debian:slim + ffmpeg), docker-compose.yml, .env.example, README.md | done | `docker compose build` succeeds; `GET /health` returns 200; `go vet ./...` passes | `go fmt ./...`, `go vet ./...`, `go test ./...`, `go test -race ./...`, `docker compose build`, `curl -i http://127.0.0.1:18080/health` passed | none |
| T-002 | Auth: SQLite schema (users + sessions), bcrypt login/logout, session cookie middleware, first-run admin bootstrap, admin user-management page | ready_for_implement | Login page renders at `/login`; valid credentials set session cookie and redirect; invalid credentials return 401; admin can add/delete users at `/admin/users`; all non-login routes return 302 without cookie; `go test ./internal/auth/...` passes | n/a | implement |
| T-003 | Footage scanner: walk FOOTAGE_ROOT, build in-memory index (date → images + videos), filename parser, periodic rescan goroutine | ready_for_implement | Scanner correctly indexes fixture directory with known dates/files; `DayList()` returns sorted dates; `DayEntry` contains sorted images and videos; periodic rescan runs without data race (`go test -race ./internal/footage/...` passes) | n/a | implement |
| T-004 | UI shell & day navigation: dark Tailwind CSS base layout, sidebar (desktop) with date list grouped by month, mobile drawer + bottom tab bar, day overview page with Images/Videos tabs | ready_for_implement | Desktop layout shows sidebar at ≥768 px; mobile layout hides sidebar and shows drawer trigger at <768 px; day overview page renders images and videos tab counts; `go vet ./...` passes | n/a | implement |
| T-005 | Image browser: raw JPEG serving, in-memory LRU thumbnail cache (160×90), thumbnail strip, full-image viewer, arrow navigation (keyboard + on-screen), deep-linkable `?idx=N` | ready_for_implement | Thumbnail endpoint returns JPEG ≤30 KB for a test image; raw endpoint serves original file unchanged; arrow nav advances and wraps correctly; keyboard left/right works; strip highlights active item; `go test ./internal/image/...` passes | n/a | implement |
| T-006 | Video browser: on-demand ffmpeg remux stream (.265→MP4 via pipe), in-memory LRU video thumbnail (first-frame extract), thumbnail strip, HTML5 player, arrow navigation, deep-linkable `?idx=N` | ready_for_implement | Stream endpoint returns `Content-Type: video/mp4` and non-empty body for a test .265 file; thumbnail endpoint returns a JPEG; player page renders with `<video>` element; arrow nav works; ffmpeg process is killed on client disconnect; `go test ./internal/video/...` passes | n/a | implement |

7
.dockerignore

@ -0,0 +1,7 @@
.cache/
.git/
.ai/mcp-server.log
.ai/sessions.json
*.test
*.out
bin/

6
.env.example

@ -0,0 +1,6 @@
PORT=8080
FOOTAGE_ROOT=./testdata/footage
ADMIN_USER=admin
ADMIN_PASS=changeme
SESSION_TTL=24h
SCAN_INTERVAL=5m

2
.gitignore

@ -15,6 +15,7 @@ Thumbs.db
*.iml
# AI workflow runtime logs
.cache/
.ai/mcp-server.log
.ai/sessions.json
@ -28,4 +29,3 @@ bin/
*.test
*.out
vendor/

17
Dockerfile

@ -0,0 +1,17 @@
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"]

51
README.md

@ -2,11 +2,58 @@
## Overview
TODO: Describe this project.
CamMonitor is a self-hosted security camera footage viewer. This scaffold provides the Go HTTP server, a `/health` endpoint, environment-based configuration, SQLite startup, and Docker Compose deployment.
## Getting Started
TODO: Add setup instructions.
Copy the sample environment file and adjust it for your footage directory:
```bash
cp .env.example .env
```
Start the app with Docker Compose:
```bash
docker compose up --build
```
Verify the server is running:
```bash
curl http://localhost:8080/health
```
The response should be:
```json
{"status":"ok"}
```
## Configuration
| Variable | Default | Description |
|----------|---------|-------------|
| `PORT` | `8080` | Host port published by Docker Compose |
| `LISTEN_ADDR` | `:8080` | TCP address used by the Go server |
| `FOOTAGE_ROOT` | `/footage` | Camera footage directory inside the container |
| `DB_PATH` | `/data/cammonitor.db` | SQLite database path |
| `ADMIN_USER` | `admin` | Bootstrap admin username for the auth phase |
| `ADMIN_PASS` | `changeme` | Bootstrap admin password for the auth phase |
| `SESSION_TTL` | `24h` | Session lifetime |
| `SCAN_INTERVAL` | `5m` | Footage rescan interval |
## Footage Layout
Footage is mounted read-only at `/footage` in the container. The planned directory format is:
```text
YYYYMMDD/
images/
AYYMMDDHHMMSSNN.jpg
record/
AYYMMDD_HHMMSS_HHMMSS.265
```
## AI Workflow

155
ROADMAP.md

@ -1,29 +1,154 @@
# ROADMAP
Goal: define and deliver the scope for this cycle.
Goal: Build a self-hosted security camera footage viewer — Go backend, modern dark UI,
Docker Compose deployment, responsive for desktop and mobile.
Delete any unused example sections below. Only the Goal and one concrete priority are required.
---
## Priority 1
## Constraints
Objective: replace with objective.
- Target hardware: Intel Atom (low CPU budget). Minimise transcoding at all costs.
- Video files are raw H.265 bitstreams (`.265`). Browsers cannot play them natively.
- Remuxing (copy stream into MP4 container) is acceptable — no re-encode.
- No Node.js build step. Tailwind CSS loaded from CDN.
- Replace with planned outcome.
---
## Examples
## Footage directory layout (read-only mount)
These example sections are optional illustrations, not required structure.
```
<FOOTAGE_ROOT>/
YYYYMMDD/
images/ A<YYYYMMDD><HHMMSS><seq>.jpg
record/ A<YYYYMMDD>_<HHMMSS>_<HHMMSS>.265
```
<!-- Example: remove or replace this section -->
## Priority 2
- `FOOTAGE_ROOT` is configured as an env var / volume in `docker-compose.yml`.
- The app must not write into the footage tree (thumbnails cached in memory).
Objective: optional second objective.
---
- Replace with optional planned outcome.
## Priority 1 — Project scaffold
<!-- Example: remove or replace this section -->
## Priority 3
Objective: Establish a working Go module, Dockerfile, and docker-compose skeleton so
every subsequent task has a solid base to build on.
Objective: optional third objective.
- Go module `github.com/domagojzecevic/cammonitor` (or local path).
- `cmd/server/main.go` entry point, `chi` router wired up, static health endpoint.
- Multi-stage `Dockerfile` (builder → `gcr.io/distroless/static` or `debian:slim` for ffmpeg).
- `docker-compose.yml` with:
- `app` service (Go binary)
- `FOOTAGE_ROOT` volume bind-mount configurable via `.env`
- `DB_PATH` for SQLite file
- Port mapping (default `8080`)
- `README.md` with quick-start instructions.
- Replace with optional planned outcome.
---
## Priority 2 — Auth (multi-user, SQLite)
Objective: Secure the app behind a login wall with session cookies and an admin
user-management page.
- SQLite schema: `users` table (id, username, bcrypt_password_hash, is_admin, created_at).
- SQLite schema: `sessions` table (token, user_id, expires_at).
- First-run bootstrap: if no users exist, create an admin user from env vars
(`ADMIN_USER`, `ADMIN_PASS`).
- Login page (`/login`): username + password form, sets `session` cookie on success.
- Logout endpoint (`/logout`).
- Auth middleware protecting all non-login routes.
- Admin user-management page (`/admin/users`): list, add, delete users (admin only).
- Session expiry configurable via env var (`SESSION_TTL`, default 24 h).
---
## Priority 3 — Footage scanner
Objective: Efficiently list available days, images, and videos from the footage tree.
- On startup and periodically (configurable interval, default 5 min), scan `FOOTAGE_ROOT`.
- Build an in-memory index: `map[date]DayEntry` where `DayEntry` holds sorted slices of
image paths and video paths.
- Expose a simple internal API used by HTTP handlers to query the index.
- Parse filenames to extract timestamps for display and sorting.
- Handle missing `images/` or `record/` sub-directories gracefully.
---
## Priority 4 — Image browser
Objective: Browse JPEG images for a selected day with thumbnail strip and arrow navigation.
- Route `/day/{date}/images` — lists all images for the day.
- Thumbnail endpoint `/thumb/image/{relpath}`: resize JPEG to 160×90 px in Go
(`golang.org/x/image` or stdlib `image` + `nfnt/resize` or manual scaling),
cache result in memory (bounded LRU, max 500 entries).
- Full-image endpoint `/raw/image/{relpath}`: serve JPEG directly from disk (no processing).
- UI:
- Fixed top strip showing all image thumbnails for the day (horizontally scrollable).
- Main area shows the currently selected image at full width.
- Left / right arrow buttons (keyboard and on-screen) to step through images.
- Active thumbnail highlighted in the strip.
- Deep-linkable URL per image (`?idx=N`).
---
## Priority 5 — Video browser
Objective: Browse and play H.265 videos for a selected day with thumbnail strip, arrow
navigation, and on-demand remux streaming.
- Route `/day/{date}/videos` — lists all videos for the day.
- Thumbnail endpoint `/thumb/video/{relpath}`: extract first frame via
`ffmpeg -i <pipe or file> -vframes 1 -f image2 pipe:1`, scale to 160×90,
cache in memory (bounded LRU, max 200 entries).
- Stream endpoint `/stream/video/{relpath}`:
`ffmpeg -i {input} -c:v copy -movflags frag_keyframe+empty_moov -f mp4 pipe:1`
piped directly to the HTTP response (`Content-Type: video/mp4`).
No disk writes. One ffmpeg process per active stream (kill on client disconnect).
- UI:
- Fixed top strip showing all video thumbnails for the day (horizontally scrollable,
shows clip duration parsed from filename).
- Main area: HTML5 `<video>` player (controls, autoplay on navigation).
- Left / right arrow buttons (keyboard and on-screen) to step through videos.
- Active thumbnail highlighted in the strip.
- Deep-linkable URL per video (`?idx=N`).
---
## Priority 6 — Responsive layout & day navigation
Objective: Unified dark-theme shell that adapts to desktop and mobile viewports.
- Tailwind CSS dark palette (slate-900 background, slate-700 cards, accent indigo-500).
- Desktop (≥ 768 px): collapsible left sidebar listing available days grouped by month;
clicking a day navigates to its images or videos tab.
- Mobile (< 768 px): sidebar hidden; top navigation bar with a hamburger/date picker
drawer; bottom tab bar for Images / Videos switch.
- Shared header: app name, logged-in username, logout button.
- Smooth transitions between images/videos on the same day without full-page reloads
(HTMX swap or minimal vanilla JS fetch).
- Keyboard shortcuts: `←`/`→` navigate items, `I`/`V` switch tabs.
---
## Acceptance Criteria (overall)
- `docker compose up` starts a working app with no extra steps beyond setting env vars.
- Login required before any footage is visible.
- Images load directly from disk with no processing delay.
- Videos play in the browser within ~2 s of clicking (remux start latency).
- Thumbnail strip visible and scrollable on both desktop and mobile.
- Arrow navigation works on desktop (keyboard) and mobile (touch buttons).
- All pages pass basic responsiveness check at 375 px and 1280 px viewport widths.
- `go vet ./...` and `go test ./...` pass cleanly.
---
## Out of scope (this cycle)
- Live / real-time camera feed.
- Motion detection or alerting.
- Video download / export.
- Per-camera filtering (footage layout assumed to be one camera per day folder for now).
- HTTPS termination (expected to sit behind a reverse proxy or Tailscale).

30
cmd/server/main.go

@ -0,0 +1,30 @@
package main
import (
"log"
"net/http"
"github.com/domagojzecevic/cammonitor/internal/config"
"github.com/domagojzecevic/cammonitor/internal/db"
"github.com/domagojzecevic/cammonitor/internal/web"
)
func main() {
cfg, err := config.Load()
if err != nil {
log.Fatalf("load config: %v", err)
}
database, err := db.Open(cfg.DBPath)
if err != nil {
log.Fatalf("open database: %v", err)
}
defer database.Close()
router := web.NewRouter(cfg, database, nil)
log.Printf("listening on %s", cfg.ListenAddr)
if err := http.ListenAndServe(cfg.ListenAddr, router); err != nil {
log.Fatalf("server stopped: %v", err)
}
}

18
docker-compose.yml

@ -0,0 +1,18 @@
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:

20
go.mod

@ -0,0 +1,20 @@
module github.com/domagojzecevic/cammonitor
go 1.22
require (
github.com/go-chi/chi/v5 v5.2.3
modernc.org/sqlite v1.34.5
)
require (
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/ncruces/go-strftime v0.1.9 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
golang.org/x/sys v0.22.0 // indirect
modernc.org/libc v1.55.3 // indirect
modernc.org/mathutil v1.6.0 // indirect
modernc.org/memory v1.8.0 // indirect
)

45
go.sum

@ -0,0 +1,45 @@
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/go-chi/chi/v5 v5.2.3 h1:WQIt9uxdsAbgIYgid+BpYc+liqQZGMHRaUwp0JUcvdE=
github.com/go-chi/chi/v5 v5.2.3/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops=
github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd h1:gbpYu9NMq8jhDVbvlGkMFWCjLFlqqEZjEmObmhUy6Vo=
github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd/go.mod h1:kf6iHlnVGwgKolg33glAes7Yg/8iWP8ukqeldJSO7jw=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
golang.org/x/mod v0.16.0 h1:QX4fJ0Rr5cPQCF7O9lh9Se4pmwfwskqZfq5moyldzic=
golang.org/x/mod v0.16.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.22.0 h1:RI27ohtqKCnwULzJLqkv897zojh5/DwS/ENaMzUOaWI=
golang.org/x/sys v0.22.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/tools v0.19.0 h1:tfGCXNR1OsFG+sVdLAitlpjAvD/I6dHDKnYrpEZUHkw=
golang.org/x/tools v0.19.0/go.mod h1:qoJWxmGSIBmAeriMx19ogtrEPrGtDbPK634QFIcLAhc=
modernc.org/cc/v4 v4.21.4 h1:3Be/Rdo1fpr8GrQ7IVw9OHtplU4gWbb+wNgeoBMmGLQ=
modernc.org/cc/v4 v4.21.4/go.mod h1:HM7VJTZbUCR3rV8EYBi9wxnJ0ZBRiGE5OeGXNA0IsLQ=
modernc.org/ccgo/v4 v4.19.2 h1:lwQZgvboKD0jBwdaeVCTouxhxAyN6iawF3STraAal8Y=
modernc.org/ccgo/v4 v4.19.2/go.mod h1:ysS3mxiMV38XGRTTcgo0DQTeTmAO4oCmJl1nX9VFI3s=
modernc.org/fileutil v1.3.0 h1:gQ5SIzK3H9kdfai/5x41oQiKValumqNTDXMvKo62HvE=
modernc.org/fileutil v1.3.0/go.mod h1:XatxS8fZi3pS8/hKG2GH/ArUogfxjpEKs3Ku3aK4JyQ=
modernc.org/gc/v2 v2.4.1 h1:9cNzOqPyMJBvrUipmynX0ZohMhcxPtMccYgGOJdOiBw=
modernc.org/gc/v2 v2.4.1/go.mod h1:wzN5dK1AzVGoH6XOzc3YZ+ey/jPgYHLuVckd62P0GYU=
modernc.org/libc v1.55.3 h1:AzcW1mhlPNrRtjS5sS+eW2ISCgSOLLNyFzRh/V3Qj/U=
modernc.org/libc v1.55.3/go.mod h1:qFXepLhz+JjFThQ4kzwzOjA/y/artDeg+pcYnY+Q83w=
modernc.org/mathutil v1.6.0 h1:fRe9+AmYlaej+64JsEEhoWuAYBkOtQiMEU7n/XgfYi4=
modernc.org/mathutil v1.6.0/go.mod h1:Ui5Q9q1TR2gFm0AQRqQUaBWFLAhQpCwNcuhBOSedWPo=
modernc.org/memory v1.8.0 h1:IqGTL6eFMaDZZhEWwcREgeMXYwmW83LYW8cROZYkg+E=
modernc.org/memory v1.8.0/go.mod h1:XPZ936zp5OMKGWPqbD3JShgd/ZoQ7899TUuQqxY+peU=
modernc.org/opt v0.1.3 h1:3XOZf2yznlhC+ibLltsDGzABUGVx8J6pnFMS3E4dcq4=
modernc.org/opt v0.1.3/go.mod h1:WdSiB5evDcignE70guQKxYUl14mgWtbClRi5wmkkTX0=
modernc.org/sortutil v1.2.0 h1:jQiD3PfS2REGJNzNCMMaLSp/wdMNieTbKX920Cqdgqc=
modernc.org/sortutil v1.2.0/go.mod h1:TKU2s7kJMf1AE84OoiGppNHJwvB753OYfNl2WRb++Ss=
modernc.org/sqlite v1.34.5 h1:Bb6SR13/fjp15jt70CL4f18JIN7p7dnMExd+UFnF15g=
modernc.org/sqlite v1.34.5/go.mod h1:YLuNmX9NKs8wRNK2ko1LW1NGYcc9FkBO69JOt1AR9JE=
modernc.org/strutil v1.2.0 h1:agBi9dp1I+eOnxXeiZawM8F4LawKv4NzGWSaLfyeNZA=
modernc.org/strutil v1.2.0/go.mod h1:/mdcBmfOibveCTBxUl5B5l6W+TTH1FXPLHZE6bTosX0=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=

60
internal/config/config.go

@ -0,0 +1,60 @@
package config
import (
"fmt"
"os"
"time"
)
type Config struct {
ListenAddr string
FootageRoot string
DBPath string
AdminUser string
AdminPass string
SessionTTL time.Duration
ScanInterval time.Duration
}
func Load() (*Config, error) {
sessionTTL, err := parseDurationEnv("SESSION_TTL", 24*time.Hour)
if err != nil {
return nil, err
}
scanInterval, err := parseDurationEnv("SCAN_INTERVAL", 5*time.Minute)
if err != nil {
return nil, err
}
return &Config{
ListenAddr: getEnv("LISTEN_ADDR", ":8080"),
FootageRoot: getEnv("FOOTAGE_ROOT", "/footage"),
DBPath: getEnv("DB_PATH", "/data/cammonitor.db"),
AdminUser: getEnv("ADMIN_USER", "admin"),
AdminPass: getEnv("ADMIN_PASS", "changeme"),
SessionTTL: sessionTTL,
ScanInterval: scanInterval,
}, nil
}
func getEnv(key, fallback string) string {
value := os.Getenv(key)
if value == "" {
return fallback
}
return value
}
func parseDurationEnv(key string, fallback time.Duration) (time.Duration, error) {
value := os.Getenv(key)
if value == "" {
return fallback, nil
}
duration, err := time.ParseDuration(value)
if err != nil {
return 0, fmt.Errorf("parse %s: %w", key, err)
}
return duration, nil
}

38
internal/db/db.go

@ -0,0 +1,38 @@
package db
import (
"database/sql"
"fmt"
"os"
"path/filepath"
_ "modernc.org/sqlite"
)
func Open(path string) (*sql.DB, error) {
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return nil, fmt.Errorf("create database directory: %w", err)
}
database, err := sql.Open("sqlite", path)
if err != nil {
return nil, fmt.Errorf("open sqlite: %w", err)
}
if _, err := database.Exec("PRAGMA foreign_keys = ON"); err != nil {
database.Close()
return nil, fmt.Errorf("enable foreign keys: %w", err)
}
if _, err := database.Exec("PRAGMA journal_mode = WAL"); err != nil {
database.Close()
return nil, fmt.Errorf("enable wal mode: %w", err)
}
if err := database.Ping(); err != nil {
database.Close()
return nil, fmt.Errorf("ping sqlite: %w", err)
}
return database, nil
}

22
internal/web/router.go

@ -0,0 +1,22 @@
package web
import (
"database/sql"
"encoding/json"
"net/http"
"github.com/domagojzecevic/cammonitor/internal/config"
"github.com/go-chi/chi/v5"
)
func NewRouter(_ *config.Config, _ *sql.DB, _ any) chi.Router {
router := chi.NewRouter()
router.Get("/health", func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
_ = json.NewEncoder(w).Encode(map[string]string{"status": "ok"})
})
return router
}

30
internal/web/router_test.go

@ -0,0 +1,30 @@
package web
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
)
func TestHealthReturnsOK(t *testing.T) {
router := NewRouter(nil, nil, nil)
request := httptest.NewRequest(http.MethodGet, "/health", nil)
response := httptest.NewRecorder()
router.ServeHTTP(response, request)
if response.Code != http.StatusOK {
t.Fatalf("expected status %d, got %d", http.StatusOK, response.Code)
}
var body map[string]string
if err := json.NewDecoder(response.Body).Decode(&body); err != nil {
t.Fatalf("decode response body: %v", err)
}
if body["status"] != "ok" {
t.Fatalf("expected status ok, got %q", body["status"])
}
}

1
testdata/footage/.gitkeep

@ -0,0 +1 @@
Loading…
Cancel
Save