16 changed files with 1164 additions and 4 deletions
@ -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." |
||||
|
} |
||||
|
} |
||||
|
} |
||||
@ -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, |
||||
|
)) |
||||
@ -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) |
||||
|
} |
||||
|
} |
||||
@ -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 |
||||
|
} |
||||
@ -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) |
||||
|
} |
||||
|
} |
||||
@ -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…
Reference in new issue