Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
FRAMERATE=30
# Chrome restart interval in minutes for memory management (0 = disabled, default: 60)
CHROME_RESTART_INTERVAL=60
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down
290 changes: 199 additions & 91 deletions cmd/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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() {
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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
}

Expand All @@ -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)
Expand Down Expand Up @@ -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...")

Expand All @@ -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
Expand Down
Loading
Loading