Browse Source

added jenkinsfile

main
Domagoj Zecevic 2 days ago
parent
commit
8345e584a7
  1. 27
      .ai/HANDOFF.md
  2. 2
      .ai/PLAN.md
  3. 43
      .ai/REVIEW.md
  4. 2
      .ai/TASKS.md
  5. 3
      .claude/settings.local.json
  6. 2
      Dockerfile
  7. 161
      Jenkinsfile
  8. 2
      README.md
  9. 288
      internal/video/handler.go
  10. 300
      internal/video/handler_test.go
  11. 94
      internal/video/stream.go
  12. 93
      internal/video/thumb.go
  13. 5
      internal/web/router.go
  14. 34
      internal/web/router_test.go
  15. 1
      internal/web/templates/templates.go
  16. 111
      internal/web/templates/videos.html

27
.ai/HANDOFF.md

@ -2,6 +2,20 @@
Append-only role handoff log. Each role adds one entry when its step is complete.
### T-006 — review — 2026-06-18T14:10:00Z
| Field | Value |
|-------|-------|
| Agent | claude |
| Summary | Reviewed T-006 video browser; all 6 tests pass including SIGTERM/kill-on-cancel under race detector; three nit/minor findings, none blocking — accumulated shell-type triplication is the main tech debt. |
| Files Changed | .ai/REVIEW.md, .ai/TASKS.md, .ai/HANDOFF.md |
| Validation | `go fmt ./...`, `go vet ./...`, `go test ./internal/video/... -v`, `go test -race ./...` all pass. |
| Verdict | PASS_WITH_NOTES |
| Blocking Findings | none |
| Next Role | implement |
---
### T-005 — review — 2026-06-18T13:45:00Z
| Field | Value |
@ -241,3 +255,16 @@ Append-only role handoff log. Each role adds one entry when its step is complete
| Next Role | none |
---
### T-006 — implement — 2026-06-18T13:48:22Z
| Field | Value |
|-------|-------|
| Agent | codex |
| Summary | Added on-demand MP4 remux streaming, cached video thumbnails, the deep-linkable video browser page, authenticated route wiring, and README coverage for video playback. |
| Files Changed | .ai/TASKS.md, .ai/HANDOFF.md, README.md, internal/video/handler.go, internal/video/handler_test.go, internal/video/stream.go, internal/video/thumb.go, internal/web/router.go, internal/web/router_test.go, internal/web/templates/templates.go, internal/web/templates/videos.html |
| Validation | `go fmt ./...` passed; `go vet ./...` passed; `go test ./internal/video/...` passed; `go test ./...` passed; `go test -race ./...` passed |
| Commit | `feat(video): add video browser and on-demand MP4 streaming` |
| Next Role | review |
---

2
.ai/PLAN.md

