feat: add metrics, observability, test fixtures, and scaling playbook artifacts
CD / deploy (push) Successful in 57s
CI / test (push) Failing after 1m29s
CI / docker (push) Skipped

This commit is contained in:
zwitschi committed 2026-08-10 00:23:50 +02:00
1 parent 926a663c23
commit f9aba11b90
32 files changed
+1788 -124

No files matched your search

+245 -40
View File
@@ -1,65 +1,97 @@
package main
// Test fixture generation commands (run from repository root):
//
// Valid 720p30 fixture (1.5s, H.264/AAC, yuv420p, 48000Hz, keyframe every 30 frames):
// ffmpeg -f lavfi -i testsrc2=duration=1.5:size=1280x720:rate=30 \
// -f lavfi -i sine=frequency=440:duration=1.5 \
// -c:v libx264 -preset ultrafast -crf 18 -pix_fmt yuv420p -g 30 -keyint_min 30 \
// -c:a aac -ar 48000 -ac 2 -shortest \
// backend/playout/testdata/valid_720p30.mp4
//
// Invalid h265 fixture (1.5s, HEVC, otherwise compliant):
// ffmpeg -f lavfi -i testsrc2=duration=1.5:size=1280x720:rate=30 \
// -f lavfi -i sine=frequency=440:duration=1.5 \
// -c:v libx265 -preset ultrafast -crf 28 -pix_fmt yuv420p -g 30 -keyint_min 30 \
// -c:a aac -ar 48000 -ac 2 -shortest \
// -tag:v hvc1 backend/playout/testdata/invalid_h265.mp4
//
// Invalid 24fps fixture (1.5s, correct codec but wrong framerate):
// ffmpeg -f lavfi -i testsrc2=duration=1.5:size=1280x720:rate=24 \
// -f lavfi -i sine=frequency=440:duration=1.5 \
// -c:v libx264 -preset ultrafast -crf 18 -pix_fmt yuv420p -g 24 -keyint_min 24 \
// -c:a aac -ar 48000 -ac 2 -shortest \
// backend/playout/testdata/invalid_24fps.mp4
//
// Test playlist fixtures are plain text files with paths relative to /app/media.
// See backend/playout/testdata/*.txt.
import (
"io"
"net/http"
"net/http/httptest"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
"time"
"github.com/aws/aws-sdk-go-v2/aws"
"github.com/aws/aws-sdk-go-v2/service/s3"
)
func TestLoadPlaylist(t *testing.T) {
tmpDir := t.TempDir()
func ensureFFProbeInPath(t *testing.T) {
t.Helper()
if _, err := exec.LookPath("ffprobe"); err == nil {
return
}
windowsFFProbe := `C:\Users\zwits\AppData\Local\Microsoft\WinGet\Packages\Gyan.FFmpeg_Microsoft.Winget.Source_8wekyb3d8bbwe\ffmpeg-9.0-full_build\bin\ffprobe.exe`
if _, err := os.Stat(windowsFFProbe); err == nil {
t.Setenv("PATH", filepath.Dir(windowsFFProbe)+string(os.PathListSeparator)+os.Getenv("PATH"))
return
}
t.Skip("ffprobe not available on PATH and Windows WinGet install path not found")
}
func TestLoadPlaylist(t *testing.T) {
tests := []struct {
name string
content string
wantLen int
wantErr bool
first string
name string
file string
wantLen int
wantErr bool
first string
}{
{
name: "simple paths",
content: "/videos/video1.mp4\n/videos/video2.mp4\n",
name: "valid playlist",
file: "valid_playlist.txt",
wantLen: 2,
first: "/videos/video1.mp4",
first: "/app/media/demo1.mp4",
},
{
name: "with file prefix",
content: "file /videos/video1.mp4\nfile '/videos/video2.mp4'\n",
wantLen: 2,
first: "/videos/video1.mp4",
},
{
name: "with file prefix double quotes",
content: "file \"/videos/video1.mp4\"\n",
wantLen: 1,
first: "/videos/video1.mp4",
},
{
name: "with comments and blanks",
content: "# comment\n\n/videos/video1.mp4\n\n# another\n/videos/video2.mp4\n",
wantLen: 2,
first: "/videos/video1.mp4",
},
{
name: "empty file",
content: "",
name: "empty playlist",
file: "empty_playlist.txt",
wantLen: 0,
},
{
name: "only comments",
content: "# comment\n# another\n",
name: "comments only",
file: "comments_only.txt",
wantLen: 0,
},
{
name: "mixed valid invalid",
file: "mixed_valid_invalid.txt",
wantLen: 3,
first: "/app/media/demo1.mp4",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
path := filepath.Join(tmpDir, tt.name+".txt")
os.WriteFile(path, []byte(tt.content), 0644)
path := filepath.Join("..", "testdata", tt.file)
videos, err := LoadPlaylist(path)
if tt.wantErr && err == nil {
@@ -149,10 +181,60 @@ func TestValidateKeyframeInterval(t *testing.T) {
}
func TestValidateVideoStrict(t *testing.T) {
// ValidateVideoStrict calls ValidateVideo which calls ffprobe
// Test only the result structure when ValidateVideo returns valid=false
// Mock not feasible without full ffprobe mock, skip for now
t.Skip("requires ffprobe mock or test media fixtures")
ensureFFProbeInPath(t)
tests := []struct {
name string
file string
wantValid bool
wantErr string
}{
{
name: "valid h264 fixture",
file: "valid_720p30.mp4",
wantValid: true,
},
{
name: "invalid h265 fixture",
file: "invalid_h265.mp4",
wantErr: "video codec must be h264",
},
{
name: "invalid framerate fixture",
file: "invalid_24fps.mp4",
wantErr: "framerate must be 30/1",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
path := filepath.Join("..", "testdata", tt.file)
result := ValidateVideoStrict(path)
valid, _ := result["valid"].(bool)
if valid != tt.wantValid {
t.Fatalf("valid = %v, want %v", valid, tt.wantValid)
}
errs, _ := result["errors"].([]string)
if tt.wantErr == "" {
if len(errs) != 0 {
t.Fatalf("errors = %v, want none", errs)
}
if got, ok := result["duration_seconds"].(float64); !ok || got <= 0 {
t.Fatalf("duration_seconds = %v, want positive duration", result["duration_seconds"])
}
return
}
if len(errs) == 0 {
t.Fatalf("errors = %v, want message containing %q", errs, tt.wantErr)
}
if !strings.Contains(strings.Join(errs, "; "), tt.wantErr) {
t.Fatalf("errors = %v, want message containing %q", errs, tt.wantErr)
}
})
}
}
func TestHealthHandler(t *testing.T) {
@@ -348,6 +430,129 @@ func TestValidatePlaylist(t *testing.T) {
}
func TestSyncToR2(t *testing.T) {
// Requires R2/S3 mock
t.Skip("requires S3 client mock")
prevClient := r2Client
prevBucket := bucket
prevPutObject := putObject
defer func() {
r2Client = prevClient
bucket = prevBucket
putObject = prevPutObject
}()
tmpDir := t.TempDir()
localPath := filepath.Join(tmpDir, "segment.m4s")
if err := os.WriteFile(localPath, []byte("segment-bytes"), 0o644); err != nil {
t.Fatalf("write fixture: %v", err)
}
bucket = "linearvoid-media"
r2Client = &s3.Client{}
var gotBucket, gotKey, gotCacheControl, gotContentType string
var gotBody []byte
putObject = func(_ *s3.Client, input *s3.PutObjectInput) (*s3.PutObjectOutput, error) {
gotBucket = aws.ToString(input.Bucket)
gotKey = aws.ToString(input.Key)
gotCacheControl = aws.ToString(input.CacheControl)
gotContentType = aws.ToString(input.ContentType)
body, err := io.ReadAll(input.Body)
if err != nil {
return nil, err
}
gotBody = body
return &s3.PutObjectOutput{}, nil
}
if err := SyncToR2(localPath, "live/segment.m4s"); err != nil {
t.Fatalf("SyncToR2 error: %v", err)
}
if gotBucket != "linearvoid-media" {
t.Fatalf("bucket = %q, want %q", gotBucket, "linearvoid-media")
}
if gotKey != "live/segment.m4s" {
t.Fatalf("key = %q, want %q", gotKey, "live/segment.m4s")
}
if string(gotBody) != "segment-bytes" {
t.Fatalf("body = %q, want %q", string(gotBody), "segment-bytes")
}
if gotCacheControl != segmentCacheControl {
t.Fatalf("CacheControl = %q, want %q", gotCacheControl, segmentCacheControl)
}
if gotContentType != "video/MP2T" {
t.Fatalf("ContentType = %q, want %q", gotContentType, "video/MP2T")
}
}
func TestSyncToR2_ManifestCacheHeaders(t *testing.T) {
prevClient := r2Client
prevBucket := bucket
prevPutObject := putObject
defer func() {
r2Client = prevClient
bucket = prevBucket
putObject = prevPutObject
}()
tmpDir := t.TempDir()
localPath := filepath.Join(tmpDir, "playlist.m3u8")
if err := os.WriteFile(localPath, []byte("#EXTM3U\n"), 0o644); err != nil {
t.Fatalf("write fixture: %v", err)
}
bucket = "linearvoid-media"
r2Client = &s3.Client{}
var gotCacheControl, gotContentType string
putObject = func(_ *s3.Client, input *s3.PutObjectInput) (*s3.PutObjectOutput, error) {
gotCacheControl = aws.ToString(input.CacheControl)
gotContentType = aws.ToString(input.ContentType)
return &s3.PutObjectOutput{}, nil
}
if err := SyncToR2(localPath, "hls/playlist.m3u8"); err != nil {
t.Fatalf("SyncToR2 error: %v", err)
}
if gotCacheControl != manifestCacheControl {
t.Fatalf("CacheControl = %q, want %q", gotCacheControl, manifestCacheControl)
}
if gotContentType != "application/vnd.apple.mpegurl" {
t.Fatalf("ContentType = %q, want %q", gotContentType, "application/vnd.apple.mpegurl")
}
}
func TestSyncToR2_NoCacheHeadersForUnknownExt(t *testing.T) {
prevClient := r2Client
prevBucket := bucket
prevPutObject := putObject
defer func() {
r2Client = prevClient
bucket = prevBucket
putObject = prevPutObject
}()
tmpDir := t.TempDir()
localPath := filepath.Join(tmpDir, "data.bin")
if err := os.WriteFile(localPath, []byte("raw"), 0o644); err != nil {
t.Fatalf("write fixture: %v", err)
}
bucket = "linearvoid-media"
r2Client = &s3.Client{}
var gotCacheControl, gotContentType string
putObject = func(_ *s3.Client, input *s3.PutObjectInput) (*s3.PutObjectOutput, error) {
gotCacheControl = aws.ToString(input.CacheControl)
gotContentType = aws.ToString(input.ContentType)
return &s3.PutObjectOutput{}, nil
}
if err := SyncToR2(localPath, "misc/data.bin"); err != nil {
t.Fatalf("SyncToR2 error: %v", err)
}
if gotCacheControl != "" {
t.Fatalf("CacheControl = %q, want empty", gotCacheControl)
}
if gotContentType != "" {
t.Fatalf("ContentType = %q, want empty", gotContentType)
}
}