feat: add automatic media normalization, R2 retention cleanup, and seamless playout loops

This commit is contained in:
zwitschi committed 2026-08-13 11:59:46 +02:00
1 parent e15d89edfe
commit 06ab5b4655
16 files changed
+1103 -408

No files matched your search

+146 -1
View File
@@ -27,6 +27,8 @@ package main
// See backend/playout/testdata/*.txt.
import (
"context"
"fmt"
"io"
"net/http"
"net/http/httptest"
@@ -39,6 +41,7 @@ import (
"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) {
@@ -265,7 +268,7 @@ func TestHealthHandler(t *testing.T) {
`"status":"healthy"`,
`"service":"playout"`,
`"mode":"live"`,
`"restarts":3`,
`"crash_count":3`,
`"run_id":"r-20260810T120000Z"`,
`"last_segment_seq"`,
`"consecutive_failures"`,
@@ -588,4 +591,146 @@ func TestSyncToR2_NoCacheHeadersForUnknownExt(t *testing.T) {
if gotContentType != "" {
t.Fatalf("ContentType = %q, want empty", gotContentType)
}
}
func TestR2SegmentRetentionHours(t *testing.T) {
os.Setenv("R2_SEGMENT_RETENTION_HOURS", "")
if got := r2SegmentRetentionHours(); got != 24*time.Hour {
t.Errorf("default = %v, want 24h", got)
}
os.Setenv("R2_SEGMENT_RETENTION_HOURS", "48")
if got := r2SegmentRetentionHours(); got != 48*time.Hour {
t.Errorf("override = %v, want 48h", got)
}
os.Setenv("R2_SEGMENT_RETENTION_HOURS", "invalid")
if got := r2SegmentRetentionHours(); got != 24*time.Hour {
t.Errorf("invalid = %v, want 24h fallback", got)
}
os.Unsetenv("R2_SEGMENT_RETENTION_HOURS")
}
func TestCleanupOldSegments(t *testing.T) {
prevClient := r2Client
prevBucket := bucket
prevList := listObjects
prevDelete := deleteObjects
prevTotal := r2CleanupObjectsTotal
defer func() {
r2Client = prevClient
bucket = prevBucket
listObjects = prevList
deleteObjects = prevDelete
r2CleanupObjectsTotal = prevTotal
}()
bucket = "linearvoid-media"
r2Client = &s3.Client{}
now := time.Now().UTC()
old := now.Add(-25 * time.Hour)
recent := now.Add(-1 * time.Hour)
listObjects = func(_ *s3.Client, input *s3.ListObjectsV2Input) (*s3.ListObjectsV2Output, error) {
return &s3.ListObjectsV2Output{
Contents: []types.Object{
{Key: aws.String("hls/r-old/seg-1.m4s"), LastModified: &old},
{Key: aws.String("hls/r-old/init.mp4"), LastModified: &old},
{Key: aws.String("hls/r-new/seg-2.m4s"), LastModified: &recent},
{Key: aws.String("hls/playlist.m3u8"), LastModified: &old},
{Key: aws.String("hls/playlist.m3u8.tmp"), LastModified: &old},
},
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 := cleanupOldSegments(context.Background())
if err != nil {
t.Fatalf("cleanupOldSegments error: %v", err)
}
if n != 2 {
t.Fatalf("deleted = %d, want 2", n)
}
joined := strings.Join(deletedKeys, ",")
if !strings.Contains(joined, "hls/r-old/seg-1.m4s") || !strings.Contains(joined, "hls/r-old/init.mp4") {
t.Fatalf("unexpected deleted keys: %v", deletedKeys)
}
if strings.Contains(joined, "playlist.m3u8") {
t.Fatalf("manifest must not be deleted: %v", deletedKeys)
}
if r2CleanupObjectsTotal != 2 {
t.Fatalf("r2CleanupObjectsTotal = %d, want 2", r2CleanupObjectsTotal)
}
}
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)
}
}
}