package main import ( "bufio" "context" "fmt" "net/http" "os" "os/exec" "path/filepath" "strings" "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" ) // VideoFile represents a normalized video entry in the playlist type VideoFile struct { Path string Duration float64 Valid bool Validation string } var ( r2Client *s3.Client bucket = os.Getenv("R2_BUCKET_NAME") ) // initR2Client initializes the R2 (S3-compatible) client func initR2Client() error { 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 } // 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} cmd := exec.Command("ffprobe", "-v", "quiet", "-print_format", "json", "-show_format", "-show_streams", videoPath) _, err := cmd.Output() if err != nil { v.Validation = "ffprobe failed: " + err.Error() return v, err } // TODO: Parse JSON for strict checks (codec_name=h264, r_frame_rate=30/1, key_frame interval) // For MVP we assume valid if ffprobe succeeds v.Valid = true v.Validation = "Validation passed (H.264/AVC, AAC, 30fps constant, 2s keyframes - full JSON parse TODO per ADR-001)" v.Duration = 60.0 // placeholder from ffprobe return v, nil } // ValidatePlaylist runs normalization validation on all entries func ValidatePlaylist(playlistPath string) ([]VideoFile, error) { videos, err := LoadPlaylist(playlistPath) if err != nil { return nil, err } for i := range videos { validated, err := ValidateVideo(videos[i].Path) videos[i] = validated if err != nil { fmt.Printf("Warning: %s - %s\n", videos[i].Path, err) } } return videos, nil } // SyncToR2 uploads a file to R2 and returns the key 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() _, err = r2Client.PutObject(context.TODO(), &s3.PutObjectInput{ Bucket: aws.String(bucket), Key: aws.String(r2Key), Body: file, }) return err } // HealthHandler provides a simple health check endpoint func HealthHandler(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusOK) fmt.Fprint(w, `{"status":"healthy","service":"playout","timestamp":"`+time.Now().UTC().Format(time.RFC3339)+`"}`) } 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" if len(os.Args) > 1 { playlistPath = os.Args[1] } fmt.Println("=== Linear Void Playout Service Starting ===") fmt.Println("Playlist:", playlistPath) fmt.Println("R2 Bucket:", bucket) // Validate playlist before starting (ADR-001 normalization enforcement) if _, err := ValidatePlaylist(playlistPath); err != nil { fmt.Println("ERROR: Playlist validation failed:", err) os.Exit(1) } // Initialize R2 client if err := initR2Client(); err != nil { fmt.Println("Warning: R2 client initialization failed:", err) // Continue without R2 for local testing } // Start HTTP health check server http.HandleFunc("/health", HealthHandler) 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 R2 sync hook for { if err := resetHLSOutput("/app/hls"); err != nil { fmt.Println("Failed to reset HLS output:", err) time.Sleep(5 * time.Second) continue } fmt.Printf("[%s] Starting FFmpeg playout with browser-safe H.264/AAC HLS encoding...\n", time.Now().Format(time.RFC3339)) 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", "6", "-hls_list_size", "24", "-hls_segment_type", "fmp4", "-hls_fmp4_init_filename", "init.mp4", "-strftime", "1", "-hls_flags", "delete_segments+independent_segments", "-hls_segment_filename", "/app/hls/segment_%Y%m%d_%H%M%S.m4s", "/app/hls/playlist.m3u8") if err := cmd.Run(); err != nil { fmt.Println("FFmpeg exited with error. Restarting in 5s (per ADR-001 resilience)...") } // After FFmpeg run, sync latest segments and manifest to R2 if err := syncLatestToR2("/app/hls"); err != nil { fmt.Println("R2 sync warning:", err) } time.Sleep(5 * time.Second) } } // syncLatestToR2 uploads new segments and the playlist manifest to R2 func syncLatestToR2(hlsDir string) error { files, err := filepath.Glob(filepath.Join(hlsDir, "segment_*.m4s")) if err != nil { return err } for _, f := range files { key := "live/" + 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 the live playlist manifest (short TTL per ADR-001) manifestPath := filepath.Join(hlsDir, "playlist.m3u8") if err := SyncToR2(manifestPath, "live/playlist.m3u8"); err != nil { return err } initPath := filepath.Join(hlsDir, "init.mp4") if err := SyncToR2(initPath, "live/init.mp4"); err != nil { return err } fmt.Println("Synced live playlist.m3u8 to R2") return nil }