Browse Source

feat(footage): index camera footage by day

main
Domagoj Zecevic 2 days ago
parent
commit
9b5f1d08d4
  1. 40
      .ai/HANDOFF.md
  2. 42
      .ai/REVIEW.md
  3. 2
      .ai/TASKS.md
  4. 6
      README.md
  5. 6
      cmd/server/main.go
  6. 104
      internal/footage/index.go
  7. 96
      internal/footage/index_test.go
  8. 212
      internal/footage/scanner.go
  9. 89
      internal/footage/scanner_test.go
  10. 3
      internal/web/router.go
  11. 1
      testdata/footage/20260101/images/A26010112000001.jpg
  12. 1
      testdata/footage/20260101/images/A26010112050001.jpg
  13. 1
      testdata/footage/20260101/record/A260101_120000_120015.265
  14. 1
      testdata/footage/20260101/record/A260101_120500_120530.265
  15. 1
      testdata/footage/20260102/images/A26010213000001.jpg

40
.ai/HANDOFF.md

@ -4,6 +4,20 @@ Append-only role handoff log. Each role adds one entry when its step is complete
---
### T-003 — review — 2026-06-18T11:05:00Z
| Field | Value |
|-------|-------|
| Agent | claude |
| Summary | Reviewed T-003 footage scanner; all 7 tests pass including race detector; two nit-level findings only — no blockers. |
| Files Changed | .ai/REVIEW.md, .ai/TASKS.md, .ai/HANDOFF.md |
| Validation | `go fmt ./...`, `go vet ./...`, `go test ./internal/footage/... -v`, `go test -race ./...` all pass. |
| Verdict | PASS_WITH_NOTES |
| Blocking Findings | none |
| Next Role | implement |
---
### T-002 — review — 2026-06-18T10:50:00Z
| Field | Value |
@ -45,6 +59,32 @@ Append-only role handoff log. Each role adds one entry when its step is complete
---
### T-003 — implement — 2026-06-18T09:04:41Z
| Field | Value |
|-------|-------|
| Agent | codex |
| Summary | Added the footage scanner, in-memory day index, startup wiring, fixture coverage, and README scanner documentation. |
| Files Changed | .ai/TASKS.md, .ai/HANDOFF.md, README.md, cmd/server/main.go, internal/web/router.go, internal/footage/scanner.go, internal/footage/index.go, internal/footage/scanner_test.go, internal/footage/index_test.go, testdata/footage/20260101/images/A26010112000001.jpg, testdata/footage/20260101/images/A26010112050001.jpg, testdata/footage/20260101/record/A260101_120000_120015.265, testdata/footage/20260101/record/A260101_120500_120530.265, testdata/footage/20260102/images/A26010213000001.jpg |
| Validation | `go fmt ./...` passed; `go vet ./...` passed; `go test ./internal/footage/...` passed; `go test ./...` passed; `go test -race ./...` passed |
| Commit | `feat(footage): index camera footage by day` |
| Next Role | review |
---
### T-003 — implement — 2026-06-18T13:03:48Z
| Field | Value |
|-------|-------|
| Agent | codex |
| Summary | Committed T-003 footage scanner after review approval. |
| Files Changed | .ai/TASKS.md, .ai/HANDOFF.md |
| Validation | n/a (commit_task) |
| Commit | `pending feat(footage): index camera footage by day` |
| Next Role | none |
---
### T-001 — review — 2026-06-18T10:20:00Z
| Field | Value |

42
.ai/REVIEW.md

