Files
linearvoid/backend/playout/src/playlist_test.go
T
zwitschi 33b6296c5e
CI / test (push) Successful in 2m50s
CI / docker (push) Successful in 5m12s
Refactor load testing scripts to support Bash and PowerShell
- 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.
2026-09-02 19:47:34 +02:00

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)
}
}