@ -108,7 +108,7 @@ CamMonitor/
- `internal/web/router.go``NewRouter(cfg, db, idx)` returns `chi.Router`; mounts `/health` returning `{"status":"ok"}`
- `Dockerfile`:
```
FROM golang:1.22-bookworm AS builder
FROM golang:1.25-bookworm AS builder
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download

43
.ai/REVIEW.md

@ -251,3 +251,46 @@ None.
- Finding #1 (shell type duplication) is the main structural risk: T-006 will face the same choice and may add a third copy. The T-006 implementer should extract shared types before or during that task.
---
## T-006 — Video browser
**Verdict:** PASS_WITH_NOTES
### Findings
| # | Severity | File | Description | Required fix? |
|---|----------|------|-------------|---------------|
| 1 | minor | `internal/video/handler.go` (lines 28–41, 217–266) | `ShellData`, `MonthGroup`, `groupDaysByMonth`, `parseIndex`, and `pathEscape` are now copied into a **third** package (web → image → video). T-005 review flagged this as minor and asked T-006 to resolve it. The implementer did not extract shared types. This is accumulated tech debt that must be addressed in a follow-up cleanup before the codebase grows further. | no |
| 2 | nit | `internal/video/handler.go` (lines 143–158) | `resolveRequestPath` adds extra route-pattern/URL parsing not present in the image handler's equivalent. Both implementations pass the traversal test; the extra complexity in the video handler is confusing and inconsistent. | no |
| 3 | nit | `internal/video/handler.go` (lines 197–200) | Same buffer-less render pattern flagged in T-004 and T-005 (`Content-Type` written before `ExecuteTemplate`; `http.Error` fallback is a no-op). | no |
### Required fixes
None.
### Verification
**Steps performed:**
1. Read all new/changed files: `internal/video/stream.go`, `internal/video/thumb.go`, `internal/video/handler.go`, `internal/video/handler_test.go`, `internal/web/templates/videos.html`, `internal/web/templates/templates.go`, `internal/web/router.go`.
2. Cross-checked against `.ai/PLAN.md` Phase 6 scope.
3. Confirmed `groupDaysByMonth`, `parseIndex`, and `pathEscape` are byte-for-byte identical across all three packages (diff exit 0).
4. Ran `go fmt ./...` — clean.
5. Ran `go vet ./...` — clean.
6. Ran `go test ./internal/video/... -v` — all 6 tests PASS.
7. Ran `go test -race ./internal/video/... -run TestStreamKillsFFmpegOnContextCancel -v` — PASS under race detector.
8. Ran `go test -race ./...` — PASS, no races across the whole module.
**Findings:**
- All acceptance criteria met: `Content-Type: video/mp4` and non-empty body verified (`TestStreamEndpointReturnsMP4HeadersAndBody`); thumbnail JPEG returned and decoded (`TestThumbnailEndpointReturnsJPEG`); thumbnail cache invokes ffmpeg only once (`TestThumbnailCacheReturnsStoredBytes`); player page has `<video>`, duration labels (00:15, 00:30), strip highlight, nav buttons, keyboard arrows (`TestVideoPageRendersPlayerNavigation`); ffmpeg kill on disconnect verified with SIGTERM trap script + kill marker file (`TestStreamKillsFFmpegOnContextCancel`); path traversal returns 400.
- SIGTERM + 250ms grace + SIGKILL pattern is correct. `processDone` channel is used to avoid killing a naturally-exiting process. ✅
- `stream.go` correctly sets `Content-Type: video/mp4`, `Cache-Control: no-store`, `X-Content-Type-Options: nosniff` before writing. ✅
- `cmd.StdoutPipe()` + `io.Copy` + `cmd.Wait()` sequence is correct; `Wait` closes the pipe after process exits. ✅
- Videos template uses true JS SPA nav (`history.pushState`, `video.load()`, `video.play()`), unlike images template which does full-page reloads. Better UX, consistent with plan. ✅
- Duration overlay (`DurationLabel`) renders `00:MM` or `HH:MM:SS`. Implemented and tested. ✅
- Route wiring in `router.go` correct: `/stream/video/*`, `/thumb/video/*`, `/day/{date}/videos` all under `RequireAuth`. ✅
- FIFO cache eviction is bounded to 200 entries, same pattern as image cache. ✅
**Risks:**
- Finding #1 (triplication of shell types) is now a committed pattern across three packages. A future change to `ShellData` or `groupDaysByMonth` must be made in three places. Recommend creating a `internal/shell` package in a cleanup commit before adding further browser tasks.
---

2
.ai/TASKS.md

@ -25,4 +25,4 @@ Command expectations:
| T-003 | Footage scanner: walk FOOTAGE_ROOT, build in-memory index (date → images + videos), filename parser, periodic rescan goroutine | done | 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) | `go fmt ./...`, `go vet ./...`, `go test ./internal/footage/...`, `go test ./...`, `go test -race ./...` passed | none |
| 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 | done | 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 | `go fmt ./...`, `go vet ./...`, `go test ./internal/web/...`, `go test ./...`, `go test -race ./...` passed | none |
| 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` | done | 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 | `go fmt ./...`, `go vet ./...`, `go test ./internal/image/...`, `go test ./...`, `go test -race ./...` passed | none |
| 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 |
| 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_to_commit | 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 | `go fmt ./...`, `go vet ./...`, `go test ./internal/video/...`, `go test ./...`, `go test -race ./...` passed | implement |

3
.claude/settings.local.json

@ -14,7 +14,8 @@
"Bash(go vet ./...:*)",
"Bash(go test ./...:*)",
"Bash(git:*)",
"mcp__aide__*"
"mcp__aide__*",
"Bash(docker compose build *)"
]
}
}

2
Dockerfile

@ -1,4 +1,4 @@
FROM golang:1.22-bookworm AS builder
FROM golang:1.25-bookworm AS builder
WORKDIR /src

161
Jenkinsfile

@ -0,0 +1,161 @@
pipeline {
agent any
environment {
REGISTRY = '192.168.1.11:5000'
IMAGE_NAME = 'cammonitor'
// Tag with the short git commit SHA; also push 'latest' on the main branch.
IMAGE_TAG = sh(script: 'git rev-parse --short HEAD', returnStdout: true).trim()
// TRIVY_SEVERITY controls which finding levels fail the build.
// Set to 'CRITICAL' to only fail on critical CVEs, or remove the
// --exit-code flag entirely to make the scan advisory-only.
TRIVY_SEVERITY = 'HIGH,CRITICAL'
}
options {
// Keep only the last 10 builds to save disk space on the Atom server.
buildDiscarder(logRotator(numToKeepStr: '10'))
timestamps()
}
stages {
stage('Checkout') {
steps {
checkout scm
}
}
stage('Build') {
steps {
// Builds the image defined in Dockerfile using the same flags
// that docker compose would use, but targets the registry tag directly.
sh """
docker build \
--tag ${REGISTRY}/${IMAGE_NAME}:${IMAGE_TAG} \
--tag ${REGISTRY}/${IMAGE_NAME}:latest \
.
"""
}
}
stage('Test') {
// Spin up a throw-away container and hit /health to confirm the binary
// starts correctly before we push anything.
steps {
sh """
docker run --rm -d \
--name cammonitor-ci-${BUILD_NUMBER} \
-p 19080:8080 \
-e FOOTAGE_ROOT=/tmp \
-e DB_PATH=/tmp/cammonitor-ci.db \
${REGISTRY}/${IMAGE_NAME}:${IMAGE_TAG}
# Give the process a moment to bind the port, then check health.
sleep 3
curl --fail --silent --show-error http://127.0.0.1:19080/health
"""
}
post {
always {
sh 'docker stop cammonitor-ci-${BUILD_NUMBER} || true'
}
}
}
stage('Scan') {
// Trivy scans the locally built image for OS and library CVEs before
// anything is pushed to the registry.
//
// Trivy must be installed on the Jenkins agent:
// https://aquasecurity.github.io/trivy/latest/getting-started/installation/
// e.g. apt install trivy or brew install trivy
//
// The vulnerability DB is cached at ~/.cache/trivy between builds.
// On air-gapped networks set TRIVY_NO_PROGRESS=true and pre-download
// the DB: https://aquasecurity.github.io/trivy/latest/docs/advanced/air-gap/
steps {
// --exit-code 1 makes the stage (and build) fail when findings at
// or above TRIVY_SEVERITY are detected. Remove it to make the scan
// informational only.
sh """
trivy image \
--severity ${TRIVY_SEVERITY} \
--exit-code 1 \
--no-progress \
--format table \
${REGISTRY}/${IMAGE_NAME}:${IMAGE_TAG}
"""
}
post {
always {
// Also emit a machine-readable SARIF report so results can be
// ingested by Jenkins Warnings NG or uploaded to GitHub Advanced
// Security if you ever connect this repo there.
sh """
trivy image \
--severity ${TRIVY_SEVERITY} \
--exit-code 0 \
--no-progress \
--format sarif \
--output trivy-report.sarif \
${REGISTRY}/${IMAGE_NAME}:${IMAGE_TAG}
"""
archiveArtifacts artifacts: 'trivy-report.sarif', allowEmptyArchive: true
}
}
}
stage('Push') {
// NOTE: 192.168.1.11:5000 is an HTTP registry (no TLS).
// The Docker daemon on the Jenkins agent must have it listed under
// "insecure-registries" in /etc/docker/daemon.json:
//
// { "insecure-registries": ["192.168.1.11:5000"] }
//
// If the registry requires credentials, replace the plain push below
// with the block commented out underneath it, and add a 'registry-creds'
// username/password credential in Jenkins > Manage Credentials.
steps {
sh "docker push ${REGISTRY}/${IMAGE_NAME}:${IMAGE_TAG}"
// Only move the 'latest' pointer when building the main branch.
script {
if (env.BRANCH_NAME == 'main') {
sh "docker push ${REGISTRY}/${IMAGE_NAME}:latest"
}
}
/* --- With registry credentials (uncomment if needed) ---
withCredentials([usernamePassword(
credentialsId: 'registry-creds',
usernameVariable: 'REG_USER',
passwordVariable: 'REG_PASS'
)]) {
sh """
docker login ${REGISTRY} -u \$REG_USER -p \$REG_PASS
docker push ${REGISTRY}/${IMAGE_NAME}:${IMAGE_TAG}
docker push ${REGISTRY}/${IMAGE_NAME}:latest
docker logout ${REGISTRY}
"""
}
-------------------------------------------------------- */
}
}
}
post {
always {
// Remove local copies so the build agent does not fill up.
sh """
docker rmi ${REGISTRY}/${IMAGE_NAME}:${IMAGE_TAG} || true
docker rmi ${REGISTRY}/${IMAGE_NAME}:latest || true
"""
}
success {
echo "Image pushed: ${REGISTRY}/${IMAGE_NAME}:${IMAGE_TAG}"
}
failure {
echo "Build failed — image was NOT pushed to the registry."
}
}
}

