You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
300 lines
8.5 KiB
300 lines
8.5 KiB
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)
|
|
}
|
|
}
|
|
|