feat: add automatic media normalization, R2 retention cleanup, and seamless playout loops
This commit is contained in:
1 parent
e15d89edfe
commit
06ab5b4655
16 files changed
+1103
-408
No files matched your search
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user