2
README.md

@ -61,6 +61,8 @@ The image browser at `/day/YYYYMMDD/images` shows a horizontal thumbnail strip a
Original JPEGs are served from `/raw/image/<relative-path>` after validating that the path stays inside `FOOTAGE_ROOT`. Thumbnails are served from `/thumb/image/<relative-path>` as cached 160x90 JPEGs generated in memory only.
The video browser at `/day/YYYYMMDD/videos` uses the same strip-and-viewer layout with an HTML5 player, wraparound arrow navigation, and `?idx=N` deep links. Video thumbnails are served from `/thumb/video/<relative-path>`, and `/stream/video/<relative-path>` remuxes the source clip to fragmented MP4 on demand with `ffmpeg` without re-encoding.
On desktop widths, the app shows a dark sidebar with indexed days grouped by month. On smaller screens, the sidebar is replaced by a drawer trigger in the header and a fixed bottom tab bar for switching between Images and Videos.
## Footage Layout

288
internal/video/handler.go

@ -0,0 +1,288 @@
package video
import (
"fmt"
"html/template"
"net/http"
"net/url"
"path/filepath"
"sort"
"strconv"
"strings"
"time"
"github.com/domagojzecevic/cammonitor/internal/auth"
"github.com/domagojzecevic/cammonitor/internal/config"
"github.com/domagojzecevic/cammonitor/internal/footage"
webtemplates "github.com/domagojzecevic/cammonitor/internal/web/templates"
"github.com/go-chi/chi/v5"
)
type Handler struct {
cfg *config.Config
idx *footage.Index
thumbs *Cache
ffmpegPath string
}
type ShellData struct {
Title string
User *auth.User
Months []MonthGroup
CurrentDate string
ActiveTab string
Content any
}
type MonthGroup struct {
Label string
Days []string
}
type PageData struct {
Date string
Videos []VideoItem
ActiveIndex int
PrevIndex int
NextIndex int
Current VideoItem
}
type VideoItem struct {
Index int
Filename string
RelPath string
StreamURL string
ThumbURL string
DurationLabel string
Active bool
}
func NewHandler(cfg *config.Config, idx *footage.Index) *Handler {
return &Handler{
cfg: cfg,
idx: idx,
ffmpegPath: defaultFFmpegPath,
thumbs: NewCache(defaultCacheEntries, defaultFFmpegPath),
}
}
func (h *Handler) ServeStream(w http.ResponseWriter, r *http.Request) {
absPath, ok := h.resolveRequestPath(w, r)
if !ok {
return
}
if err := Stream(w, r, absPath, h.ffmpegPath); err != nil {
http.Error(w, "stream video failed", http.StatusBadGateway)
}
}
func (h *Handler) ServeThumb(w http.ResponseWriter, r *http.Request) {
absPath, ok := h.resolveRequestPath(w, r)
if !ok {
return
}
thumb, err := h.thumbs.Thumbnail(absPath)
if err != nil {
http.NotFound(w, r)
return
}
w.Header().Set("Cache-Control", "max-age=3600")
w.Header().Set("Content-Type", "image/jpeg")
w.WriteHeader(http.StatusOK)
_, _ = w.Write(thumb)
}
func (h *Handler) ServePage(w http.ResponseWriter, r *http.Request) {
date := chi.URLParam(r, "date")
day, ok := h.day(date)
if !ok {
http.NotFound(w, r)
return
}
data := h.pageData(date, day.Videos, parseIndex(r.URL.Query().Get("idx"), len(day.Videos)))
h.render(w, r, ShellData{
Title: "CamMonitor " + date + " videos",
CurrentDate: date,
ActiveTab: "videos",
Content: data,
})
}
func (h *Handler) pageData(date string, files []footage.VideoFile, active int) PageData {
items := make([]VideoItem, 0, len(files))
for i, file := range files {
items = append(items, VideoItem{
Index: i,
Filename: file.Filename,
RelPath: filepath.ToSlash(file.RelPath),
StreamURL: "/stream/video/" + pathEscape(file.RelPath),
ThumbURL: "/thumb/video/" + pathEscape(file.RelPath),
DurationLabel: formatDuration(file.Duration),
Active: i == active,
})
}
page := PageData{
Date: date,
Videos: items,
ActiveIndex: active,
}
if len(items) > 0 {
page.PrevIndex = (active - 1 + len(items)) % len(items)
page.NextIndex = (active + 1) % len(items)
page.Current = items[active]
}
return page
}
func (h *Handler) resolveRequestPath(w http.ResponseWriter, r *http.Request) (string, bool) {
relPath := chi.URLParam(r, "*")
if routePattern := chi.RouteContext(r.Context()).RoutePattern(); routePattern != "" {
prefix := strings.TrimSuffix(routePattern, "*")
escaped := strings.TrimPrefix(r.URL.EscapedPath(), prefix)
if unescaped, err := url.PathUnescape(escaped); err == nil && unescaped != "" {
relPath = unescaped
}
}
absPath, err := h.resolveRelPath(relPath)
if err != nil {
http.Error(w, "invalid video path", http.StatusBadRequest)
return "", false
}
return absPath, true
}
func (h *Handler) resolveRelPath(relPath string) (string, error) {
if h.cfg == nil || h.cfg.FootageRoot == "" {
return "", fmt.Errorf("missing footage root")
}
if relPath == "" {
return "", fmt.Errorf("missing path")
}
clean := filepath.Clean(filepath.FromSlash(relPath))
if clean == "." || clean == ".." || filepath.IsAbs(clean) || strings.HasPrefix(clean, ".."+string(filepath.Separator)) {
return "", fmt.Errorf("unsafe path")
}
root, err := filepath.Abs(h.cfg.FootageRoot)
if err != nil {
return "", err
}
target, err := filepath.Abs(filepath.Join(root, clean))
if err != nil {
return "", err
}
rel, err := filepath.Rel(root, target)
if err != nil {
return "", err
}
if rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) {
return "", fmt.Errorf("path escapes footage root")
}
return target, nil
}
func (h *Handler) render(w http.ResponseWriter, r *http.Request, data ShellData) {
data.User, _ = auth.UserFromContext(r.Context())
data.Months = groupDaysByMonth(h.dayList())
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if err := videoTemplate.ExecuteTemplate(w, "base", data); err != nil {
http.Error(w, "render video page failed", http.StatusInternalServerError)
}
}
func (h *Handler) dayList() []string {
if h.idx == nil {
return nil
}
return h.idx.DayList()
}
func (h *Handler) day(date string) (*footage.DayEntry, bool) {
if h.idx == nil {
return nil, false
}
return h.idx.Day(date)
}
func parseIndex(value string, length int) int {
if length <= 0 {
return 0
}
index, err := strconv.Atoi(value)
if err != nil || index < 0 || index >= length {
return 0
}
return index
}
func pathEscape(relPath string) string {
parts := strings.Split(filepath.ToSlash(relPath), "/")
for i, part := range parts {
parts[i] = url.PathEscape(part)
}
return strings.Join(parts, "/")
}
func groupDaysByMonth(days []string) []MonthGroup {
groups := make(map[string][]string)
labels := make([]string, 0)
for _, day := range days {
if len(day) != len("20060102") {
continue
}
parsed, err := time.Parse("20060102", day)
if err != nil {
continue
}
label := parsed.Format("2006-01")
if _, ok := groups[label]; !ok {
labels = append(labels, label)
}
groups[label] = append(groups[label], day)
}
sort.Sort(sort.Reverse(sort.StringSlice(labels)))
monthGroups := make([]MonthGroup, 0, len(labels))
for _, label := range labels {
monthGroups = append(monthGroups, MonthGroup{
Label: label,
Days: groups[label],
})
}
return monthGroups
}
func formatDuration(duration time.Duration) string {
totalSeconds := int(duration.Round(time.Second) / time.Second)
if totalSeconds < 0 {
totalSeconds = 0
}
hours := totalSeconds / 3600
minutes := (totalSeconds % 3600) / 60
seconds := totalSeconds % 60
if hours > 0 {
return fmt.Sprintf("%02d:%02d:%02d", hours, minutes, seconds)
}
return fmt.Sprintf("%02d:%02d", minutes, seconds)
}
var videoTemplate = template.Must(template.ParseFS(
webtemplates.FS,
webtemplates.Base,
webtemplates.Videos,
))

