- Created a new login page for admin access at frontend/admin/login/index.html. - Implemented form submission with error handling for login credentials. - Updated frontend/index.html to mute the video player and removed unnecessary Alpine.js data binding. - Enhanced manifest URL resolution in frontend/src/main.ts to include cache-busting parameters. - Modified vite.config.ts to include the new admin login page in the build process. - Updated nginx.conf to handle admin login routing, authentication, and API proxying for admin actions.
285 lines
7.5 KiB
Go
285 lines
7.5 KiB
Go
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
|
|
} |