feat: add metrics, observability, test fixtures, and scaling playbook artifacts
This commit is contained in:
1 parent
926a663c23
commit
f9aba11b90
32 files changed
+1788
-124
No files matched your search
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user