300
internal/video/handler_test.go

@ -0,0 +1,300 @@
package video
import (
"bytes"
"context"
stdimage "image"
"image/color"
"image/jpeg"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/domagojzecevic/cammonitor/internal/config"
"github.com/domagojzecevic/cammonitor/internal/footage"
"github.com/go-chi/chi/v5"
)
func TestThumbnailEndpointReturnsJPEG(t *testing.T) {
root := createVideoFixture(t)
index := footage.NewIndex(root, 0)
t.Cleanup(index.Close)
thumbPath := filepath.Join(t.TempDir(), "thumb.jpg")
writeJPEG(t, thumbPath, color.RGBA{R: 90, G: 200, B: 120, A: 255})
router := videoRouter(root, index, writeThumbnailFFmpeg(t, thumbPath, ""))
request := httptest.NewRequest(http.MethodGet, "/thumb/video/20260101/record/A260101_120000_120015.265", nil)
response := httptest.NewRecorder()
router.ServeHTTP(response, request)
if response.Code != http.StatusOK {
t.Fatalf("expected status %d, got %d: %s", http.StatusOK, response.Code, response.Body.String())
}
if contentType := response.Header().Get("Content-Type"); contentType != "image/jpeg" {
t.Fatalf("expected image/jpeg content type, got %q", contentType)
}
if _, err := jpeg.Decode(bytes.NewReader(response.Body.Bytes())); err != nil {
t.Fatalf("decode thumbnail: %v", err)
}
}
func TestThumbnailCacheReturnsStoredBytes(t *testing.T) {
root := createVideoFixture(t)
videoPath := filepath.Join(root, "20260101", "record", "A260101_120000_120015.265")
tempDir := t.TempDir()
thumbPath := filepath.Join(tempDir, "thumb.jpg")
countPath := filepath.Join(tempDir, "count.txt")
writeJPEG(t, thumbPath, color.RGBA{R: 200, G: 120, B: 90, A: 255})
cache := NewCache(2, writeThumbnailFFmpeg(t, thumbPath, countPath))
first, err := cache.Thumbnail(videoPath)
if err != nil {
t.Fatalf("first thumbnail: %v", err)
}
second, err := cache.Thumbnail(videoPath)
if err != nil {
t.Fatalf("second thumbnail: %v", err)
}
if !bytes.Equal(first, second) {
t.Fatalf("expected cached thumbnail bytes to match")
}
counts, err := os.ReadFile(countPath)
if err != nil {
t.Fatalf("read count file: %v", err)
}
if got := strings.Count(strings.TrimSpace(string(counts)), "thumb"); got != 1 {
t.Fatalf("expected ffmpeg to run once, got %d entries in %q", got, string(counts))
}
}
func TestStreamEndpointReturnsMP4HeadersAndBody(t *testing.T) {
root := createVideoFixture(t)
index := footage.NewIndex(root, 0)
t.Cleanup(index.Close)
router := videoRouter(root, index, writeStreamFFmpeg(t, "fake-mp4-stream", ""))
request := httptest.NewRequest(http.MethodGet, "/stream/video/20260101/record/A260101_120000_120015.265", nil)
response := httptest.NewRecorder()
router.ServeHTTP(response, request)
if response.Code != http.StatusOK {
t.Fatalf("expected status %d, got %d: %s", http.StatusOK, response.Code, response.Body.String())
}
if contentType := response.Header().Get("Content-Type"); contentType != "video/mp4" {
t.Fatalf("expected video/mp4 content type, got %q", contentType)
}
if cacheControl := response.Header().Get("Cache-Control"); cacheControl != "no-store" {
t.Fatalf("expected Cache-Control no-store, got %q", cacheControl)
}
if got := response.Body.String(); got != "fake-mp4-stream" {
t.Fatalf("expected stream body %q, got %q", "fake-mp4-stream", got)
}
}
func TestStreamEndpointRejectsTraversal(t *testing.T) {
root := createVideoFixture(t)
index := footage.NewIndex(root, 0)
t.Cleanup(index.Close)
router := videoRouter(root, index, writeStreamFFmpeg(t, "fake-mp4-stream", ""))
request := httptest.NewRequest(http.MethodGet, "/stream/video/%2e%2e/secret.265", nil)
response := httptest.NewRecorder()
router.ServeHTTP(response, request)
if response.Code != http.StatusBadRequest {
t.Fatalf("expected status %d, got %d", http.StatusBadRequest, response.Code)
}
}
func TestVideoPageRendersPlayerNavigation(t *testing.T) {
root := createVideoFixture(t)
index := footage.NewIndex(root, 0)
t.Cleanup(index.Close)
router := videoRouter(root, index, writeStreamFFmpeg(t, "fake-mp4-stream", ""))
request := httptest.NewRequest(http.MethodGet, "/day/20260101/videos?idx=1", nil)
response := httptest.NewRecorder()
router.ServeHTTP(response, request)
if response.Code != http.StatusOK {
t.Fatalf("expected status %d, got %d: %s", http.StatusOK, response.Code, response.Body.String())
}
body := response.Body.String()
for _, want := range []string{
`data-video-browser`,
`data-active-index="1"`,
`<video`,
`/stream/video/20260101/record/A260101_120500_120530.265`,
`/thumb/video/20260101/record/A260101_120000_120015.265`,
`data-nav="prev"`,
`data-nav="next"`,
`keydown`,
`ArrowLeft`,
`ArrowRight`,
`ring-2 ring-indigo-400`,
`00:15`,
`00:30`,
} {
if !strings.Contains(body, want) {
t.Fatalf("expected response to contain %q\nbody:\n%s", want, body)
}
}
}
func TestStreamKillsFFmpegOnContextCancel(t *testing.T) {
root := createVideoFixture(t)
index := footage.NewIndex(root, 0)
t.Cleanup(index.Close)
killedPath := filepath.Join(t.TempDir(), "killed.txt")
router := videoRouter(root, index, writeStreamingKillFFmpeg(t, killedPath))
ctx, cancel := context.WithCancel(context.Background())
request := httptest.NewRequest(http.MethodGet, "/stream/video/20260101/record/A260101_120000_120015.265", nil).WithContext(ctx)
response := httptest.NewRecorder()
done := make(chan struct{})
go func() {
router.ServeHTTP(response, request)
close(done)
}()
time.Sleep(150 * time.Millisecond)
cancel()
select {
case <-done:
case <-time.After(2 * time.Second):
t.Fatal("stream handler did not exit after context cancellation")
}
if _, err := os.Stat(killedPath); err != nil {
t.Fatalf("expected ffmpeg kill marker to exist: %v", err)
}
if !strings.Contains(response.Body.String(), "fake-mp4-stream") {
t.Fatalf("expected stream body to contain initial bytes, got %q", response.Body.String())
}
}
func videoRouter(root string, index *footage.Index, ffmpegPath string) chi.Router {
handler := NewHandler(&config.Config{FootageRoot: root}, index)
handler.ffmpegPath = ffmpegPath
handler.thumbs = NewCache(defaultCacheEntries, ffmpegPath)
router := chi.NewRouter()
router.Get("/stream/video/*", handler.ServeStream)
router.Get("/thumb/video/*", handler.ServeThumb)
router.Get("/day/{date}/videos", handler.ServePage)
return router
}
func createVideoFixture(t *testing.T) string {
t.Helper()
root := t.TempDir()
recordDir := filepath.Join(root, "20260101", "record")
if err := os.MkdirAll(recordDir, 0o755); err != nil {
t.Fatalf("create record dir: %v", err)
}
for _, name := range []string{
"A260101_120000_120015.265",
"A260101_120500_120530.265",
} {
if err := os.WriteFile(filepath.Join(recordDir, name), []byte("video"), 0o644); err != nil {
t.Fatalf("write video fixture %s: %v", name, err)
}
}
return root
}
func writeThumbnailFFmpeg(t *testing.T, thumbPath, countPath string) string {
t.Helper()
lines := []string{"#!/bin/sh"}
if countPath != "" {
lines = append(lines, "echo thumb >> "+shellQuote(countPath))
}
lines = append(lines, "cat "+shellQuote(thumbPath))
lines = append(lines, "")
return writeScript(t, strings.Join(lines, "\n"))
}
func writeStreamFFmpeg(t *testing.T, body, countPath string) string {
t.Helper()
lines := []string{"#!/bin/sh"}
if countPath != "" {
lines = append(lines, "echo stream >> "+shellQuote(countPath))
}
lines = append(lines, "printf %s "+shellQuote(body))
lines = append(lines, "")
return writeScript(t, strings.Join(lines, "\n"))
}
func writeStreamingKillFFmpeg(t *testing.T, killedPath string) string {
t.Helper()
script := strings.Join([]string{
"#!/bin/sh",
"trap \"echo killed > " + shellQuote(killedPath) + "; exit 0\" TERM INT",
"printf %s " + shellQuote("fake-mp4-stream"),
"while :; do sleep 0.05; done",
"",
}, "\n")
return writeScript(t, script)
}
func writeScript(t *testing.T, content string) string {
t.Helper()
path := filepath.Join(t.TempDir(), "ffmpeg.sh")
if err := os.WriteFile(path, []byte(content), 0o755); err != nil {
t.Fatalf("write script: %v", err)
}
return path
}
func shellQuote(value string) string {
return "'" + strings.ReplaceAll(value, "'", "'\"'\"'") + "'"
}
func writeJPEG(t *testing.T, path string, fill color.Color) {
t.Helper()
img := stdimage.NewRGBA(stdimage.Rect(0, 0, 160, 90))
for y := 0; y < img.Bounds().Dy(); y++ {
for x := 0; x < img.Bounds().Dx(); x++ {
img.Set(x, y, fill)
}
}
file, err := os.Create(path)
if err != nil {
t.Fatalf("create jpeg %s: %v", path, err)
}
defer file.Close()
if err := jpeg.Encode(file, img, &jpeg.Options{Quality: 85}); err != nil {
t.Fatalf("encode jpeg %s: %v", path, err)
}
}