@ -124,3 +124,45 @@ None.
- When T-004 adds `base.html` to the embed glob, template parse order matters; the implementer should parse base first or use `template.ParseFS(..., "base.html", "admin_users.html")` to ensure the base is available before extension templates.
---
## T-003 — Footage scanner
**Verdict:** PASS_WITH_NOTES
### Findings
| # | Severity | File | Description | Required fix? |
|---|----------|------|-------------|---------------|
| 1 | nit | `internal/footage/index.go` (line 83) | `rescan()` silently discards `Scan()` errors — the index silently retains stale data with no log output. Hard to diagnose filesystem issues in production. | no |
| 2 | nit | `internal/web/router.go` (line 14) | `_ *footage.Index` parameter accepted but still ignored. Correct for T-003 scope; will be wired in T-004. | no |
### Required fixes
None.
### Verification
**Steps performed:**
1. Read all new/changed files: `internal/footage/scanner.go`, `internal/footage/index.go`, `internal/footage/scanner_test.go`, `internal/footage/index_test.go`, `cmd/server/main.go`, `internal/web/router.go`.
2. Cross-checked against `.ai/PLAN.md` Phase 3 scope — all required types, functions, and behaviours implemented.
3. Inspected fixture files in `testdata/footage/` — 5 files across 2 day directories, correct naming pattern, 8 bytes each.
4. Checked regex patterns against fixture filenames: image `^A(\d{6})(\d{6})\d+\.jpg$` and video `^A(\d{6})_(\d{6})_(\d{6})\.265$` both match.
5. Verified time parsing format `"060102150405"` (YYMMDD + HHMMSS) produces correct timestamps for fixture filenames.
6. Ran `go fmt ./...` — clean.
7. Ran `go vet ./...` — clean.
8. Ran `go test ./internal/footage/... -v` — all 7 tests PASS.
9. Ran `go test -race ./...` — PASS, no data races (cached + fresh run).
**Findings:**
- All acceptance criteria met: fixture directory indexed correctly (2 days, correct image/video counts), `DayList()` returns newest-first sorted dates, `DayEntry` contains timestamp-sorted images and start-time-sorted videos, periodic rescan confirmed by `TestIndexPeriodicRescan`, race detector clean on `TestIndexConcurrentAccessDuringRescan` (8 goroutines × 100 iterations).
- `copyDayEntry` defensively copies slices on every `Day()` call — callers cannot mutate internal index state. ✅
- `Close()` uses `sync.Once` around `close(done)` — safe to call multiple times. ✅
- `NewIndex` does an initial synchronous `rescan()` before starting the goroutine — index is always non-empty on first use if footage exists. ✅
- Midnight-spanning clips handled: `if end.Before(start) { end = end.Add(24 * time.Hour) }`. ✅
- Missing `images/` or `record/` subdirectory returns `nil, nil` (not an error) — scanner gracefully handles partial day directories. ✅
- `main.go` wires `footage.NewIndex` and defers `Close()` correctly. ✅
**Risks:**
- Silent error discard in `rescan()` (finding #1) is low risk for correctness but could delay diagnosis of a footage mount problem in production.
---

2
.ai/TASKS.md

@ -22,7 +22,7 @@ Command expectations:
| --- | --- | --- | --- | --- | --- |
| 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 | done | 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 | `go fmt ./...`, `go vet ./...`, `go test ./...`, `go test -race ./...`, `go test ./internal/auth/...` passed after template rework | none |
| 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-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 | 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 |

6
README.md

@ -2,7 +2,7 @@
## Overview
CamMonitor is a self-hosted security camera footage viewer. It provides the Go HTTP server, a `/health` endpoint, environment-based configuration, SQLite startup and migrations, bcrypt login, session cookies, admin user management, and Docker Compose deployment.
CamMonitor is a self-hosted security camera footage viewer. It provides the Go HTTP server, a `/health` endpoint, environment-based configuration, SQLite startup and migrations, bcrypt login, session cookies, admin user management, an in-memory footage index, and Docker Compose deployment.
## Getting Started
@ -55,7 +55,9 @@ Admin users can manage accounts at `/admin/users`. New users are created with bc
## Footage Layout
Footage is mounted read-only at `/footage` in the container. The planned directory format is:
Footage is mounted read-only at `/footage` in the container. CamMonitor scans this tree on startup and then rescans it on the `SCAN_INTERVAL` schedule. Days are indexed from `YYYYMMDD` directories, with images and videos sorted chronologically inside each day.
The expected directory format is:
```text
YYYYMMDD/

6
cmd/server/main.go

@ -8,6 +8,7 @@ import (
"github.com/domagojzecevic/cammonitor/internal/auth"
"github.com/domagojzecevic/cammonitor/internal/config"
"github.com/domagojzecevic/cammonitor/internal/db"
"github.com/domagojzecevic/cammonitor/internal/footage"
"github.com/domagojzecevic/cammonitor/internal/web"
)
@ -29,7 +30,10 @@ func main() {
}
go purgeExpiredSessions(authStore)
router := web.NewRouter(cfg, database, nil)
footageIndex := footage.NewIndex(cfg.FootageRoot, cfg.ScanInterval)
defer footageIndex.Close()
router := web.NewRouter(cfg, database, footageIndex)
log.Printf("listening on %s", cfg.ListenAddr)
if err := http.ListenAndServe(cfg.ListenAddr, router); err != nil {

104
internal/footage/index.go

@ -0,0 +1,104 @@
package footage
import (
"sort"
"sync"
"time"
)
type Index struct {
root string
interval time.Duration
mu sync.RWMutex
days map[string]DayEntry
done chan struct{}
once sync.Once
}
func NewIndex(root string, interval time.Duration) *Index {
index := &Index{
root: root,
interval: interval,
days: make(map[string]DayEntry),
done: make(chan struct{}),
}
index.rescan()
if interval > 0 {
go index.run()
}
return index
}
func (i *Index) DayList() []string {
i.mu.RLock()
defer i.mu.RUnlock()
days := make([]string, 0, len(i.days))
for day := range i.days {
days = append(days, day)
}
sort.Sort(sort.Reverse(sort.StringSlice(days)))
return days
}
func (i *Index) Day(date string) (*DayEntry, bool) {
i.mu.RLock()
defer i.mu.RUnlock()
entry, ok := i.days[date]
if !ok {
return nil, false
}
copied := copyDayEntry(entry)
return &copied, true
}
func (i *Index) Close() {
i.once.Do(func() {
close(i.done)
})
}
func (i *Index) run() {
ticker := time.NewTicker(i.interval)
defer ticker.Stop()
for {
select {
case <-ticker.C:
i.rescan()
case <-i.done:
return
}
}
}
func (i *Index) rescan() {
days, err := Scan(i.root)
if err != nil {
return
}
next := make(map[string]DayEntry, len(days))
for _, day := range days {
next[day.Date] = copyDayEntry(day)
}
i.mu.Lock()
i.days = next
i.mu.Unlock()
}
func copyDayEntry(entry DayEntry) DayEntry {
copied := DayEntry{
Date: entry.Date,
Images: append([]ImageFile(nil), entry.Images...),
Videos: append([]VideoFile(nil), entry.Videos...),
}
return copied
}

96
internal/footage/index_test.go

@ -0,0 +1,96 @@
package footage
import (
"os"
"path/filepath"
"sync"
"testing"
"time"
)
func TestIndexListsDaysNewestFirstAndReturnsCopies(t *testing.T) {
root := filepath.Join("..", "..", "testdata", "footage")
index := NewIndex(root, time.Hour)
t.Cleanup(index.Close)
days := index.DayList()
if len(days) != 2 {
t.Fatalf("expected 2 days, got %d", len(days))
}
if days[0] != "20260102" || days[1] != "20260101" {
t.Fatalf("expected days sorted newest first, got %#v", days)
}
entry, ok := index.Day("20260101")
if !ok {
t.Fatalf("expected day 20260101")
}
entry.Images = nil
entryAgain, ok := index.Day("20260101")
if !ok {
t.Fatalf("expected day 20260101 on second lookup")
}
if len(entryAgain.Images) != 2 {
t.Fatalf("expected index day copy to preserve images, got %d", len(entryAgain.Images))
}
}
func TestIndexPeriodicRescan(t *testing.T) {
root := t.TempDir()
writeFixtureFile(t, root, filepath.Join("20260101", "images", "A26010112000001.jpg"))
index := NewIndex(root, 10*time.Millisecond)
t.Cleanup(index.Close)
if days := index.DayList(); len(days) != 1 || days[0] != "20260101" {
t.Fatalf("expected initial day 20260101, got %#v", days)
}
writeFixtureFile(t, root, filepath.Join("20260102", "record", "A260102_130000_130015.265"))
deadline := time.Now().Add(time.Second)
for time.Now().Before(deadline) {
days := index.DayList()
if len(days) == 2 && days[0] == "20260102" && days[1] == "20260101" {
return
}
time.Sleep(10 * time.Millisecond)
}
t.Fatalf("timed out waiting for rescan to include new day, got %#v", index.DayList())
}
func TestIndexConcurrentAccessDuringRescan(t *testing.T) {
root := t.TempDir()
writeFixtureFile(t, root, filepath.Join("20260101", "images", "A26010112000001.jpg"))
index := NewIndex(root, time.Millisecond)
t.Cleanup(index.Close)
var wg sync.WaitGroup
for worker := 0; worker < 8; worker++ {
wg.Add(1)
go func() {
defer wg.Done()
for i := 0; i < 100; i++ {
_ = index.DayList()
_, _ = index.Day("20260101")
}
}()
}
wg.Wait()
}
func writeFixtureFile(t *testing.T, root, relPath string) {
t.Helper()
path := filepath.Join(root, relPath)
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
t.Fatalf("create fixture dir: %v", err)
}
if err := os.WriteFile(path, []byte("fixture"), 0o644); err != nil {
t.Fatalf("write fixture file: %v", err)
}
}

212
internal/footage/scanner.go

@ -0,0 +1,212 @@
package footage
import (
"fmt"
"os"
"path/filepath"
"regexp"
"sort"
"time"
)
var (
imageFilenamePattern = regexp.MustCompile(`^A(\d{6})(\d{6})\d+\.jpg$`)
videoFilenamePattern = regexp.MustCompile(`^A(\d{6})_(\d{6})_(\d{6})\.265$`)
)
type DayEntry struct {
Date string
Images []ImageFile
Videos []VideoFile
}
type ImageFile struct {
RelPath string
Filename string
Timestamp time.Time
}
type VideoFile struct {
RelPath string
Filename string
StartTime time.Time
EndTime time.Time
Duration time.Duration
}
func Scan(root string) ([]DayEntry, error) {
dayDirs, err := os.ReadDir(root)
if err != nil {
if os.IsNotExist(err) {
return nil, nil
}
return nil, fmt.Errorf("read footage root: %w", err)
}
days := make([]DayEntry, 0, len(dayDirs))
for _, dayDir := range dayDirs {
if !dayDir.IsDir() {
continue
}
if _, err := time.ParseInLocation("20060102", dayDir.Name(), time.Local); err != nil {
continue
}
entry := DayEntry{Date: dayDir.Name()}
dayPath := filepath.Join(root, dayDir.Name())
images, err := scanImages(root, dayPath)
if err != nil {
return nil, err
}
entry.Images = images
videos, err := scanVideos(root, dayPath)
if err != nil {
return nil, err
}
entry.Videos = videos
days = append(days, entry)
}
sort.Slice(days, func(i, j int) bool {
return days[i].Date > days[j].Date
})
return days, nil
}
func scanImages(root, dayPath string) ([]ImageFile, error) {
imageDir := filepath.Join(dayPath, "images")
entries, err := os.ReadDir(imageDir)
if err != nil {
if os.IsNotExist(err) {
return nil, nil
}
return nil, fmt.Errorf("read image dir %s: %w", imageDir, err)
}
images := make([]ImageFile, 0, len(entries))
for _, entry := range entries {
if entry.IsDir() {
continue
}
info, err := entry.Info()
if err != nil {
return nil, fmt.Errorf("read image info %s: %w", entry.Name(), err)
}
if !info.Mode().Type().IsRegular() {
continue
}
image, err := parseImageFilename(entry.Name())
if err != nil {
continue
}
image.RelPath = mustRel(root, filepath.Join(imageDir, entry.Name()))
images = append(images, image)
}
sort.Slice(images, func(i, j int) bool {
if images[i].Timestamp.Equal(images[j].Timestamp) {
return images[i].Filename < images[j].Filename
}
return images[i].Timestamp.Before(images[j].Timestamp)
})
return images, nil
}
func scanVideos(root, dayPath string) ([]VideoFile, error) {
recordDir := filepath.Join(dayPath, "record")
entries, err := os.ReadDir(recordDir)
if err != nil {
if os.IsNotExist(err) {
return nil, nil
}
return nil, fmt.Errorf("read record dir %s: %w", recordDir, err)
}
videos := make([]VideoFile, 0, len(entries))
for _, entry := range entries {
if entry.IsDir() {
continue
}
info, err := entry.Info()
if err != nil {
return nil, fmt.Errorf("read video info %s: %w", entry.Name(), err)
}
if !info.Mode().Type().IsRegular() {
continue
}
video, err := parseVideoFilename(entry.Name())
if err != nil {
continue
}
video.RelPath = mustRel(root, filepath.Join(recordDir, entry.Name()))
videos = append(videos, video)
}
sort.Slice(videos, func(i, j int) bool {
if videos[i].StartTime.Equal(videos[j].StartTime) {
return videos[i].Filename < videos[j].Filename
}
return videos[i].StartTime.Before(videos[j].StartTime)
})
return videos, nil
}
func parseImageFilename(name string) (ImageFile, error) {
matches := imageFilenamePattern.FindStringSubmatch(name)
if matches == nil {
return ImageFile{}, fmt.Errorf("invalid image filename %q", name)
}
timestamp, err := time.ParseInLocation("060102150405", matches[1]+matches[2], time.Local)
if err != nil {
return ImageFile{}, fmt.Errorf("parse image timestamp %q: %w", name, err)
}
return ImageFile{
Filename: name,
Timestamp: timestamp,
}, nil
}
func parseVideoFilename(name string) (VideoFile, error) {
matches := videoFilenamePattern.FindStringSubmatch(name)
if matches == nil {
return VideoFile{}, fmt.Errorf("invalid video filename %q", name)
}
start, err := time.ParseInLocation("060102150405", matches[1]+matches[2], time.Local)
if err != nil {
return VideoFile{}, fmt.Errorf("parse video start timestamp %q: %w", name, err)
}
end, err := time.ParseInLocation("060102150405", matches[1]+matches[3], time.Local)
if err != nil {
return VideoFile{}, fmt.Errorf("parse video end timestamp %q: %w", name, err)
}
if end.Before(start) {
end = end.Add(24 * time.Hour)
}
return VideoFile{
Filename: name,
StartTime: start,
EndTime: end,
Duration: end.Sub(start),
}, nil
}
func mustRel(root, path string) string {
relPath, err := filepath.Rel(root, path)
if err != nil {
return path
}
return relPath
}

89
internal/footage/scanner_test.go

@ -0,0 +1,89 @@
package footage
import (
"path/filepath"
"testing"
"time"
)
func TestScanIndexesFixtureFootage(t *testing.T) {
root := filepath.Join("..", "..", "testdata", "footage")
days, err := Scan(root)
if err != nil {
t.Fatalf("scan fixture footage: %v", err)
}
if len(days) != 2 {
t.Fatalf("expected 2 days, got %d", len(days))
}
if days[0].Date != "20260102" || days[1].Date != "20260101" {
t.Fatalf("expected days sorted newest first, got %#v", []string{days[0].Date, days[1].Date})
}
day := days[1]
if len(day.Images) != 2 {
t.Fatalf("expected 2 images for %s, got %d", day.Date, len(day.Images))
}
if got := day.Images[0].RelPath; got != filepath.Join("20260101", "images", "A26010112000001.jpg") {
t.Fatalf("unexpected first image path %q", got)
}
if got := day.Images[1].RelPath; got != filepath.Join("20260101", "images", "A26010112050001.jpg") {
t.Fatalf("unexpected second image path %q", got)
}
if !day.Images[0].Timestamp.Before(day.Images[1].Timestamp) {
t.Fatalf("expected images sorted by timestamp ascending")
}
if len(day.Videos) != 2 {
t.Fatalf("expected 2 videos for %s, got %d", day.Date, len(day.Videos))
}
if got := day.Videos[0].RelPath; got != filepath.Join("20260101", "record", "A260101_120000_120015.265") {
t.Fatalf("unexpected first video path %q", got)
}
if got := day.Videos[0].Duration; got != 15*time.Second {
t.Fatalf("expected first video duration 15s, got %s", got)
}
if !day.Videos[0].StartTime.Before(day.Videos[1].StartTime) {
t.Fatalf("expected videos sorted by start time ascending")
}
}
func TestParseImageFilename(t *testing.T) {
image, err := parseImageFilename("A26010112000001.jpg")
if err != nil {
t.Fatalf("parse image filename: %v", err)
}
if image.Filename != "A26010112000001.jpg" {
t.Fatalf("unexpected filename %q", image.Filename)
}
want := time.Date(2026, time.January, 1, 12, 0, 0, 0, time.Local)
if !image.Timestamp.Equal(want) {
t.Fatalf("expected timestamp %s, got %s", want, image.Timestamp)
}
}
func TestParseVideoFilename(t *testing.T) {
video, err := parseVideoFilename("A260101_120000_120015.265")
if err != nil {
t.Fatalf("parse video filename: %v", err)
}
if video.Filename != "A260101_120000_120015.265" {
t.Fatalf("unexpected filename %q", video.Filename)
}
if video.Duration != 15*time.Second {
t.Fatalf("expected duration 15s, got %s", video.Duration)
}
}
func TestParseFilenameRejectsUnknownPattern(t *testing.T) {
if _, err := parseImageFilename("camera.jpg"); err == nil {
t.Fatalf("expected invalid image filename to fail")
}
if _, err := parseVideoFilename("camera.265"); err == nil {
t.Fatalf("expected invalid video filename to fail")
}
}

3
internal/web/router.go

@ -7,10 +7,11 @@ import (
"github.com/domagojzecevic/cammonitor/internal/auth"
"github.com/domagojzecevic/cammonitor/internal/config"
"github.com/domagojzecevic/cammonitor/internal/footage"
"github.com/go-chi/chi/v5"
)
func NewRouter(cfg *config.Config, database *sql.DB, _ any) chi.Router {
func NewRouter(cfg *config.Config, database *sql.DB, _ *footage.Index) chi.Router {
router := chi.NewRouter()
router.Get("/health", func(w http.ResponseWriter, _ *http.Request) {

1
testdata/footage/20260101/images/A26010112000001.jpg

@ -0,0 +1 @@
fixture

1
testdata/footage/20260101/images/A26010112050001.jpg

@ -0,0 +1 @@
fixture

1
testdata/footage/20260101/record/A260101_120000_120015.265

@ -0,0 +1 @@
fixture

1
testdata/footage/20260101/record/A260101_120500_120530.265

@ -0,0 +1 @@
fixture

1
testdata/footage/20260102/images/A26010213000001.jpg

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