diff --git a/.env.example b/.env.example index 909bff5..4617509 100644 --- a/.env.example +++ b/.env.example @@ -7,4 +7,6 @@ RTMP_URL=rtmp://localhost:1935/live/stream # Output resolution (720p, 1080p, or 2k) RESOLUTION=720p # Output framerate (30 or 60) -FRAMERATE=30 \ No newline at end of file +FRAMERATE=30 +# Chrome restart interval in minutes for memory management (0 = disabled, default: 60) +CHROME_RESTART_INTERVAL=60 \ No newline at end of file diff --git a/README.md b/README.md index 4446e17..fbf876e 100644 --- a/README.md +++ b/README.md @@ -112,6 +112,10 @@ To enable status checking for Twitch, provide a `TWITCH_CHANNEL`, `TWITCH_CLIENT ## Environmental Variables +- `CHROME_RESTART_INTERVAL` + - Integer + - Default: `60` + - Chrome restart interval in minutes for memory management. Set to `0` to disable periodic Chrome restarts. Recommended values are between 30-120 minutes depending on the memory usage characteristics of the webpage being streamed. - `FRAMERATE` - Enum - `30` diff --git a/cmd/main.go b/cmd/main.go index be1fef1..8e0f821 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -37,6 +37,8 @@ const ( DefaultFramerate = "30" // Default cron string for checking stream status DefaultCheckStreamCronString = "*/10 * * * *" // Every 10 minutes + // Default Chrome restart interval in minutes (0 = disabled) + DefaultChromeRestartInterval = 60 // 60 minutes ) // Struct that represents the current state of the stream @@ -173,6 +175,8 @@ type Config struct { Width int // Computed height based on resolution Height int + // Chrome restart interval in minutes (0 = disabled) + ChromeRestartInterval int } func main() { @@ -296,6 +300,17 @@ func loadConfig(ctx context.Context) (*Config, error) { Framerate: utils.GetEnvOrDefault("FRAMERATE", DefaultFramerate), } + // Parse Chrome restart interval + chromeRestartIntervalStr := utils.GetEnvOrDefault("CHROME_RESTART_INTERVAL", fmt.Sprintf("%d", DefaultChromeRestartInterval)) + chromeRestartInterval, err := strconv.Atoi(chromeRestartIntervalStr) + if err != nil || chromeRestartInterval < 0 { + logger.Warn("Invalid Chrome restart interval, defaulting", + zap.String("value", chromeRestartIntervalStr), + zap.Int("default", DefaultChromeRestartInterval)) + chromeRestartInterval = DefaultChromeRestartInterval + } + config.ChromeRestartInterval = chromeRestartInterval + // Validate and set framerate originalFramerate := config.Framerate switch config.Framerate { @@ -328,6 +343,13 @@ func loadConfig(ctx context.Context) (*Config, error) { config.Height = 720 } + // Log Chrome restart configuration + if config.ChromeRestartInterval > 0 { + logger.Info("Chrome restart enabled", zap.Int("interval_minutes", config.ChromeRestartInterval)) + } else { + logger.Info("Chrome restart disabled") + } + return config, nil } @@ -347,105 +369,80 @@ func streamWebpage(ctx context.Context, config *Config) error { streamCtx, streamCancel := context.WithCancel(ctx) defer streamCancel() - // Create Chrome context with options for screen capture - opts := append(chromedp.DefaultExecAllocatorOptions[:], - chromedp.Flag("headless", false), // We need non-headless for video capture - chromedp.Flag("kiosk", true), - chromedp.Flag("disable-gpu", false), - chromedp.Flag("no-sandbox", true), - chromedp.Flag("disable-setuid-sandbox", true), - chromedp.Flag("disable-dev-shm-usage", true), - chromedp.Flag("disable-web-security", true), - chromedp.Flag("allow-running-insecure-content", true), - chromedp.Flag("autoplay-policy", "no-user-gesture-required"), - chromedp.Flag("use-fake-ui-for-media-stream", true), - chromedp.Flag("use-fake-device-for-media-stream", true), - chromedp.Flag("alsa-output-device", "pulse"), - chromedp.Flag("enable-features", "VaapiVideoDecoder"), - chromedp.Flag("enable-automation", false), - chromedp.Flag("disable-blink-features", "AutomationControlled"), - chromedp.Flag("mute-audio", false), - chromedp.Flag("window-position", "0,0"), - chromedp.WindowSize(config.Width, config.Height), - ) - - allocCtx, allocCancel := chromedp.NewExecAllocator(streamCtx, opts...) - defer allocCancel() - - chromeCtx, chromeCancel := chromedp.NewContext(allocCtx) - defer chromeCancel() - - // Start Chrome and navigate to webpage - logger.Info("Starting Chrome browser", zap.String("url", config.WebpageURL)) - - // Capture the status code when the page loads - var statusCode int64 - chromedp.ListenTarget(chromeCtx, func(ev interface{}) { - switch ev := ev.(type) { - case *network.EventResponseReceived: - if ev.Response.URL == config.WebpageURL { - statusCode = ev.Response.Status - } - } - }) - - // Load the page - if err := chromedp.Run(chromeCtx, - chromedp.Navigate(config.WebpageURL), - chromedp.WaitVisible("body", chromedp.ByQuery), - ); err != nil { - return fmt.Errorf("failed to navigate to webpage: %v", err) - } - - // Log the page load result based on status code - if statusCode >= 200 && statusCode < 300 { - logger.Info("Page load completed successfully", zap.String("url", config.WebpageURL), zap.Int64("status_code", statusCode)) - } else { - logger.Fatal("Page load failed with error status, terminating program", zap.String("url", config.WebpageURL), zap.Int64("status_code", statusCode)) - } - - // Wait a moment for the page to fully load - time.Sleep(3 * time.Second) - - // Get the display information to find where Chrome is running - displayInfo, err := getDisplayInfo() + // Start Chrome session and get display info + chromeCancel, displayInfo, err := startChromeSession(streamCtx, config) if err != nil { - return fmt.Errorf("failed to get display info: %v", err) + return err } - logger.Debug("Display information", zap.String("display", displayInfo)) - - // Start FFmpeg to capture and stream - return startFFmpegStream(streamCtx, config, displayInfo, streamCancel, chromeCancel) + // Start FFmpeg stream with Chrome restart support + return startFFmpegStreamWithChromeRestart(streamCtx, config, displayInfo, streamCancel, chromeCancel) } -// Function to get the display info generated by the start.sh script and feed it to FFmpeg -func getDisplayInfo() (string, error) { - // Try to get the DISPLAY environment variable - display := os.Getenv("DISPLAY") - if display == "" { - display = ":0" // Default X11 display - } - return display, nil -} - -// Helper function to extract numeric value from bitrate string (e.g., "3000k" -> 3000) -func extractNumberFromBitrate(bitrate string) int { - // Remove the 'k' suffix and convert to int - numStr := strings.TrimSuffix(bitrate, "k") - num, err := strconv.Atoi(numStr) - if err != nil { - return 3000 // Default fallback - } - return num -} - -// Function to start FFmpeg stream with the given configuration -func startFFmpegStream(ctx context.Context, config *Config, display string, streamCancel, chromeCancel context.CancelFunc) error { +// Function to start FFmpeg stream with Chrome restart support for memory management +func startFFmpegStreamWithChromeRestart(ctx context.Context, config *Config, display string, streamCancel, initialChromeCancel context.CancelFunc) error { logger := utils.GetLoggerFromContext(ctx) logger.Info("Starting FFmpeg stream") + // Keep track of the current Chrome cancel function + var currentChromeCancel = initialChromeCancel + var chromeCancelMutex sync.RWMutex + + // Set up Chrome restart timer if enabled + var chromeRestartTicker *time.Ticker + if config.ChromeRestartInterval > 0 { + chromeRestartTicker = time.NewTicker(time.Duration(config.ChromeRestartInterval) * time.Minute) + defer chromeRestartTicker.Stop() + logger.Info("Chrome restart timer started", zap.Int("interval_minutes", config.ChromeRestartInterval)) + + // Start Chrome restart goroutine + go func() { + for { + select { + case <-chromeRestartTicker.C: + logger.Info("Performing periodic Chrome restart to manage memory") + + // Get current Chrome cancel function and cancel it + chromeCancelMutex.RLock() + oldChromeCancel := currentChromeCancel + chromeCancelMutex.RUnlock() + + if oldChromeCancel != nil { + oldChromeCancel() + } + + // Wait a moment for cleanup + time.Sleep(2 * time.Second) + + // Start new Chrome session + newChromeCancel, _, restartErr := startChromeSession(ctx, config) + if restartErr != nil { + logger.Error("Failed to restart Chrome session", zap.Error(restartErr)) + // Continue with old session if restart fails + continue + } + + // Update Chrome cancel function + chromeCancelMutex.Lock() + currentChromeCancel = newChromeCancel + chromeCancelMutex.Unlock() + + // Update global stream state with new Chrome cancel function + globalStreamState.mu.Lock() + if globalStreamState.isRunning { + globalStreamState.chromeCancel = newChromeCancel + } + globalStreamState.mu.Unlock() + + logger.Info("Chrome session restarted successfully") + case <-ctx.Done(): + return + } + } + }() + } + // Calculate keyframe interval for 2 seconds (GOP size = framerate * 2) framerate := config.Framerate framerateInt, err := strconv.Atoi(framerate) @@ -557,8 +554,13 @@ func startFFmpegStream(ctx context.Context, config *Config, display string, stre } }() + // Get current Chrome cancel function for global state + chromeCancelMutex.RLock() + finalChromeCancel := currentChromeCancel + chromeCancelMutex.RUnlock() + // Register this stream as running - globalStreamState.setStreamRunning(streamCancel, chromeCancel, cmd) + globalStreamState.setStreamRunning(streamCancel, finalChromeCancel, cmd) logger.Debug("FFmpeg started successfully, streaming...") @@ -583,6 +585,112 @@ func startFFmpegStream(ctx context.Context, config *Config, display string, stre return err } +// Function to start a Chrome session and return the cancel function and display info +func startChromeSession(ctx context.Context, config *Config) (context.CancelFunc, string, error) { + logger := utils.GetLoggerFromContext(ctx) + + // Create Chrome context with options for screen capture + opts := append(chromedp.DefaultExecAllocatorOptions[:], + chromedp.Flag("headless", false), // We need non-headless for video capture + chromedp.Flag("kiosk", true), + chromedp.Flag("disable-gpu", false), + chromedp.Flag("no-sandbox", true), + chromedp.Flag("disable-setuid-sandbox", true), + chromedp.Flag("disable-dev-shm-usage", true), + chromedp.Flag("disable-web-security", true), + chromedp.Flag("allow-running-insecure-content", true), + chromedp.Flag("autoplay-policy", "no-user-gesture-required"), + chromedp.Flag("use-fake-ui-for-media-stream", true), + chromedp.Flag("use-fake-device-for-media-stream", true), + chromedp.Flag("alsa-output-device", "pulse"), + chromedp.Flag("enable-features", "VaapiVideoDecoder"), + chromedp.Flag("enable-automation", false), + chromedp.Flag("disable-blink-features", "AutomationControlled"), + chromedp.Flag("mute-audio", false), + chromedp.Flag("window-position", "0,0"), + // Memory management flags to mitigate memory leaks + chromedp.Flag("memory-pressure-off", true), + chromedp.Flag("max_old_space_size", "2048"), // Limit V8 heap to 2GB + chromedp.Flag("disable-background-timer-throttling", true), + chromedp.Flag("disable-renderer-backgrounding", true), + chromedp.Flag("disable-backgrounding-occluded-windows", true), + chromedp.Flag("disable-features", "TranslateUI,VizDisplayCompositor"), + chromedp.Flag("aggressive-cache-discard", true), + chromedp.WindowSize(config.Width, config.Height), + ) + + allocCtx, allocCancel := chromedp.NewExecAllocator(ctx, opts...) + defer allocCancel() + + chromeCtx, chromeCancel := chromedp.NewContext(allocCtx) + + // Start Chrome and navigate to webpage + logger.Info("Starting Chrome browser", zap.String("url", config.WebpageURL)) + + // Capture the status code when the page loads + var statusCode int64 + chromedp.ListenTarget(chromeCtx, func(ev interface{}) { + switch ev := ev.(type) { + case *network.EventResponseReceived: + if ev.Response.URL == config.WebpageURL { + statusCode = ev.Response.Status + } + } + }) + + // Load the page + if err := chromedp.Run(chromeCtx, + chromedp.Navigate(config.WebpageURL), + chromedp.WaitVisible("body", chromedp.ByQuery), + ); err != nil { + chromeCancel() + return nil, "", fmt.Errorf("failed to navigate to webpage: %v", err) + } + + // Log the page load result based on status code + if statusCode >= 200 && statusCode < 300 { + logger.Info("Page load completed successfully", zap.String("url", config.WebpageURL), zap.Int64("status_code", statusCode)) + } else { + chromeCancel() + return nil, "", fmt.Errorf("page load failed with status code %d", statusCode) + } + + // Wait a moment for the page to fully load + time.Sleep(3 * time.Second) + + // Get the display information to find where Chrome is running + displayInfo, err := getDisplayInfo() + if err != nil { + chromeCancel() + return nil, "", fmt.Errorf("failed to get display info: %v", err) + } + + logger.Debug("Display information", zap.String("display", displayInfo)) + + return chromeCancel, displayInfo, nil +} + +// Function to get the display info generated by the start.sh script and feed it to FFmpeg +func getDisplayInfo() (string, error) { + // Try to get the DISPLAY environment variable + display := os.Getenv("DISPLAY") + if display == "" { + display = ":0" // Default X11 display + } + return display, nil +} + +// Helper function to extract numeric value from bitrate string (e.g., "3000k" -> 3000) +func extractNumberFromBitrate(bitrate string) int { + // Remove the 'k' suffix and convert to int + numStr := strings.TrimSuffix(bitrate, "k") + num, err := strconv.Atoi(numStr) + if err != nil { + return 3000 // Default fallback + } + return num +} + // If the proper enviromental variables are set, setup a cron job to check the status of the stream // If the stream is not live, then restart the stream // This is used because various platforms have maximum stream durations and after that we need to restart diff --git a/cmd/main_test.go b/cmd/main_test.go index 204fb96..85a1ed8 100644 --- a/cmd/main_test.go +++ b/cmd/main_test.go @@ -6,6 +6,7 @@ import ( "net/http" "net/http/httptest" "os/exec" + "strconv" "testing" "go.uber.org/zap" @@ -107,6 +108,7 @@ func TestLoadConfig(t *testing.T) { t.Setenv("RTMP_URL", "") t.Setenv("RESOLUTION", "") t.Setenv("FRAMERATE", "") + t.Setenv("CHROME_RESTART_INTERVAL", "") logger, _ := zap.NewDevelopment() ctx := utils.SaveLoggerToContext(context.Background(), logger) @@ -128,6 +130,9 @@ func TestLoadConfig(t *testing.T) { if config.Framerate != DefaultFramerate { t.Errorf("Expected default framerate %q, got %q", DefaultFramerate, config.Framerate) } + if config.ChromeRestartInterval != DefaultChromeRestartInterval { + t.Errorf("Expected default Chrome restart interval %d, got %d", DefaultChromeRestartInterval, config.ChromeRestartInterval) + } }) t.Run("Custom Configuration", func(t *testing.T) { @@ -135,11 +140,13 @@ func TestLoadConfig(t *testing.T) { expectedRTMP := "rtmp://custom.example.com/live/test" expectedResolution := "1080p" expectedFramerate := "60" + expectedChromeRestartInterval := 120 t.Setenv("WEBPAGE_URL", expectedURL) t.Setenv("RTMP_URL", expectedRTMP) t.Setenv("RESOLUTION", expectedResolution) t.Setenv("FRAMERATE", expectedFramerate) + t.Setenv("CHROME_RESTART_INTERVAL", strconv.Itoa(expectedChromeRestartInterval)) logger, _ := zap.NewDevelopment() ctx := utils.SaveLoggerToContext(context.Background(), logger) @@ -161,6 +168,9 @@ func TestLoadConfig(t *testing.T) { if config.Framerate != expectedFramerate { t.Errorf("Expected framerate %q, got %q", expectedFramerate, config.Framerate) } + if config.ChromeRestartInterval != expectedChromeRestartInterval { + t.Errorf("Expected Chrome restart interval %d, got %d", expectedChromeRestartInterval, config.ChromeRestartInterval) + } }) t.Run("720p Resolution Dimensions", func(t *testing.T) { @@ -363,3 +373,97 @@ func TestGetDisplayInfo(t *testing.T) { } }) } + +func TestChromeRestartConfiguration(t *testing.T) { + t.Run("Chrome Restart Disabled", func(t *testing.T) { + t.Setenv("CHROME_RESTART_INTERVAL", "0") + + logger, _ := zap.NewDevelopment() + ctx := utils.SaveLoggerToContext(context.Background(), logger) + + config, err := loadConfig(ctx) + + if err != nil { + t.Fatalf("Expected no error, got %v", err) + } + if config.ChromeRestartInterval != 0 { + t.Errorf("Expected Chrome restart interval 0 (disabled), got %d", config.ChromeRestartInterval) + } + }) + + t.Run("Custom Chrome Restart Interval", func(t *testing.T) { + expectedInterval := 30 + t.Setenv("CHROME_RESTART_INTERVAL", strconv.Itoa(expectedInterval)) + + logger, _ := zap.NewDevelopment() + ctx := utils.SaveLoggerToContext(context.Background(), logger) + + config, err := loadConfig(ctx) + + if err != nil { + t.Fatalf("Expected no error, got %v", err) + } + if config.ChromeRestartInterval != expectedInterval { + t.Errorf("Expected Chrome restart interval %d, got %d", expectedInterval, config.ChromeRestartInterval) + } + }) + + t.Run("Invalid Chrome Restart Interval Defaults", func(t *testing.T) { + t.Setenv("CHROME_RESTART_INTERVAL", "invalid") + + logger, _ := zap.NewDevelopment() + ctx := utils.SaveLoggerToContext(context.Background(), logger) + + config, err := loadConfig(ctx) + + if err != nil { + t.Fatalf("Expected no error, got %v", err) + } + if config.ChromeRestartInterval != DefaultChromeRestartInterval { + t.Errorf("Expected Chrome restart interval to default to %d, got %d", DefaultChromeRestartInterval, config.ChromeRestartInterval) + } + }) + + t.Run("Negative Chrome Restart Interval Defaults", func(t *testing.T) { + t.Setenv("CHROME_RESTART_INTERVAL", "-10") + + logger, _ := zap.NewDevelopment() + ctx := utils.SaveLoggerToContext(context.Background(), logger) + + config, err := loadConfig(ctx) + + if err != nil { + t.Fatalf("Expected no error, got %v", err) + } + if config.ChromeRestartInterval != DefaultChromeRestartInterval { + t.Errorf("Expected Chrome restart interval to default to %d, got %d", DefaultChromeRestartInterval, config.ChromeRestartInterval) + } + }) +} + +func TestStartChromeSession(t *testing.T) { + t.Run("Chrome Session Configuration Validation", func(t *testing.T) { + logger, _ := zap.NewDevelopment() + ctx := utils.SaveLoggerToContext(context.Background(), logger) + + config := &Config{ + WebpageURL: "data:text/html,Test Page", // Use data URI to avoid network + Width: 1280, + Height: 720, + } + + // This test validates the configuration that would be passed to Chrome + // Full integration testing would require Chrome to be installed + if config.WebpageURL == "" { + t.Error("Expected webpage URL to be set") + } + if config.Width != 1280 || config.Height != 720 { + t.Errorf("Expected dimensions 1280x720, got %dx%d", config.Width, config.Height) + } + + // Test that context is properly set up + if ctx == nil { + t.Error("Expected context to be non-nil") + } + }) +} diff --git a/go.mod b/go.mod index a274d8b..eb1fb86 100644 --- a/go.mod +++ b/go.mod @@ -3,6 +3,7 @@ module github.com/Zozman/stream-webpage-container go 1.25.0 require ( + github.com/chromedp/cdproto v0.0.0-20250803210736-d308e07a266d github.com/chromedp/chromedp v0.14.1 github.com/nicklaw5/helix/v2 v2.31.1 github.com/prometheus/client_golang v1.23.0 @@ -13,7 +14,6 @@ require ( require ( github.com/beorn7/perks v1.0.1 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect - github.com/chromedp/cdproto v0.0.0-20250803210736-d308e07a266d // indirect github.com/chromedp/sysutil v1.1.0 // indirect github.com/go-json-experiment/json v0.0.0-20250813233538-9b1f9ea2e11b // indirect github.com/gobwas/httphead v0.1.0 // indirect