94
internal/video/stream.go

@ -0,0 +1,94 @@
package video
import (
"bytes"
"fmt"
"io"
"log"
"net/http"
"os/exec"
"strings"
"syscall"
"time"
)
func Stream(w http.ResponseWriter, r *http.Request, absPath, ffmpegPath string) error {
if ffmpegPath == "" {
ffmpegPath = defaultFFmpegPath
}
cmd := exec.Command(
ffmpegPath,
"-loglevel", "error",
"-i", absPath,
"-c:v", "copy",
"-movflags", "frag_keyframe+empty_moov",
"-f", "mp4",
"pipe:1",
)
var stderr bytes.Buffer
cmd.Stderr = &stderr
stdout, err := cmd.StdoutPipe()
if err != nil {
return fmt.Errorf("open ffmpeg stdout: %w", err)
}
if err := cmd.Start(); err != nil {
return fmt.Errorf("start ffmpeg: %w%s", err, stderrSuffix(stderr.String()))
}
processDone := make(chan struct{})
go func() {
select {
case <-r.Context().Done():
if cmd.Process == nil {
return
}
_ = cmd.Process.Signal(syscall.SIGTERM)
timer := time.NewTimer(250 * time.Millisecond)
defer timer.Stop()
select {
case <-processDone:
case <-timer.C:
_ = cmd.Process.Kill()
}
case <-processDone:
}
}()
w.Header().Set("Cache-Control", "no-store")
w.Header().Set("Content-Type", "video/mp4")
w.Header().Set("X-Content-Type-Options", "nosniff")
w.WriteHeader(http.StatusOK)
if flusher, ok := w.(http.Flusher); ok {
flusher.Flush()
}
_, copyErr := io.Copy(w, stdout)
waitErr := cmd.Wait()
close(processDone)
if r.Context().Err() != nil {
return nil
}
if copyErr != nil {
log.Printf("ffmpeg remux copy failed for %s: %v", absPath, copyErr)
return nil
}
if waitErr != nil {
log.Printf("ffmpeg remux failed for %s: %v%s", absPath, waitErr, stderrSuffix(stderr.String()))
}
return nil
}
func stderrSuffix(stderr string) string {
trimmed := strings.TrimSpace(stderr)
if trimmed == "" {
return ""
}
return ": " + trimmed
}

