Files
linearvoid/backend/playout/src/playlist.go
T
zwitschi d8cf853842
Release / release (push) Successful in 1m15s
CI / test (push) Successful in 2m53s
CI / docker (push) Successful in 2m0s
chore(release): 1.1.0
- playout: run-scoped R2 manifest URIs (candidate a rewrite via rewriteManifestToRun)
- tests: playlist_rewrite_test.go (rewrite + publish integration + slate guard)
- docs: STREAMING, cloudflare-workers-setup, architecture (incl. Level-2 diagram)
- release housekeeping: TODO->DONE, CHANGELOG 1.1.0, version bump
2026-09-03 09:27:38 +02:00

1296 lines
39 KiB
Go

package main
import (
"bufio"
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"net/http"
"os"
"os/exec"
"path/filepath"
"runtime"
"sort"
"strconv"
"strings"
"sync"
"time"
"github.com/aws/aws-sdk-go-v2/aws"
"github.com/aws/aws-sdk-go-v2/config"
"github.com/aws/aws-sdk-go-v2/service/s3"
"github.com/aws/aws-sdk-go-v2/service/s3/types"
"github.com/fsnotify/fsnotify"
)
// VideoFile represents a normalized video entry in the playlist
type VideoFile struct {
Path string
Duration float64
Valid bool
Validation string
}
var (
r2Client *s3.Client
putObject = func(client *s3.Client, input *s3.PutObjectInput) (*s3.PutObjectOutput, error) {
return client.PutObject(context.TODO(), input)
}
listObjects = func(client *s3.Client, input *s3.ListObjectsV2Input) (*s3.ListObjectsV2Output, error) {
return client.ListObjectsV2(context.TODO(), input)
}
deleteObjects = func(client *s3.Client, input *s3.DeleteObjectsInput) (*s3.DeleteObjectsOutput, error) {
return client.DeleteObjects(context.TODO(), input)
}
copyObject = func(client *s3.Client, input *s3.CopyObjectInput) (*s3.CopyObjectOutput, error) {
return client.CopyObject(context.TODO(), input)
}
bucket = os.Getenv("R2_BUCKET_NAME")
playoutMode = "starting" // "live", "slate", "degraded"
restartCount = 0
restartWindow = time.Now()
slateDir = "/app/slate"
hlsDir = "/app/hls"
hlsSegmentDuration = envWithDefault("HLS_SEGMENT_DURATION", "2")
hlsWindowSize = envWithDefault("HLS_WINDOW_SIZE", "12")
// Unique segment naming (STREAMING.md contract: r-{runID}/seg-{seq}.m4s)
runID string
segSequence uint64
// Health endpoint detail (STREAMING.md contract)
activeItem string
playlistRevision string
lastSegmentSeq uint64
lastR2PubTime time.Time
consecutiveFails int
lastFFmpegStartTime time.Time
lastFFmpegExitError error
// R2 retention cleanup counter
r2CleanupObjectsTotal int64
// R2 retention gauges
r2RunsRetained int64
r2BytesRetained int64
// Continuous playout loop metrics
loopsTotal int64
lastRunDuration time.Duration
lastRunClean bool
)
const (
maxRestartsPerMinute = 5
slateManifestName = "playlist.m3u8"
// Cache-Control header values for R2 uploads.
segmentCacheControl = "public, max-age=31536000, immutable"
manifestCacheControl = "public, max-age=1, s-maxage=2, must-revalidate"
// R2 retention policy.
defaultR2KeepRuns = 2
defaultR2MaxRetentionBytes = int64(10 * 1024 * 1024 * 1024) // 10 GiB
)
// envWithDefault returns the value of the environment variable or the default if not set.
func envWithDefault(key, defaultVal string) string {
if v := os.Getenv(key); v != "" {
return v
}
return defaultVal
}
// isR2Configured returns true when the required R2 environment variables are set.
func isR2Configured() bool {
return os.Getenv("R2_ENDPOINT") != "" &&
os.Getenv("R2_ACCESS_KEY_ID") != "" &&
os.Getenv("R2_SECRET_ACCESS_KEY") != "" &&
os.Getenv("R2_BUCKET_NAME") != ""
}
// initR2Client initializes the R2 (S3-compatible) client.
// Returns an error if required environment variables are missing.
func initR2Client() error {
if !isR2Configured() {
return fmt.Errorf("R2 not configured: R2_ENDPOINT, R2_ACCESS_KEY_ID, R2_SECRET_ACCESS_KEY, or R2_BUCKET_NAME is empty")
}
cfg, err := config.LoadDefaultConfig(context.TODO(),
config.WithRegion("auto"),
config.WithCredentialsProvider(aws.CredentialsProviderFunc(func(ctx context.Context) (aws.Credentials, error) {
return aws.Credentials{
AccessKeyID: os.Getenv("R2_ACCESS_KEY_ID"),
SecretAccessKey: os.Getenv("R2_SECRET_ACCESS_KEY"),
}, nil
})),
)
if err != nil {
return err
}
r2Client = s3.NewFromConfig(cfg, func(o *s3.Options) {
o.BaseEndpoint = aws.String(os.Getenv("R2_ENDPOINT"))
})
return nil
}
// initRunID generates a unique run/session ID for globally unique segment naming (STREAMING.md contract).
func initRunID() {
runID = "r-" + time.Now().UTC().Format("20060102T150405Z")
segSequence = 0
}
// watchAndPublishSegments watches the HLS output directory for new .m4s segments
// and uploads them to R2 in real time with runID-based keys (STREAMING.md contract).
func watchAndPublishSegments(ctx context.Context, hlsOutputDir string) {
watcher, err := fsnotify.NewWatcher()
if err != nil {
fmt.Println("Warning: fsnotify unavailable, R2 sync falls back to batch:", err)
return
}
defer watcher.Close()
if err := watcher.Add(hlsOutputDir); err != nil {
fmt.Println("Warning: cannot watch HLS dir:", err)
return
}
for {
select {
case event, ok := <-watcher.Events:
if !ok {
return
}
if event.Op&fsnotify.Create != 0 && strings.HasSuffix(event.Name, ".m4s") {
// Wait for FFmpeg to finish writing the segment
time.Sleep(500 * time.Millisecond)
segSequence++
lastSegmentSeq = segSequence
key := "hls/" + runID + "/" + filepath.Base(event.Name)
if err := SyncToR2(event.Name, key); err != nil {
consecutiveFails++
fmt.Printf("Failed to sync segment %s: %v\n", event.Name, err)
} else {
consecutiveFails = 0
lastR2PubTime = time.Now()
}
}
case err, ok := <-watcher.Errors:
if !ok {
return
}
fmt.Println("fsnotify error:", err)
case <-ctx.Done():
return
}
}
}
// publishManifest uploads the manifest atomically: temp key first, then copy to final key (STREAMING.md contract).
// Bare media URIs in the manifest are rewritten to the run-scoped R2 layout
// (r-{runID}/…) so that every referenced segment/init resolves on the media host.
func publishManifest(localManifestPath string) error {
if r2Client == nil {
if err := initR2Client(); err != nil {
return err
}
}
data, err := os.ReadFile(localManifestPath)
if err != nil {
return fmt.Errorf("read manifest failed: %w", err)
}
// Rewrite bare relative media URIs to the current run folder so the manifest
// matches the run-scoped segment keys uploaded by watchAndPublishSegments /
// syncLatestToR2 (candidate (a)). Slate mode is skipped: its manifest references
// slate assets that are not published under the live run prefix.
if playoutMode != "slate" {
data = rewriteManifestToRun(runID, data)
}
// Stage the (rewritten) manifest locally so the existing SyncToR2 path is reused.
staging := localManifestPath + ".r2staging"
if err := os.WriteFile(staging, data, 0o644); err != nil {
return fmt.Errorf("write staging manifest failed: %w", err)
}
defer os.Remove(staging)
// 1. Upload to temp key
if err := SyncToR2(staging, "hls/playlist.m3u8.tmp"); err != nil {
return fmt.Errorf("temp manifest upload failed: %w", err)
}
// 2. Atomic copy to final key
_, err = copyObject(r2Client, &s3.CopyObjectInput{
Bucket: aws.String(bucket),
CopySource: aws.String(bucket + "/hls/playlist.m3u8.tmp"),
Key: aws.String("hls/playlist.m3u8"),
})
if err != nil {
return fmt.Errorf("manifest atomic swap failed: %w", err)
}
return nil
}
// isMediaURI reports whether s names an HLS media file (a segment or init file).
func isMediaURI(s string) bool {
name := s
if i := strings.IndexByte(name, '?'); i >= 0 {
name = name[:i]
}
switch strings.ToLower(filepath.Ext(name)) {
case ".m4s", ".ts", ".mp4", ".m4a":
return true
}
return false
}
// rewriteManifestToRun prefixes bare relative media URIs in an HLS manifest with the
// run folder, e.g. "seg-000001.m4s" -> "r-20260903T…/seg-000001.m4s" and
// #EXT-X-MAP:URI="init.mp4" -> URI="r-20260903T…/init.mp4". This keeps the published
// manifest consistent with the run-scoped R2 layout (STREAMING.md contract). Directives,
// comments, blank lines, and URIs that already contain a path are left unchanged.
// Returns data unchanged when runID is empty.
func rewriteManifestToRun(runID string, data []byte) []byte {
if runID == "" {
return data
}
prefix := runID + "/"
lines := strings.Split(string(data), "\n")
out := make([]string, 0, len(lines))
for _, line := range lines {
trimmed := strings.TrimSpace(line)
switch {
case strings.HasPrefix(trimmed, "#EXT-X-MAP:URI="):
if open := strings.Index(trimmed, `URI="`); open >= 0 {
rest := trimmed[open+len(`URI="`):]
if closeIdx := strings.IndexByte(rest, '"'); closeIdx > 0 {
uri := rest[:closeIdx]
if uri != "" && !strings.Contains(uri, "/") {
line = strings.Replace(line, `URI="`+uri+`"`, `URI="`+prefix+uri+`"`, 1)
}
}
}
out = append(out, line)
case trimmed != "" && !strings.HasPrefix(trimmed, "#") && !strings.Contains(trimmed, "/") && isMediaURI(trimmed):
out = append(out, prefix+trimmed)
default:
out = append(out, line)
}
}
return []byte(strings.Join(out, "\n"))
}
// LoadPlaylist reads a playlist file and returns a list of VideoFile entries
func LoadPlaylist(playlistPath string) ([]VideoFile, error) {
file, err := os.Open(playlistPath)
if err != nil {
return nil, err
}
defer file.Close()
var videos []VideoFile
scanner := bufio.NewScanner(file)
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if line == "" || strings.HasPrefix(line, "#") {
continue
}
if strings.HasPrefix(line, "file ") {
line = strings.TrimSpace(strings.TrimPrefix(line, "file "))
line = strings.Trim(line, "'\"")
}
videos = append(videos, VideoFile{Path: line})
}
return videos, scanner.Err()
}
// ValidateVideo uses ffprobe to enforce codec, fps, and keyframe interval (ADR-001)
func ValidateVideo(videoPath string) (VideoFile, error) {
v := VideoFile{Path: videoPath}
// Run the stream/format probe and the keyframe probe concurrently: they are
// independent, and the keyframe probe (-show_frames) is the expensive one.
var streamOut []byte
var streamErr error
var keyframeErr error
var wg sync.WaitGroup
wg.Add(2)
go func() {
defer wg.Done()
cmd := exec.Command("ffprobe", "-v", "quiet", "-print_format", "json", "-show_format", "-show_streams", videoPath)
streamOut, streamErr = cmd.Output()
}()
go func() {
defer wg.Done()
keyframeErr = validateKeyframeInterval(videoPath)
}()
wg.Wait()
if streamErr != nil {
v.Validation = "ffprobe failed: " + streamErr.Error()
return v, fmt.Errorf("ffprobe: %w", streamErr)
}
var errors []string
// Parse JSON
var probe struct {
Streams []struct {
CodecName string `json:"codec_name"`
CodecType string `json:"codec_type"`
Width int `json:"width"`
Height int `json:"height"`
RFrameRate string `json:"r_frame_rate"`
PixFmt string `json:"pix_fmt"`
SampleRate string `json:"sample_rate"`
Channels int `json:"channels"`
Duration string `json:"duration"`
} `json:"streams"`
Format struct {
Duration string `json:"duration"`
} `json:"format"`
}
if err := json.Unmarshal(streamOut, &probe); err != nil {
v.Validation = "ffprobe JSON parse failed: " + err.Error()
return v, fmt.Errorf("ffprobe json: %w", err)
}
if len(probe.Streams) == 0 {
errors = append(errors, "no streams detected")
}
var hasVideo, hasAudio bool
for _, s := range probe.Streams {
switch s.CodecType {
case "video":
hasVideo = true
if s.CodecName != "h264" {
errors = append(errors, fmt.Sprintf("video codec must be h264, got %s", s.CodecName))
}
if s.RFrameRate != "30/1" {
errors = append(errors, fmt.Sprintf("framerate must be 30/1, got %s", s.RFrameRate))
}
if s.Width != 1280 && s.Width != 1920 {
errors = append(errors, fmt.Sprintf("width must be 1280 or 1920, got %d", s.Width))
}
if s.Height != 720 && s.Height != 1080 {
errors = append(errors, fmt.Sprintf("height must be 720 or 1080, got %d", s.Height))
}
if s.PixFmt != "yuv420p" {
errors = append(errors, fmt.Sprintf("pixel format must be yuv420p, got %s", s.PixFmt))
}
case "audio":
hasAudio = true
if s.CodecName != "aac" {
errors = append(errors, fmt.Sprintf("audio codec must be aac, got %s", s.CodecName))
}
if s.SampleRate != "48000" {
errors = append(errors, fmt.Sprintf("audio sample rate must be 48000, got %s", s.SampleRate))
}
}
}
if !hasVideo {
errors = append(errors, "no video stream found")
}
if !hasAudio {
errors = append(errors, "no audio stream found")
}
// Parse duration from format or stream
durationSec := 0.0
if probe.Format.Duration != "" {
if d, err := strconv.ParseFloat(probe.Format.Duration, 64); err == nil {
durationSec = d
}
}
if len(errors) > 0 {
v.Valid = false
v.Validation = strings.Join(errors, "; ")
return v, fmt.Errorf("validation failed: %s", v.Validation)
}
// Keyframe interval check (probe already completed concurrently above).
if keyframeErr != nil {
errors = append(errors, keyframeErr.Error())
}
if len(errors) > 0 {
v.Valid = false
v.Validation = strings.Join(errors, "; ")
return v, fmt.Errorf("validation failed: %s", v.Validation)
}
v.Valid = true
v.Duration = durationSec
v.Validation = fmt.Sprintf("OK: H.264/AAC, 30fps, yuv420p, 48kHz, %.1fs, keyframes OK", durationSec)
return v, nil
}
// validateKeyframeInterval checks that max gap between keyframes <=2s (60 frames at 30fps).
func validateKeyframeInterval(videoPath string) error {
cmd := exec.Command("ffprobe",
"-v", "quiet",
"-select_streams", "v:0",
"-show_frames",
"-show_entries", "frame=key_frame,best_effort_timestamp_time,pkt_pts_time",
"-of", "csv=p=0",
videoPath)
output, err := cmd.Output()
if err != nil {
return fmt.Errorf("keyframe probe failed: %w", err)
}
type kfEntry struct{ pts float64 }
var keyframes []kfEntry
lines := strings.Split(strings.TrimSpace(string(output)), "\n")
for _, line := range lines {
line = strings.TrimSpace(line)
if line == "" {
continue
}
parts := strings.Split(line, ",")
if len(parts) < 2 || parts[0] != "1" {
continue
}
var pts float64
var found bool
for _, raw := range parts[1:] {
if value, err := strconv.ParseFloat(strings.TrimSpace(raw), 64); err == nil {
pts = value
found = true
break
}
}
if !found {
continue
}
keyframes = append(keyframes, kfEntry{pts})
}
if len(keyframes) < 2 {
return fmt.Errorf("insufficient keyframes for interval analysis (need >=2, got %d)", len(keyframes))
}
maxGap := 0.0
for i := 1; i < len(keyframes); i++ {
gap := keyframes[i].pts - keyframes[i-1].pts
if gap > maxGap {
maxGap = gap
}
}
if maxGap > 2.1 { // small tolerance for floating point
return fmt.Errorf("keyframe interval too large: max %.2fs (must be <=2s)", maxGap)
}
return nil
}
// ValidateVideoStrict runs ValidateVideo and returns a structured JSON result.
func ValidateVideoStrict(videoPath string) map[string]any {
v, err := ValidateVideo(videoPath)
result := map[string]any{
"path": videoPath,
"valid": v.Valid,
"errors": []string{},
}
if err != nil {
result["errors"] = strings.Split(v.Validation, "; ")
}
if v.Valid {
result["duration_seconds"] = v.Duration
}
return result
}
// validateWorkers returns the number of concurrent validation workers.
func validateWorkers() int {
n := runtime.NumCPU()
if v := os.Getenv("PLAYOUT_VALIDATE_WORKERS"); v != "" {
if parsed, err := strconv.Atoi(v); err == nil && parsed > 0 {
n = parsed
}
}
return n
}
// ValidatePlaylist runs normalization validation on all entries in parallel,
// deduplicating repeated paths so each unique file is probed once.
func ValidatePlaylist(playlistPath string) ([]VideoFile, error) {
videos, err := LoadPlaylist(playlistPath)
if err != nil {
return nil, err
}
// Collect unique paths in order of first appearance.
var order []string
seen := make(map[string]bool, len(videos))
for _, v := range videos {
if !seen[v.Path] {
seen[v.Path] = true
order = append(order, v.Path)
}
}
// Validate unique paths concurrently with a bounded worker pool.
results := make(map[string]VideoFile, len(order))
var mu sync.Mutex
var wg sync.WaitGroup
sem := make(chan struct{}, validateWorkers())
for _, path := range order {
wg.Add(1)
sem <- struct{}{}
go func(p string) {
defer wg.Done()
defer func() { <-sem }()
vf, err := ValidateVideo(p)
if err != nil {
fmt.Printf("Warning: %s - %s\n", p, err)
}
mu.Lock()
results[p] = vf
mu.Unlock()
}(path)
}
wg.Wait()
// Map results back onto the playlist (preserving order and duplicates).
for i := range videos {
videos[i] = results[videos[i].Path]
}
return videos, nil
}
// SyncToR2 uploads a file to R2 and returns the key.
// Cache-Control is set automatically based on file extension:
// - .m4s, .ts, .mp4, .m4a -> immutable (1 year)
// - .m3u8 -> short-lived (2s, live manifest)
// - everything else -> no cache header
func SyncToR2(localPath, r2Key string) error {
if r2Client == nil {
if err := initR2Client(); err != nil {
return err
}
}
file, err := os.Open(localPath)
if err != nil {
return err
}
defer file.Close()
input := &s3.PutObjectInput{
Bucket: aws.String(bucket),
Key: aws.String(r2Key),
Body: file,
}
ext := strings.ToLower(filepath.Ext(r2Key))
switch ext {
case ".m4s", ".ts", ".mp4", ".m4a":
input.CacheControl = aws.String(segmentCacheControl)
input.ContentType = aws.String("video/MP2T")
case ".m3u8":
input.CacheControl = aws.String(manifestCacheControl)
input.ContentType = aws.String("application/vnd.apple.mpegurl")
}
_, err = putObject(r2Client, input)
return err
}
// HealthHandler provides a detailed health check endpoint (STREAMING.md contract)
func HealthHandler(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
status := "healthy"
if playoutMode == "slate" || playoutMode == "degraded" {
status = "degraded"
}
// Unhealthy if manifest has not advanced for >2 segment durations (12s)
if hlsDir != "" && time.Since(lastR2PubTime) > 12*time.Second && playoutMode != "starting" {
status = "unhealthy"
}
lastExit := ""
if lastFFmpegExitError != nil {
lastExit = lastFFmpegExitError.Error()
}
resp := map[string]any{
"status": status,
"service": "playout",
"mode": playoutMode,
"crash_count": restartCount,
"run_id": runID,
"active_item": activeItem,
"playlist_revision": playlistRevision,
"last_segment_seq": lastSegmentSeq,
"last_r2_pub": lastR2PubTime.UTC().Format(time.RFC3339),
"consecutive_failures": consecutiveFails,
"last_exit_error": lastExit,
"timestamp": time.Now().UTC().Format(time.RFC3339),
}
json.NewEncoder(w).Encode(resp)
}
// activateSlate copies pre-rendered slate HLS into the live output directory.
// Writes a static manifest referencing slate segments. Returns error if slate not found.
func activateSlate() error {
slateHLS := filepath.Join(slateDir, "hls")
if _, err := os.Stat(filepath.Join(slateHLS, "slate.m3u8")); os.IsNotExist(err) {
return fmt.Errorf("slate HLS not found at %s — run scripts/generate-slate.sh first", slateHLS)
}
// Clear existing live segments
if err := resetHLSOutput(hlsDir); err != nil {
return fmt.Errorf("failed to clear HLS output for slate: %w", err)
}
// Copy slate segments into live HLS dir
slateFiles, err := filepath.Glob(filepath.Join(slateHLS, "*"))
if err != nil {
return err
}
for _, src := range slateFiles {
dst := filepath.Join(hlsDir, filepath.Base(src))
data, err := os.ReadFile(src)
if err != nil {
return err
}
if err := os.WriteFile(dst, data, 0o644); err != nil {
return err
}
}
playoutMode = "slate"
fmt.Printf("[%s] EMERGENCY: Switched to slate fallback. %d restarts in window.\n",
time.Now().Format(time.RFC3339), restartCount)
return nil
}
// deactivateSlate clears slate state and prepares for live playout resumption.
func deactivateSlate() {
playoutMode = "live"
fmt.Printf("[%s] RECOVERY: Slate deactivated, resuming live playout.\n",
time.Now().Format(time.RFC3339))
}
// shouldEscalateToSlate checks if restart count exceeds threshold within the window.
func shouldEscalateToSlate() bool {
if time.Since(restartWindow) > 60*time.Second {
restartCount = 0
restartWindow = time.Now()
return false
}
return restartCount >= maxRestartsPerMinute
}
func resetHLSOutput(hlsDir string) error {
if err := os.MkdirAll(hlsDir, 0o755); err != nil {
return err
}
matches, err := filepath.Glob(filepath.Join(hlsDir, "segment_*.ts"))
if err != nil {
return err
}
for _, match := range matches {
if err := os.Remove(match); err != nil && !os.IsNotExist(err) {
return err
}
}
fmp4Matches, err := filepath.Glob(filepath.Join(hlsDir, "segment_*.m4s"))
if err != nil {
return err
}
for _, match := range fmp4Matches {
if err := os.Remove(match); err != nil && !os.IsNotExist(err) {
return err
}
}
manifestPath := filepath.Join(hlsDir, "playlist.m3u8")
if err := os.Remove(manifestPath); err != nil && !os.IsNotExist(err) {
return err
}
initPath := filepath.Join(hlsDir, "init.mp4")
if err := os.Remove(initPath); err != nil && !os.IsNotExist(err) {
return err
}
return nil
}
func main() {
playlistPath := "/app/playlist/playlist.txt"
validateOnly := false
for _, arg := range os.Args[1:] {
switch arg {
case "-validate":
validateOnly = true
default:
if !strings.HasPrefix(arg, "-") {
playlistPath = arg
}
}
}
if validateOnly {
runValidateOnly(playlistPath)
return
}
// Load shared HLS runtime config (written by the chat admin) before starting.
loadSharedHLSConfig()
fmt.Println("=== Linear Void Playout Service Starting ===")
fmt.Println("Playlist:", playlistPath)
fmt.Println("R2 Bucket:", bucket)
// Initialize run ID for globally unique segment naming (STREAMING.md)
initRunID()
fmt.Println("Run ID:", runID)
// Validate playlist before starting (ADR-001 normalization enforcement).
// Runs once here; start-playout.sh no longer pre-validates separately.
videos, err := ValidatePlaylist(playlistPath)
if err != nil {
fmt.Println("ERROR: Playlist validation failed:", err)
os.Exit(1)
}
invalid := 0
for _, v := range videos {
if !v.Valid {
fmt.Printf("ERROR: invalid media: %s — %s\n", v.Path, v.Validation)
invalid++
}
}
if invalid > 0 {
fmt.Printf("ERROR: Playlist validation failed: %d invalid entries\n", invalid)
os.Exit(1)
}
// Initialize R2 client (skip if not configured — local HLS via nginx still works)
if err := initR2Client(); err != nil {
fmt.Println("R2 sync disabled:", err)
}
// Start R2 segment retention cleanup (hourly + once at startup)
if r2Client != nil {
go runR2CleanupLoop()
}
// Start HTTP health check server
http.HandleFunc("/health", HealthHandler)
http.HandleFunc("/metrics", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/plain; version=0.0.4; charset=utf-8")
fmt.Fprintln(w, "# HELP linearvoid_playout_crashes_total Total FFmpeg crashes (not clean completions).")
fmt.Fprintln(w, "# TYPE linearvoid_playout_crashes_total counter")
fmt.Fprintf(w, "linearvoid_playout_crashes_total %d\n", restartCount)
fmt.Fprintln(w, "# HELP linearvoid_playout_mode Current playout mode (0=live, 1=slate, 2=degraded).")
fmt.Fprintln(w, "# TYPE linearvoid_playout_mode gauge")
modeVal := 0
if playoutMode == "slate" {
modeVal = 1
} else if playoutMode == "degraded" {
modeVal = 2
}
fmt.Fprintf(w, "linearvoid_playout_mode %d\n", modeVal)
fmt.Fprintln(w, "# HELP linearvoid_playout_r2_cleanup_objects_total Total R2 objects deleted by retention cleanup.")
fmt.Fprintln(w, "# TYPE linearvoid_playout_r2_cleanup_objects_total counter")
fmt.Fprintf(w, "linearvoid_playout_r2_cleanup_objects_total %d\n", r2CleanupObjectsTotal)
fmt.Fprintln(w, "# HELP linearvoid_playout_r2_runs_retained Number of R2 run folders retained by cleanup.")
fmt.Fprintln(w, "# TYPE linearvoid_playout_r2_runs_retained gauge")
fmt.Fprintf(w, "linearvoid_playout_r2_runs_retained %d\n", r2RunsRetained)
fmt.Fprintln(w, "# HELP linearvoid_playout_r2_bytes_retained Total bytes retained in R2 run folders.")
fmt.Fprintln(w, "# TYPE linearvoid_playout_r2_bytes_retained gauge")
fmt.Fprintf(w, "linearvoid_playout_r2_bytes_retained %d\n", r2BytesRetained)
fmt.Fprintln(w, "# HELP linearvoid_playout_loops_total Total clean playlist completions (loops).")
fmt.Fprintln(w, "# TYPE linearvoid_playout_loops_total counter")
fmt.Fprintf(w, "linearvoid_playout_loops_total %d\n", loopsTotal)
fmt.Fprintln(w, "# HELP linearvoid_playout_run_duration_seconds Duration of the last FFmpeg run in seconds.")
fmt.Fprintln(w, "# TYPE linearvoid_playout_run_duration_seconds gauge")
fmt.Fprintf(w, "linearvoid_playout_run_duration_seconds %f\n", lastRunDuration.Seconds())
})
go func() {
fmt.Println("Health check server listening on :8081/health")
if err := http.ListenAndServe(":8081", nil); err != nil {
fmt.Println("Health server error:", err)
}
}()
// Run FFmpeg in a loop for continuous playback with slate fallback
for {
if playoutMode == "slate" {
// During slate: attempt recovery by restarting FFmpeg
fmt.Printf("[%s] Slate active — attempting live playout recovery...\n", time.Now().Format(time.RFC3339))
}
// New run ID on each restart for unique R2 segment keys (STREAMING.md)
initRunID()
// Remove run folders older than the retention policy (keep the last N runs,
// cap total bytes) now that the next run has started. This never touches the
// current or previous run folders.
if r2Client != nil {
if _, err := cleanupOldRuns(context.Background()); err != nil {
fmt.Println("R2 cleanup error:", err)
}
}
// Reset HLS output only when the playlist changed, we are recovering from a
// crash/slate, or this is the first run. Otherwise continue the segment
// sequence so the loop is seamless for viewers (no gap on clean loops).
fingerprint, fpErr := playlistFingerprint(playlistPath)
if fpErr != nil {
fmt.Println("Warning: cannot fingerprint playlist:", fpErr)
fingerprint = ""
}
playlistChanged := fingerprint != playlistRevision
shouldReset := playlistChanged || playoutMode != "live" || !lastRunClean
windowSize := 24
if ws, err := strconv.Atoi(hlsWindowSize); err == nil && ws > 0 {
windowSize = ws
}
startNumber := 0
if shouldReset {
if err := resetHLSOutput(hlsDir); err != nil {
fmt.Println("Failed to reset HLS output:", err)
time.Sleep(5 * time.Second)
continue
}
playlistRevision = fingerprint
} else {
if n, err := hlsNextStartNumber(hlsDir, windowSize); err == nil {
startNumber = n
}
}
// Start filesystem watcher for real-time R2 segment upload (STREAMING.md).
// Only active when R2 is configured; otherwise the watcher is skipped.
var watchCtx context.Context
var watchCancel context.CancelFunc
if r2Client != nil {
watchCtx, watchCancel = context.WithCancel(context.Background())
go watchAndPublishSegments(watchCtx, hlsDir)
}
lastFFmpegStartTime = time.Now()
fmt.Printf("[%s] Starting FFmpeg playout (run %s) with browser-safe H.264/AAC HLS encoding...\n",
lastFFmpegStartTime.UTC().Format(time.RFC3339), runID)
cmd := exec.Command("ffmpeg", "-re", "-f", "concat", "-safe", "0", "-i", playlistPath,
"-map", "0:v:0", "-map", "0:a:0?",
"-vf", "scale=1280:720:force_original_aspect_ratio=decrease,pad=1280:720:(ow-iw)/2:(oh-ih)/2,fps=30,format=yuv420p",
"-c:v", "libx264", "-preset", "veryfast", "-pix_fmt", "yuv420p",
"-profile:v", "high", "-level", "4.0",
"-r", "30", "-g", "60", "-keyint_min", "60", "-sc_threshold", "0",
"-c:a", "aac", "-ar", "48000", "-ac", "2", "-b:a", "128k",
"-f", "hls",
"-hls_time", hlsSegmentDuration,
"-hls_list_size", hlsWindowSize,
"-hls_segment_type", "fmp4",
"-hls_fmp4_init_filename", "init.mp4",
"-hls_flags", "delete_segments+independent_segments",
"-hls_segment_filename", filepath.Join(hlsDir, "seg-%06d.m4s"),
"-start_number", strconv.Itoa(startNumber),
filepath.Join(hlsDir, "playlist.m3u8"))
runErr := cmd.Run()
runDuration := time.Since(lastFFmpegStartTime)
lastFFmpegExitError = runErr
lastRunDuration = runDuration
lastRunClean = runErr == nil
if watchCancel != nil {
watchCancel() // Stop the filesystem watcher
}
// After FFmpeg exits, sync remaining segments and publish manifest to R2.
// Do this before logging/restart logic so R2 is up to date regardless of exit cause.
if r2Client != nil {
if err := syncLatestToR2(hlsDir); err != nil {
fmt.Println("R2 sync warning:", err)
}
// Publish manifest atomically (STREAMING.md safe publication)
manifestPath := filepath.Join(hlsDir, "playlist.m3u8")
if err := publishManifest(manifestPath); err != nil {
fmt.Println("Manifest publish warning:", err)
}
}
// Differentiate clean playlist completion from FFmpeg crash.
// A nil error means FFmpeg completed the playlist normally — not a crash.
if runErr == nil {
loopsTotal++
fmt.Printf("[%s] Playlist completed normally after %s. Looping...\n",
time.Now().UTC().Format(time.RFC3339), runDuration.Round(time.Second))
// If we were in slate mode, a successful run means recovery
if playoutMode == "slate" {
deactivateSlate()
}
// Minimal delay for clean loop — just enough for watcher goroutine cleanup
time.Sleep(500 * time.Millisecond)
continue
}
// FFmpeg exited with an error — this is a real crash
restartCount++
fmt.Printf("[%s] FFmpeg CRASHED after %s (crash #%d in window): %v\n",
time.Now().UTC().Format(time.RFC3339), runDuration.Round(time.Second), restartCount, runErr)
if shouldEscalateToSlate() {
fmt.Printf("[%s] CRITICAL: %d crashes in 60s — escalating to slate fallback\n",
time.Now().UTC().Format(time.RFC3339), restartCount)
if err := activateSlate(); err != nil {
fmt.Printf("[%s] Slate activation failed: %v — will retry\n",
time.Now().UTC().Format(time.RFC3339), err)
}
// Stay in slate mode, retry recovery after delay
time.Sleep(30 * time.Second)
continue
}
// If we were in slate but FFmpeg ran long enough to be a recovery, deactivate slate
if playoutMode == "slate" && runDuration > 30*time.Second {
deactivateSlate()
}
// Backoff delay after crash before retry
time.Sleep(2 * time.Second)
}
}
// syncLatestToR2 uploads new segments to R2 with runID-based keys
func syncLatestToR2(hlsDir string) error {
files, err := filepath.Glob(filepath.Join(hlsDir, "seg-*.m4s"))
if err != nil {
return err
}
for _, f := range files {
key := "hls/" + runID + "/" + filepath.Base(f)
if err := SyncToR2(f, key); err != nil {
fmt.Printf("Failed to sync %s: %v\n", f, err)
} else {
fmt.Printf("Synced segment to R2: %s\n", key)
}
}
// Sync init segment to run-specific R2 key
initPath := filepath.Join(hlsDir, "init.mp4")
if err := SyncToR2(initPath, "hls/"+runID+"/init.mp4"); err != nil {
return err
}
fmt.Println("Synced init.mp4 to R2")
return nil
}
// r2KeepRuns returns how many of the most recent run folders to keep in R2.
func r2KeepRuns() int {
n, err := strconv.Atoi(envWithDefault("R2_KEEP_RUNS", "2"))
if err != nil || n < 1 {
return defaultR2KeepRuns
}
return n
}
// r2MaxRetentionBytes returns the maximum total bytes to retain across run folders.
func r2MaxRetentionBytes() int64 {
n, err := strconv.ParseInt(envWithDefault("R2_MAX_RETENTION_BYTES", "10737418240"), 10, 64)
if err != nil || n <= 0 {
return defaultR2MaxRetentionBytes
}
return n
}
// r2RunFromKey extracts the run folder name ("r-...") from an R2 object key.
func r2RunFromKey(key string) (string, bool) {
rest := strings.TrimPrefix(key, "hls/")
if rest == key || !strings.HasPrefix(rest, "r-") {
return "", false
}
if idx := strings.IndexByte(rest, '/'); idx > 0 {
return rest[:idx], true
}
return "", false
}
// runObjects groups the R2 object keys and total size for a single run folder.
type runObjects struct {
keys []string
size int64
}
// deleteR2Keys deletes the given R2 object keys in batches of up to 1000.
func deleteR2Keys(keys []string) (int, error) {
deleted := 0
for i := 0; i < len(keys); i += 1000 {
end := i + 1000
if end > len(keys) {
end = len(keys)
}
batch := keys[i:end]
objects := make([]types.ObjectIdentifier, len(batch))
for j, key := range batch {
objects[j] = types.ObjectIdentifier{Key: aws.String(key)}
}
if _, err := deleteObjects(r2Client, &s3.DeleteObjectsInput{
Bucket: aws.String(bucket),
Delete: &types.Delete{Objects: objects, Quiet: aws.Bool(true)},
}); err != nil {
return deleted, err
}
for _, key := range batch {
fmt.Printf("R2 cleanup: deleted %s\n", key)
}
deleted += len(batch)
}
return deleted, nil
}
// cleanupOldRuns lists run-scoped objects under hls/, keeps the most recent
// R2_KEEP_RUNS runs, and deletes older runs. It also enforces the
// R2_MAX_RETENTION_BYTES cap by removing oldest retained runs first, always
// keeping at least the newest run. The live manifest keys are never deleted.
func cleanupOldRuns(ctx context.Context) (int, error) {
if r2Client == nil {
if err := initR2Client(); err != nil {
return 0, err
}
}
runs := map[string]*runObjects{}
var token *string
for {
output, err := listObjects(r2Client, &s3.ListObjectsV2Input{
Bucket: aws.String(bucket),
Prefix: aws.String("hls/"),
ContinuationToken: token,
})
if err != nil {
return 0, err
}
for _, obj := range output.Contents {
key := aws.ToString(obj.Key)
if key == "hls/playlist.m3u8" || key == "hls/playlist.m3u8.tmp" {
continue
}
run, ok := r2RunFromKey(key)
if !ok {
continue
}
r, exists := runs[run]
if !exists {
r = &runObjects{}
runs[run] = r
}
r.keys = append(r.keys, key)
if obj.Size != nil {
r.size += *obj.Size
}
}
if output.IsTruncated == nil || !*output.IsTruncated {
break
}
token = output.NextContinuationToken
}
if len(runs) == 0 {
r2RunsRetained = 0
r2BytesRetained = 0
return 0, nil
}
// Run IDs are ISO timestamps, so lexicographic order equals chronological order.
sortedRuns := make([]string, 0, len(runs))
for run := range runs {
sortedRuns = append(sortedRuns, run)
}
sort.Strings(sortedRuns) // ascending: oldest -> newest
deleteRun := map[string]bool{}
keepRuns := r2KeepRuns()
// Mark all but the newest keepRuns runs for deletion.
if len(sortedRuns) > keepRuns {
for _, run := range sortedRuns[:len(sortedRuns)-keepRuns] {
deleteRun[run] = true
}
}
// Enforce the total size cap on retained runs (oldest retained first), always
// keeping the newest run even if it alone exceeds the cap.
maxBytes := r2MaxRetentionBytes()
retained := make([]string, 0, keepRuns)
var retainedBytes int64
for _, run := range sortedRuns {
if !deleteRun[run] {
retained = append(retained, run)
retainedBytes += runs[run].size
}
}
for i := 0; retainedBytes > maxBytes && i < len(retained)-1; i++ {
run := retained[i]
deleteRun[run] = true
retainedBytes -= runs[run].size
}
var toDelete []string
for run, del := range deleteRun {
if del {
toDelete = append(toDelete, runs[run].keys...)
}
}
deleted, err := deleteR2Keys(toDelete)
if err != nil {
return deleted, err
}
keptRuns := int64(0)
keptBytes := int64(0)
for run, r := range runs {
if !deleteRun[run] {
keptRuns++
keptBytes += r.size
}
}
r2RunsRetained = keptRuns
r2BytesRetained = keptBytes
r2CleanupObjectsTotal += int64(deleted)
return deleted, nil
}
// runR2CleanupLoop runs retention cleanup once at startup, then hourly.
func runR2CleanupLoop() {
if _, err := cleanupOldRuns(context.Background()); err != nil {
fmt.Println("R2 cleanup error:", err)
}
ticker := time.NewTicker(time.Hour)
defer ticker.Stop()
for range ticker.C {
if _, err := cleanupOldRuns(context.Background()); err != nil {
fmt.Println("R2 cleanup error:", err)
}
}
}
// playlistFingerprint returns a short hash of the playlist file content, used to
// detect playlist changes between loops.
func playlistFingerprint(playlistPath string) (string, error) {
data, err := os.ReadFile(playlistPath)
if err != nil {
return "", err
}
sum := sha256.Sum256(data)
return hex.EncodeToString(sum[:]), nil
}
// hlsNextStartNumber returns the next segment sequence number to use, and prunes
// stale segments outside the live window so they do not accumulate across loops.
func hlsNextStartNumber(hlsDir string, windowSize int) (int, error) {
files, err := filepath.Glob(filepath.Join(hlsDir, "seg-*.m4s"))
if err != nil {
return 0, err
}
seqOf := func(f string) int {
base := filepath.Base(f)
num := strings.TrimSuffix(strings.TrimPrefix(base, "seg-"), ".m4s")
n, err := strconv.Atoi(num)
if err != nil {
return -1
}
return n
}
maxSeq := -1
for _, f := range files {
if n := seqOf(f); n > maxSeq {
maxSeq = n
}
}
next := maxSeq + 1
// Keep the live window; delete older segments no longer referenced.
threshold := next - windowSize
for _, f := range files {
if n := seqOf(f); n >= 0 && n < threshold {
if err := os.Remove(f); err != nil && !os.IsNotExist(err) {
return next, err
}
}
}
return next, nil
}
// runValidateOnly validates the playlist and exits 0 (valid) or 1 (invalid/missing).
func runValidateOnly(playlistPath string) {
videos, err := ValidatePlaylist(playlistPath)
if err != nil {
fmt.Println("ERROR: Playlist validation failed:", err)
os.Exit(1)
}
valid := true
for _, v := range videos {
if v.Valid {
fmt.Printf("OK: %s (%.1fs)\n", v.Path, v.Duration)
} else {
valid = false
fmt.Printf("INVALID: %s — %s\n", v.Path, v.Validation)
}
}
if !valid {
os.Exit(1)
}
fmt.Println("Playlist valid.")
}
// loadSharedHLSConfig reads HLS settings from the shared runtime config file
// (written by the chat admin service) and overrides the env-derived defaults.
func loadSharedHLSConfig() {
path := envWithDefault("PLAYOUT_CONFIG_PATH", "/app/playlist/config.json")
data, err := os.ReadFile(path)
if err != nil {
if !os.IsNotExist(err) {
fmt.Println("Warning: cannot read shared config:", err)
}
return
}
var cfg map[string]string
if err := json.Unmarshal(data, &cfg); err != nil {
fmt.Println("Warning: cannot parse shared config:", err)
return
}
if v := strings.TrimSpace(cfg["hls_segment_duration"]); v != "" {
if n, err := strconv.Atoi(v); err == nil && n > 0 {
hlsSegmentDuration = strconv.Itoa(n)
}
}
if v := strings.TrimSpace(cfg["hls_window_size"]); v != "" {
if n, err := strconv.Atoi(v); err == nil && n > 0 {
hlsWindowSize = strconv.Itoa(n)
}
}
fmt.Printf("Shared HLS config: segment=%ss window=%s\n", hlsSegmentDuration, hlsWindowSize)
}