- Updated README.md to reflect changes in load testing commands and added usage examples for Bash scripts. - Converted PowerShell load testing scripts to Bash equivalents for better cross-platform compatibility. - Added new Bash scripts: run-loadtests.sh, summarize-artillery.sh, and summarize-k6.sh to mirror existing PowerShell functionality. - Updated admin-api.js and ws-chat.yml for consistency and improved performance metrics reporting. - Enhanced error handling and output formatting in summary scripts for better readability.
947 lines
27 KiB
Go
947 lines
27 KiB
Go
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 (
|
|
"context"
|
|
"fmt"
|
|
"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"
|
|
"github.com/aws/aws-sdk-go-v2/service/s3/types"
|
|
)
|
|
|
|
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
|
|
file string
|
|
wantLen int
|
|
wantErr bool
|
|
first string
|
|
}{
|
|
{
|
|
name: "valid playlist",
|
|
file: "valid_playlist.txt",
|
|
wantLen: 2,
|
|
first: "/app/media/demo1.mp4",
|
|
},
|
|
{
|
|
name: "empty playlist",
|
|
file: "empty_playlist.txt",
|
|
wantLen: 0,
|
|
},
|
|
{
|
|
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("..", "testdata", tt.file)
|
|
|
|
videos, err := LoadPlaylist(path)
|
|
if tt.wantErr && err == nil {
|
|
t.Error("expected error, got nil")
|
|
}
|
|
if !tt.wantErr && err != nil {
|
|
t.Errorf("unexpected error: %v", err)
|
|
}
|
|
if len(videos) != tt.wantLen {
|
|
t.Errorf("len = %d, want %d", len(videos), tt.wantLen)
|
|
}
|
|
if tt.wantLen > 0 && videos[0].Path != tt.first {
|
|
t.Errorf("videos[0].Path = %q, want %q", videos[0].Path, tt.first)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestLoadPlaylist_FileNotFound(t *testing.T) {
|
|
_, err := LoadPlaylist("/nonexistent/playlist.txt")
|
|
if err == nil {
|
|
t.Error("expected error for missing file, got nil")
|
|
}
|
|
}
|
|
|
|
func TestValidateKeyframeInterval(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
csvData string
|
|
wantErr bool
|
|
errText string
|
|
}{
|
|
{
|
|
name: "valid keyframes (gaps <=2s)",
|
|
csvData: "1,0.00\n0,0.50\n1,1.00\n0,1.50\n1,2.00\n0,2.50\n1,3.00\n",
|
|
wantErr: false,
|
|
},
|
|
{
|
|
name: "gap too large >2s",
|
|
csvData: "1,0.00\n0,1.00\n1,3.50\n0,4.00\n1,6.00\n",
|
|
wantErr: true,
|
|
errText: "keyframe interval too large",
|
|
},
|
|
{
|
|
name: "insufficient keyframes (<2)",
|
|
csvData: "1,0.00\n",
|
|
wantErr: true,
|
|
errText: "insufficient keyframes",
|
|
},
|
|
{
|
|
name: "empty output",
|
|
csvData: "",
|
|
wantErr: true,
|
|
errText: "keyframe probe failed",
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
// create temp ffprobe that outputs csvData
|
|
tmpDir := t.TempDir()
|
|
mockProbe := filepath.Join(tmpDir, "ffprobe")
|
|
var script string
|
|
if tt.csvData == "" {
|
|
script = "@echo off\necho\nexit /b 1\n"
|
|
} else {
|
|
script = "@echo off\necho " + strings.ReplaceAll(tt.csvData, "\n", "& echo ") + "\nexit /b 0\n"
|
|
}
|
|
os.WriteFile(mockProbe+".bat", []byte(script), 0755)
|
|
os.Setenv("PATH", tmpDir+string(os.PathListSeparator)+os.Getenv("PATH"))
|
|
defer os.Unsetenv("PATH")
|
|
|
|
err := validateKeyframeInterval("test.mp4")
|
|
if tt.wantErr && err == nil {
|
|
t.Error("expected error, got nil")
|
|
}
|
|
if !tt.wantErr && err != nil {
|
|
t.Errorf("unexpected error: %v", err)
|
|
}
|
|
if tt.wantErr && tt.errText != "" && err != nil {
|
|
if !strings.Contains(err.Error(), tt.errText) {
|
|
t.Errorf("error = %v, want containing %q", err, tt.errText)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestValidateVideoStrict(t *testing.T) {
|
|
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) {
|
|
// Save and restore globals
|
|
prevMode := playoutMode
|
|
prevCount := restartCount
|
|
prevRunID := runID
|
|
playoutMode = "live"
|
|
restartCount = 3
|
|
runID = "r-20260810T120000Z"
|
|
lastR2PubTime = time.Now()
|
|
defer func() {
|
|
playoutMode = prevMode
|
|
restartCount = prevCount
|
|
runID = prevRunID
|
|
}()
|
|
|
|
req := httptest.NewRequest("GET", "/health", nil)
|
|
w := httptest.NewRecorder()
|
|
HealthHandler(w, req)
|
|
|
|
if w.Code != http.StatusOK {
|
|
t.Errorf("status = %d, want 200", w.Code)
|
|
}
|
|
|
|
body := w.Body.String()
|
|
for _, want := range []string{
|
|
`"status":"healthy"`,
|
|
`"service":"playout"`,
|
|
`"mode":"live"`,
|
|
`"crash_count":3`,
|
|
`"run_id":"r-20260810T120000Z"`,
|
|
`"last_segment_seq"`,
|
|
`"consecutive_failures"`,
|
|
} {
|
|
if !strings.Contains(body, want) {
|
|
t.Errorf("body missing %q: %s", want, body)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestHealthHandler_Degraded(t *testing.T) {
|
|
prevMode := playoutMode
|
|
prevR2Pub := lastR2PubTime
|
|
playoutMode = "slate"
|
|
lastR2PubTime = time.Now() // fresh R2 pub = not unhealthy
|
|
defer func() {
|
|
playoutMode = prevMode
|
|
lastR2PubTime = prevR2Pub
|
|
}()
|
|
|
|
req := httptest.NewRequest("GET", "/health", nil)
|
|
w := httptest.NewRecorder()
|
|
HealthHandler(w, req)
|
|
|
|
if w.Code != http.StatusOK {
|
|
t.Errorf("status = %d, want 200", w.Code)
|
|
}
|
|
body := w.Body.String()
|
|
if !strings.Contains(body, `"degraded"`) {
|
|
t.Errorf("body missing degraded: %s", body)
|
|
}
|
|
}
|
|
|
|
func TestHealthHandler_Unhealthy(t *testing.T) {
|
|
prevMode := playoutMode
|
|
prevR2Pub := lastR2PubTime
|
|
playoutMode = "live"
|
|
lastR2PubTime = time.Now().Add(-30 * time.Second) // stale R2 = unhealthy
|
|
defer func() {
|
|
playoutMode = prevMode
|
|
lastR2PubTime = prevR2Pub
|
|
}()
|
|
|
|
req := httptest.NewRequest("GET", "/health", nil)
|
|
w := httptest.NewRecorder()
|
|
HealthHandler(w, req)
|
|
|
|
body := w.Body.String()
|
|
if !strings.Contains(body, `"unhealthy"`) {
|
|
t.Errorf("body missing unhealthy (stale R2 pub should trigger unhealthy): %s", body)
|
|
}
|
|
}
|
|
|
|
func TestShouldEscalateToSlate(t *testing.T) {
|
|
// Save globals
|
|
prevCount := restartCount
|
|
prevWindow := restartWindow
|
|
defer func() {
|
|
restartCount = prevCount
|
|
restartWindow = prevWindow
|
|
}()
|
|
|
|
// Under threshold
|
|
restartWindow = time.Now()
|
|
restartCount = 3
|
|
if shouldEscalateToSlate() {
|
|
t.Error("shouldEscalateToSlate() = true, want false (under threshold)")
|
|
}
|
|
|
|
// At threshold
|
|
restartCount = maxRestartsPerMinute
|
|
if !shouldEscalateToSlate() {
|
|
t.Error("shouldEscalateToSlate() = false, want true (at threshold)")
|
|
}
|
|
|
|
// Over threshold
|
|
restartCount = 7
|
|
if !shouldEscalateToSlate() {
|
|
t.Error("shouldEscalateToSlate() = false, want true (over threshold)")
|
|
}
|
|
|
|
// Window expired
|
|
restartWindow = time.Now().Add(-61 * time.Second)
|
|
restartCount = 10
|
|
if shouldEscalateToSlate() {
|
|
t.Error("shouldEscalateToSlate() = true, want false (window expired, should reset)")
|
|
}
|
|
if restartCount != 0 {
|
|
t.Errorf("restartCount after window reset = %d, want 0", restartCount)
|
|
}
|
|
}
|
|
|
|
func TestResetHLSOutput(t *testing.T) {
|
|
tmpDir := t.TempDir()
|
|
|
|
// Create some segment files
|
|
os.MkdirAll(tmpDir, 0755)
|
|
os.WriteFile(filepath.Join(tmpDir, "segment_test.ts"), []byte("data"), 0644)
|
|
os.WriteFile(filepath.Join(tmpDir, "segment_test.m4s"), []byte("data"), 0644)
|
|
os.WriteFile(filepath.Join(tmpDir, "playlist.m3u8"), []byte("#EXTM3U"), 0644)
|
|
os.WriteFile(filepath.Join(tmpDir, "init.mp4"), []byte("init"), 0644)
|
|
|
|
err := resetHLSOutput(tmpDir)
|
|
if err != nil {
|
|
t.Fatalf("resetHLSOutput error: %v", err)
|
|
}
|
|
|
|
// Verify all cleaned
|
|
for _, name := range []string{"segment_test.ts", "segment_test.m4s", "playlist.m3u8", "init.mp4"} {
|
|
if _, err := os.Stat(filepath.Join(tmpDir, name)); !os.IsNotExist(err) {
|
|
t.Errorf("file %s still exists after reset", name)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestResetHLSOutput_EmptyDir(t *testing.T) {
|
|
tmpDir := t.TempDir()
|
|
err := resetHLSOutput(tmpDir)
|
|
if err != nil {
|
|
t.Fatalf("resetHLSOutput on empty dir error: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestActivateSlate_NoSlate(t *testing.T) {
|
|
prevSlateDir := slateDir
|
|
prevHLSDir := hlsDir
|
|
prevMode := playoutMode
|
|
tmpDir := t.TempDir()
|
|
slateDir = filepath.Join(tmpDir, "slate")
|
|
hlsDir = filepath.Join(tmpDir, "hls")
|
|
os.MkdirAll(hlsDir, 0755)
|
|
defer func() {
|
|
slateDir = prevSlateDir
|
|
hlsDir = prevHLSDir
|
|
playoutMode = prevMode
|
|
}()
|
|
|
|
err := activateSlate()
|
|
if err == nil {
|
|
t.Error("expected error when slate HLS missing, got nil")
|
|
}
|
|
}
|
|
|
|
func TestActivateSlate_Success(t *testing.T) {
|
|
prevSlateDir := slateDir
|
|
prevHLSDir := hlsDir
|
|
prevMode := playoutMode
|
|
tmpDir := t.TempDir()
|
|
slateDir = filepath.Join(tmpDir, "slate")
|
|
hlsDir = filepath.Join(tmpDir, "hls")
|
|
|
|
// Create slate HLS structure
|
|
slateHLS := filepath.Join(slateDir, "hls")
|
|
os.MkdirAll(slateHLS, 0755)
|
|
os.MkdirAll(hlsDir, 0755)
|
|
os.WriteFile(filepath.Join(slateHLS, "slate.m3u8"), []byte("#EXTM3U\n#EXT-X-ENDLIST\n"), 0644)
|
|
os.WriteFile(filepath.Join(slateHLS, "slate_segment.ts"), []byte("slate-data"), 0644)
|
|
|
|
defer func() {
|
|
slateDir = prevSlateDir
|
|
hlsDir = prevHLSDir
|
|
playoutMode = prevMode
|
|
}()
|
|
|
|
err := activateSlate()
|
|
if err != nil {
|
|
t.Fatalf("activateSlate error: %v", err)
|
|
}
|
|
|
|
if playoutMode != "slate" {
|
|
t.Errorf("playoutMode = %q, want slate", playoutMode)
|
|
}
|
|
|
|
// Verify files copied
|
|
if _, err := os.Stat(filepath.Join(hlsDir, "slate.m3u8")); os.IsNotExist(err) {
|
|
t.Error("slate.m3u8 not copied to hls dir")
|
|
}
|
|
}
|
|
|
|
func TestDeactivateSlate(t *testing.T) {
|
|
prevMode := playoutMode
|
|
playoutMode = "slate"
|
|
defer func() { playoutMode = prevMode }()
|
|
|
|
deactivateSlate()
|
|
|
|
if playoutMode != "live" {
|
|
t.Errorf("playoutMode = %q, want live", playoutMode)
|
|
}
|
|
}
|
|
|
|
func TestValidatePlaylist(t *testing.T) {
|
|
// ValidatePlaylist calls ValidateVideo which needs ffprobe
|
|
t.Skip("requires ffprobe mock or test media fixtures")
|
|
}
|
|
|
|
func TestValidateWorkersConfig(t *testing.T) {
|
|
os.Unsetenv("PLAYOUT_VALIDATE_WORKERS")
|
|
if got := validateWorkers(); got < 1 {
|
|
t.Errorf("validateWorkers() = %d, want >= 1", got)
|
|
}
|
|
|
|
os.Setenv("PLAYOUT_VALIDATE_WORKERS", "3")
|
|
if got := validateWorkers(); got != 3 {
|
|
t.Errorf("validateWorkers() = %d, want 3", got)
|
|
}
|
|
|
|
os.Setenv("PLAYOUT_VALIDATE_WORKERS", "invalid")
|
|
if got := validateWorkers(); got < 1 {
|
|
t.Errorf("validateWorkers() = %d, want fallback >= 1", got)
|
|
}
|
|
|
|
os.Unsetenv("PLAYOUT_VALIDATE_WORKERS")
|
|
}
|
|
|
|
func TestValidatePlaylist_Dedup(t *testing.T) {
|
|
ensureFFProbeInPath(t)
|
|
|
|
valid := filepath.Join("..", "testdata", "valid_720p30.mp4")
|
|
playlistDir := t.TempDir()
|
|
p := filepath.Join(playlistDir, "playlist.txt")
|
|
// Same file listed three times to exercise dedup + order preservation.
|
|
content := fmt.Sprintf("file '%s'\nfile '%s'\nfile '%s'\n", valid, valid, valid)
|
|
if err := os.WriteFile(p, []byte(content), 0o644); err != nil {
|
|
t.Fatalf("write playlist: %v", err)
|
|
}
|
|
|
|
videos, err := ValidatePlaylist(p)
|
|
if err != nil {
|
|
t.Fatalf("ValidatePlaylist error: %v", err)
|
|
}
|
|
if len(videos) != 3 {
|
|
t.Fatalf("len = %d, want 3", len(videos))
|
|
}
|
|
for i, v := range videos {
|
|
if !v.Valid {
|
|
t.Errorf("entry %d invalid: %s", i, v.Validation)
|
|
}
|
|
if v.Path != valid {
|
|
t.Errorf("entry %d path = %q, want %q", i, v.Path, valid)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestSyncToR2(t *testing.T) {
|
|
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, "hls/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 != "hls/segment.m4s" {
|
|
t.Fatalf("key = %q, want %q", gotKey, "hls/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)
|
|
}
|
|
}
|
|
|
|
func TestR2KeepRuns(t *testing.T) {
|
|
os.Setenv("R2_KEEP_RUNS", "")
|
|
if got := r2KeepRuns(); got != 2 {
|
|
t.Errorf("default = %d, want 2", got)
|
|
}
|
|
|
|
os.Setenv("R2_KEEP_RUNS", "5")
|
|
if got := r2KeepRuns(); got != 5 {
|
|
t.Errorf("override = %d, want 5", got)
|
|
}
|
|
|
|
os.Setenv("R2_KEEP_RUNS", "0")
|
|
if got := r2KeepRuns(); got != 2 {
|
|
t.Errorf("invalid = %d, want 2 fallback", got)
|
|
}
|
|
|
|
os.Unsetenv("R2_KEEP_RUNS")
|
|
}
|
|
|
|
func TestR2MaxRetentionBytes(t *testing.T) {
|
|
os.Setenv("R2_MAX_RETENTION_BYTES", "")
|
|
if got := r2MaxRetentionBytes(); got != 10*1024*1024*1024 {
|
|
t.Errorf("default = %d, want 10GiB", got)
|
|
}
|
|
|
|
os.Setenv("R2_MAX_RETENTION_BYTES", "100")
|
|
if got := r2MaxRetentionBytes(); got != 100 {
|
|
t.Errorf("override = %d, want 100", got)
|
|
}
|
|
|
|
os.Setenv("R2_MAX_RETENTION_BYTES", "invalid")
|
|
if got := r2MaxRetentionBytes(); got != 10*1024*1024*1024 {
|
|
t.Errorf("invalid = %d, want 10GiB fallback", got)
|
|
}
|
|
|
|
os.Unsetenv("R2_MAX_RETENTION_BYTES")
|
|
}
|
|
|
|
func TestR2RunFromKey(t *testing.T) {
|
|
cases := []struct {
|
|
key string
|
|
run string
|
|
ok bool
|
|
}{
|
|
{"hls/r-20260815T120000Z/seg-1.m4s", "r-20260815T120000Z", true},
|
|
{"hls/r-20260815T120000Z/init.mp4", "r-20260815T120000Z", true},
|
|
{"hls/playlist.m3u8", "", false},
|
|
{"hls/playlist.m3u8.tmp", "", false},
|
|
{"other/r-x/seg.m4s", "", false},
|
|
{"hls/r-noslash", "", false},
|
|
}
|
|
for _, c := range cases {
|
|
run, ok := r2RunFromKey(c.key)
|
|
if run != c.run || ok != c.ok {
|
|
t.Errorf("r2RunFromKey(%q) = (%q, %v), want (%q, %v)", c.key, run, ok, c.run, c.ok)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestCleanupOldRuns(t *testing.T) {
|
|
prevClient := r2Client
|
|
prevBucket := bucket
|
|
prevList := listObjects
|
|
prevDelete := deleteObjects
|
|
prevTotal := r2CleanupObjectsTotal
|
|
prevRuns := r2RunsRetained
|
|
prevBytes := r2BytesRetained
|
|
defer func() {
|
|
r2Client = prevClient
|
|
bucket = prevBucket
|
|
listObjects = prevList
|
|
deleteObjects = prevDelete
|
|
r2CleanupObjectsTotal = prevTotal
|
|
r2RunsRetained = prevRuns
|
|
r2BytesRetained = prevBytes
|
|
}()
|
|
|
|
bucket = "linearvoid-media"
|
|
r2Client = &s3.Client{}
|
|
|
|
size := int64(1024)
|
|
listObjects = func(_ *s3.Client, input *s3.ListObjectsV2Input) (*s3.ListObjectsV2Output, error) {
|
|
return &s3.ListObjectsV2Output{
|
|
Contents: []types.Object{
|
|
{Key: aws.String("hls/r-20260813T000000Z/seg-1.m4s"), Size: &size},
|
|
{Key: aws.String("hls/r-20260814T000000Z/seg-2.m4s"), Size: &size},
|
|
{Key: aws.String("hls/r-20260815T000000Z/seg-3.m4s"), Size: &size},
|
|
{Key: aws.String("hls/playlist.m3u8"), Size: &size},
|
|
{Key: aws.String("hls/playlist.m3u8.tmp"), Size: &size},
|
|
},
|
|
IsTruncated: aws.Bool(false),
|
|
}, nil
|
|
}
|
|
|
|
var deletedKeys []string
|
|
deleteObjects = func(_ *s3.Client, input *s3.DeleteObjectsInput) (*s3.DeleteObjectsOutput, error) {
|
|
for _, obj := range input.Delete.Objects {
|
|
deletedKeys = append(deletedKeys, aws.ToString(obj.Key))
|
|
}
|
|
return &s3.DeleteObjectsOutput{}, nil
|
|
}
|
|
|
|
n, err := cleanupOldRuns(context.Background())
|
|
if err != nil {
|
|
t.Fatalf("cleanupOldRuns error: %v", err)
|
|
}
|
|
if n != 1 {
|
|
t.Fatalf("deleted = %d, want 1 (oldest run)", n)
|
|
}
|
|
|
|
joined := strings.Join(deletedKeys, ",")
|
|
if !strings.Contains(joined, "hls/r-20260813T000000Z/seg-1.m4s") {
|
|
t.Fatalf("expected oldest run deleted, got: %v", deletedKeys)
|
|
}
|
|
if strings.Contains(joined, "playlist.m3u8") {
|
|
t.Fatalf("manifest must not be deleted: %v", deletedKeys)
|
|
}
|
|
if r2RunsRetained != 2 {
|
|
t.Fatalf("r2RunsRetained = %d, want 2", r2RunsRetained)
|
|
}
|
|
if r2BytesRetained != 2*size {
|
|
t.Fatalf("r2BytesRetained = %d, want %d", r2BytesRetained, 2*size)
|
|
}
|
|
if r2CleanupObjectsTotal != 1 {
|
|
t.Fatalf("r2CleanupObjectsTotal = %d, want 1", r2CleanupObjectsTotal)
|
|
}
|
|
}
|
|
|
|
func TestCleanupOldRuns_SizeCap(t *testing.T) {
|
|
prevClient := r2Client
|
|
prevBucket := bucket
|
|
prevList := listObjects
|
|
prevDelete := deleteObjects
|
|
prevTotal := r2CleanupObjectsTotal
|
|
prevRuns := r2RunsRetained
|
|
prevBytes := r2BytesRetained
|
|
defer func() {
|
|
r2Client = prevClient
|
|
bucket = prevBucket
|
|
listObjects = prevList
|
|
deleteObjects = prevDelete
|
|
r2CleanupObjectsTotal = prevTotal
|
|
r2RunsRetained = prevRuns
|
|
r2BytesRetained = prevBytes
|
|
}()
|
|
|
|
bucket = "linearvoid-media"
|
|
r2Client = &s3.Client{}
|
|
|
|
// Force a small cap so the size policy trims even the last 2 runs.
|
|
os.Setenv("R2_KEEP_RUNS", "2")
|
|
os.Setenv("R2_MAX_RETENTION_BYTES", "1024")
|
|
defer os.Unsetenv("R2_KEEP_RUNS")
|
|
defer os.Unsetenv("R2_MAX_RETENTION_BYTES")
|
|
|
|
size := int64(1024)
|
|
listObjects = func(_ *s3.Client, input *s3.ListObjectsV2Input) (*s3.ListObjectsV2Output, error) {
|
|
return &s3.ListObjectsV2Output{
|
|
Contents: []types.Object{
|
|
{Key: aws.String("hls/r-20260813T000000Z/seg-1.m4s"), Size: &size},
|
|
{Key: aws.String("hls/r-20260814T000000Z/seg-2.m4s"), Size: &size},
|
|
{Key: aws.String("hls/r-20260815T000000Z/seg-3.m4s"), Size: &size},
|
|
},
|
|
IsTruncated: aws.Bool(false),
|
|
}, nil
|
|
}
|
|
|
|
var deletedKeys []string
|
|
deleteObjects = func(_ *s3.Client, input *s3.DeleteObjectsInput) (*s3.DeleteObjectsOutput, error) {
|
|
for _, obj := range input.Delete.Objects {
|
|
deletedKeys = append(deletedKeys, aws.ToString(obj.Key))
|
|
}
|
|
return &s3.DeleteObjectsOutput{}, nil
|
|
}
|
|
|
|
n, err := cleanupOldRuns(context.Background())
|
|
if err != nil {
|
|
t.Fatalf("cleanupOldRuns error: %v", err)
|
|
}
|
|
// 3 runs -> keep 2 -> oldest deleted by count; remaining 2 bytes (2048) exceed
|
|
// the 1024 cap -> the older of the kept two is deleted, leaving only the newest.
|
|
if n != 2 {
|
|
t.Fatalf("deleted = %d, want 2", n)
|
|
}
|
|
|
|
joined := strings.Join(deletedKeys, ",")
|
|
if !strings.Contains(joined, "hls/r-20260813T000000Z/seg-1.m4s") {
|
|
t.Fatalf("expected oldest run deleted, got: %v", deletedKeys)
|
|
}
|
|
if !strings.Contains(joined, "hls/r-20260814T000000Z/seg-2.m4s") {
|
|
t.Fatalf("expected second-oldest run deleted by size cap, got: %v", deletedKeys)
|
|
}
|
|
if strings.Contains(joined, "hls/r-20260815T000000Z/seg-3.m4s") {
|
|
t.Fatalf("newest run must be kept, got: %v", deletedKeys)
|
|
}
|
|
if r2RunsRetained != 1 {
|
|
t.Fatalf("r2RunsRetained = %d, want 1", r2RunsRetained)
|
|
}
|
|
}
|
|
|
|
func TestPlaylistFingerprint(t *testing.T) {
|
|
tmpDir := t.TempDir()
|
|
p := filepath.Join(tmpDir, "playlist.txt")
|
|
if err := os.WriteFile(p, []byte("file '/app/media/a.mp4'\n"), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
fp1, err := playlistFingerprint(p)
|
|
if err != nil {
|
|
t.Fatalf("playlistFingerprint error: %v", err)
|
|
}
|
|
fp2, err := playlistFingerprint(p)
|
|
if err != nil {
|
|
t.Fatalf("playlistFingerprint error: %v", err)
|
|
}
|
|
if fp1 != fp2 {
|
|
t.Errorf("fingerprint not stable: %q vs %q", fp1, fp2)
|
|
}
|
|
|
|
if err := os.WriteFile(p, []byte("file '/app/media/b.mp4'\n"), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
fp3, err := playlistFingerprint(p)
|
|
if err != nil {
|
|
t.Fatalf("playlistFingerprint error: %v", err)
|
|
}
|
|
if fp1 == fp3 {
|
|
t.Errorf("fingerprint should change when playlist content changes")
|
|
}
|
|
}
|
|
|
|
func TestHLSNextStartNumber(t *testing.T) {
|
|
tmpDir := t.TempDir()
|
|
// Create segments 0..29 with a live window of 10.
|
|
for i := 0; i < 30; i++ {
|
|
if err := os.WriteFile(filepath.Join(tmpDir, fmt.Sprintf("seg-%06d.m4s", i)), []byte("x"), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
|
|
next, err := hlsNextStartNumber(tmpDir, 10)
|
|
if err != nil {
|
|
t.Fatalf("hlsNextStartNumber error: %v", err)
|
|
}
|
|
if next != 30 {
|
|
t.Errorf("next = %d, want 30", next)
|
|
}
|
|
|
|
// Segments < 20 should be pruned; 20..29 kept (the live window).
|
|
for i := 0; i < 20; i++ {
|
|
if _, err := os.Stat(filepath.Join(tmpDir, fmt.Sprintf("seg-%06d.m4s", i))); !os.IsNotExist(err) {
|
|
t.Errorf("seg-%06d should have been pruned", i)
|
|
}
|
|
}
|
|
for i := 20; i < 30; i++ {
|
|
if _, err := os.Stat(filepath.Join(tmpDir, fmt.Sprintf("seg-%06d.m4s", i))); err != nil {
|
|
t.Errorf("seg-%06d should be kept: %v", i, err)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestLoadSharedHLSConfig(t *testing.T) {
|
|
prevDur := hlsSegmentDuration
|
|
prevWin := hlsWindowSize
|
|
defer func() {
|
|
hlsSegmentDuration = prevDur
|
|
hlsWindowSize = prevWin
|
|
}()
|
|
|
|
tmpDir := t.TempDir()
|
|
cfgPath := filepath.Join(tmpDir, "config.json")
|
|
if err := os.WriteFile(cfgPath, []byte(`{"hls_segment_duration":"4","hls_window_size":"12"}`), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
os.Setenv("PLAYOUT_CONFIG_PATH", cfgPath)
|
|
defer os.Unsetenv("PLAYOUT_CONFIG_PATH")
|
|
|
|
loadSharedHLSConfig()
|
|
|
|
if hlsSegmentDuration != "4" {
|
|
t.Errorf("hlsSegmentDuration = %q, want 4", hlsSegmentDuration)
|
|
}
|
|
if hlsWindowSize != "12" {
|
|
t.Errorf("hlsWindowSize = %q, want 12", hlsWindowSize)
|
|
}
|
|
}
|
|
|
|
func TestLoadSharedHLSConfig_MissingFile(t *testing.T) {
|
|
prevDur := hlsSegmentDuration
|
|
prevWin := hlsWindowSize
|
|
defer func() {
|
|
hlsSegmentDuration = prevDur
|
|
hlsWindowSize = prevWin
|
|
}()
|
|
|
|
os.Setenv("PLAYOUT_CONFIG_PATH", filepath.Join(t.TempDir(), "nope.json"))
|
|
defer os.Unsetenv("PLAYOUT_CONFIG_PATH")
|
|
|
|
loadSharedHLSConfig() // should be a no-op without erroring
|
|
|
|
if hlsSegmentDuration != prevDur {
|
|
t.Errorf("hlsSegmentDuration changed to %q, want unchanged %q", hlsSegmentDuration, prevDur)
|
|
}
|
|
} |