93
internal/video/thumb.go

@ -0,0 +1,93 @@
package video
import (
"bytes"
"fmt"
"os/exec"
"sync"
)
const (
defaultCacheEntries = 200
thumbnailWidth = 160
thumbnailHeight = 90
defaultFFmpegPath = "ffmpeg"
)
type Cache struct {
mu sync.Mutex
max int
ffmpegPath string
entries map[string][]byte
order []string
}
func NewCache(max int, ffmpegPath string) *Cache {
if max <= 0 {
max = defaultCacheEntries
}
if ffmpegPath == "" {
ffmpegPath = defaultFFmpegPath
}
return &Cache{
max: max,
ffmpegPath: ffmpegPath,
entries: make(map[string][]byte),
}
}
func (c *Cache) Thumbnail(absPath string) ([]byte, error) {
if cached, ok := c.get(absPath); ok {
return cached, nil
}
cmd := exec.Command(
c.ffmpegPath,
"-loglevel", "error",
"-ss", "0",
"-i", absPath,
"-vframes", "1",
"-vf", fmt.Sprintf("scale=%d:%d:force_original_aspect_ratio=decrease", thumbnailWidth, thumbnailHeight),
"-f", "image2",
"-vcodec", "mjpeg",
"pipe:1",
)
var stderr bytes.Buffer
cmd.Stderr = &stderr
output, err := cmd.Output()
if err != nil {
return nil, fmt.Errorf("generate video thumbnail: %w%s", err, stderrSuffix(stderr.String()))
}
c.add(absPath, output)
return append([]byte(nil), output...), nil
}
func (c *Cache) get(key string) ([]byte, bool) {
c.mu.Lock()
defer c.mu.Unlock()
value, ok := c.entries[key]
if !ok {
return nil, false
}
return append([]byte(nil), value...), true
}
func (c *Cache) add(key string, value []byte) {
c.mu.Lock()
defer c.mu.Unlock()
if _, ok := c.entries[key]; !ok {
c.order = append(c.order, key)
}
c.entries[key] = append([]byte(nil), value...)
for len(c.order) > c.max {
oldest := c.order[0]
c.order = c.order[1:]
delete(c.entries, oldest)
}
}

5
internal/web/router.go

@ -9,6 +9,7 @@ import (
"github.com/domagojzecevic/cammonitor/internal/config"
"github.com/domagojzecevic/cammonitor/internal/footage"
imagebrowser "github.com/domagojzecevic/cammonitor/internal/image"
videobrowser "github.com/domagojzecevic/cammonitor/internal/video"
"github.com/go-chi/chi/v5"
)
@ -29,6 +30,7 @@ func NewRouter(cfg *config.Config, database *sql.DB, index *footage.Index) chi.R
authHandler := auth.NewHandler(store, cfg.SessionTTL)
webHandler := NewHandler(index)
imageHandler := imagebrowser.NewHandler(cfg, index)
videoHandler := videobrowser.NewHandler(cfg, index)
router.Get("/login", authHandler.LoginPage)
router.Post("/login", authHandler.Login)
@ -40,8 +42,11 @@ func NewRouter(cfg *config.Config, database *sql.DB, index *footage.Index) chi.R
protected.Get("/", webHandler.Index)
protected.Get("/day/{date}", webHandler.DayOverview)
protected.Get("/day/{date}/images", imageHandler.ServePage)
protected.Get("/day/{date}/videos", videoHandler.ServePage)
protected.Get("/raw/image/*", imageHandler.ServeRaw)
protected.Get("/thumb/image/*", imageHandler.ServeThumb)
protected.Get("/stream/video/*", videoHandler.ServeStream)
protected.Get("/thumb/video/*", videoHandler.ServeThumb)
protected.Group(func(admin chi.Router) {
admin.Use(auth.RequireAdmin)

34
internal/web/router_test.go

@ -163,6 +163,40 @@ func TestImagePageRendersAuthenticatedBrowser(t *testing.T) {
}
}
func TestVideoPageRendersAuthenticatedBrowser(t *testing.T) {
router, cookie := newAuthenticatedRouter(t)
request := httptest.NewRequest(http.MethodGet, "/day/20260101/videos?idx=1", nil)
request.AddCookie(cookie)
response := httptest.NewRecorder()
router.ServeHTTP(response, request)
if response.Code != http.StatusOK {
t.Fatalf("expected status %d, got %d: %s", http.StatusOK, response.Code, response.Body.String())
}
body := response.Body.String()
for _, want := range []string{
`data-video-browser`,
`data-active-index="1"`,
`href="/day/20260101/videos?idx=0"`,
`href="/day/20260101/videos?idx=1"`,
`src="/stream/video/20260101/record/A260101_120500_120530.265"`,
`src="/thumb/video/20260101/record/A260101_120000_120015.265"`,
`data-nav="prev"`,
`data-nav="next"`,
`keydown`,
`ArrowLeft`,
`ArrowRight`,
`ring-2 ring-indigo-400`,
} {
if !strings.Contains(body, want) {
t.Fatalf("expected response to contain %q\nbody:\n%s", want, body)
}
}
}
func newAuthenticatedRouter(t *testing.T) (http.Handler, *http.Cookie) {
t.Helper()

1
internal/web/templates/templates.go

@ -8,6 +8,7 @@ const (
Base = "base.html"
Day = "day.html"
Images = "images.html"
Videos = "videos.html"
)
// FS exposes file-backed HTML templates for handlers that render web pages.

111
internal/web/templates/videos.html

@ -0,0 +1,111 @@
{{define "content"}}
{{with .Content}}
<div data-video-browser data-date="{{.Date}}" data-active-index="{{.ActiveIndex}}" class="flex h-full min-h-[calc(100vh-7rem)] flex-col gap-4">
<div class="flex items-center justify-between gap-3">
<div>
<h1 class="text-lg font-semibold text-slate-100">{{.Date}}</h1>
<p class="text-sm text-slate-400">Videos</p>
</div>
{{if .Videos}}
<div class="flex items-center gap-2">
<a href="/day/{{.Date}}/videos?idx={{.PrevIndex}}" data-nav="prev" data-index="{{.PrevIndex}}" class="rounded-md border border-slate-700 px-3 py-2 text-sm font-medium text-slate-100 hover:bg-slate-800">Prev</a>
<a href="/day/{{.Date}}/videos?idx={{.NextIndex}}" data-nav="next" data-index="{{.NextIndex}}" class="rounded-md border border-slate-700 px-3 py-2 text-sm font-medium text-slate-100 hover:bg-slate-800">Next</a>
</div>
{{end}}
</div>
{{if .Videos}}
<div class="h-28 shrink-0 overflow-x-auto border-y border-slate-800 py-3">
<div class="flex h-full gap-3">
{{range .Videos}}
<a href="/day/{{$.Content.Date}}/videos?idx={{.Index}}" data-video-item data-index="{{.Index}}" data-stream-url="{{.StreamURL}}" data-thumb-url="{{.ThumbURL}}" class="relative h-full w-40 shrink-0 overflow-hidden rounded-md border border-slate-800 bg-slate-950 {{if .Active}}ring-2 ring-indigo-400{{end}}">
<img src="{{.ThumbURL}}" alt="{{.Filename}}" class="h-full w-full object-cover">
<span class="absolute bottom-2 right-2 rounded bg-slate-950/85 px-2 py-1 text-xs font-medium text-white">{{.DurationLabel}}</span>
</a>
{{end}}
</div>
</div>
<div class="relative min-h-0 flex-1 overflow-hidden bg-black">
<video data-video-player controls autoplay poster="{{.Current.ThumbURL}}" src="{{.Current.StreamURL}}" class="h-full max-h-[calc(100vh-15rem)] min-h-[18rem] w-full object-contain"></video>
<a href="/day/{{.Date}}/videos?idx={{.PrevIndex}}" data-nav="prev" data-index="{{.PrevIndex}}" class="absolute left-3 top-1/2 -translate-y-1/2 rounded-md bg-slate-950/80 px-3 py-2 text-sm font-medium text-white hover:bg-slate-900">Prev</a>
<a href="/day/{{.Date}}/videos?idx={{.NextIndex}}" data-nav="next" data-index="{{.NextIndex}}" class="absolute right-3 top-1/2 -translate-y-1/2 rounded-md bg-slate-950/80 px-3 py-2 text-sm font-medium text-white hover:bg-slate-900">Next</a>
</div>
<script>
(() => {
const root = document.querySelector("[data-video-browser]");
if (!root) return;
const player = root.querySelector("[data-video-player]");
const items = Array.from(root.querySelectorAll("[data-video-item]"));
const prevLinks = Array.from(root.querySelectorAll('[data-nav="prev"]'));
const nextLinks = Array.from(root.querySelectorAll('[data-nav="next"]'));
const date = root.dataset.date;
let active = Number(root.dataset.activeIndex || "0");
const setNav = (links, index) => {
links.forEach((link) => {
link.dataset.index = String(index);
link.href = `/day/${date}/videos?idx=${index}`;
});
};
const setActive = (index) => {
if (!player || items.length === 0) return;
active = ((index % items.length) + items.length) % items.length;
const item = items[active];
const prev = (active - 1 + items.length) % items.length;
const next = (active + 1) % items.length;
root.dataset.activeIndex = String(active);
player.src = item.dataset.streamUrl || "";
player.poster = item.dataset.thumbUrl || "";
player.load();
void player.play().catch(() => {});
setNav(prevLinks, prev);
setNav(nextLinks, next);
items.forEach((link, itemIndex) => {
link.classList.toggle("ring-2", itemIndex === active);
link.classList.toggle("ring-indigo-400", itemIndex === active);
});
const url = new URL(window.location.href);
url.searchParams.set("idx", String(active));
window.history.pushState({ idx: active }, "", url.toString());
};
window.history.replaceState({ idx: active }, "", window.location.href);
prevLinks.forEach((link) => {
link.addEventListener("click", (event) => {
event.preventDefault();
setActive(Number(link.dataset.index || "0"));
});
});
nextLinks.forEach((link) => {
link.addEventListener("click", (event) => {
event.preventDefault();
setActive(Number(link.dataset.index || "0"));
});
});
items.forEach((item, index) => {
item.addEventListener("click", (event) => {
event.preventDefault();
setActive(index);
});
});
window.addEventListener("keydown", (event) => {
if (event.key === "ArrowLeft") setActive(active - 1);
if (event.key === "ArrowRight") setActive(active + 1);
});
})();
</script>
{{else}}
<div class="border border-slate-800 bg-slate-950 p-6 text-sm text-slate-400">No videos for this day.</div>
{{end}}
</div>
{{end}}
{{end}}
Loading…
Cancel
Save