Mustafa Can Yücel
blog-post-24

Streaming RTSP on an HTML Page Part 2

Introduction

In the previous blog post, we have explained how to stream RTSP on an HTML page using HLS while transcoding it continuously with FFmpeg. Since the transcoding is 24/7, it is suitable for dashboards or livestreams. However, if your goal is to stream on a web page where not many users are expected, or you have to pay for every CPU second, it is not a good idea to transcode the stream while noone is watching. In this post, we will create a new architecture where we will transcode the stream only when a user connects to the page.

You can find all the code also on GitHub.

The server architecture will have the following components:

  • A manually-initiated systemd service will be responsible for starting the FFmpeg transcoding.
  • A server app will be responsible for:
    • Starting the aforementioned systemd service on request (via an api endpoint)
    • Keeping the service alive (or rather not touching the running script) as long as heartbeat requests arrive from the clients
    • Stopping the service when no heartbeat requests arrive for a certain amount of time
    • Treating initialize requests from other clients that arrive while the server is already running as a heartbeat request
We will use Go for the server app and systemd for the service. The server app will be responsible for starting the service, and the service will be responsible for transcoding the stream.

If you are starting from a clean slate, you can refer to the previous blog post on how to install the dependencies (ffmpeg, caddy, etc.) and configure the basic settings. Remember that if you are using Oracle Cloud, you need to open the necessary ports (80, 443, 1935) in the security list, and handle the iptables rules accordingly.

If you are creating a new server, and you will use this server only for a single purpose of streaming, you can use Ubuntu Minimal 22.04 instead of the full version. This will save you some disk space.

Creating Project Directory Structure

Since we will have a multitude of files, it is a good idea to create a directory structure. We will create the following directories:

ubuntu@kocaeli-streaming-2:/opt/stream-service/scripts$ tree /opt/stream-service/
/opt/stream-service/
├── bin
│   └── stream-manager
├── logs
│   ├── camera.log
│   └── manager.log
├── scripts
│   └── stream.sh
└── src
    └── stream-manager
        ├── go.mod
        └── main.go

5 directories, 6 files
  • bin directory will contain the compiled binaries.
  • logs directory will contain the log files, for both the manager app and the transcoding script.
  • scripts directory will contain the transcoding script.
  • src directory will contain the source code of the manager app.

The Transcoding Service

First, we create the script file that will be run by the systemd service. The script will be responsible for transcoding the RTSP stream to HLS. We will use the same script as in the previous blog post:

sudo nvim /opt/stream-service/scripts/stream.sh
#!/bin/bash
# Define directory
HLS_DIR="/var/www/hls"

# Clean existing files
rm -f ${HLS_DIR}/*.ts ${HLS_DIR}/playlist.m3u8

# Create directory if it doesn't exist
mkdir -p ${HLS_DIR}

ffmpeg -loglevel info \
    -fflags nobuffer \
    -flags low_delay \
    -rtsp_transport tcp \
    -i "rtsp://[ip]/user=[user]&password=[password]&channel=[channel]&stream=0.sdp" \
    -an \
    -c:v libx264 \
    -preset ultrafast \
    -tune zerolatency \
    -profile:v baseline \
    -f hls \
    -s 1280x720 \
    -hls_time 2 \
    -hls_list_size 3 \
    -hls_flags delete_segments+independent_segments \
    -hls_segment_type mpegts \
    -flush_packets 1 \
    -hls_segment_filename "${HLS_DIR}/segment_%03d.ts" \
    "${HLS_DIR}/playlist.m3u8"
Change the [ip], [user], [password], [channel] fields, then don't forget to make the script executable, and adjust the permissions:
chmod +x /opt/stream-service/scripts/stream.sh
# required permissions:
-rwxr-xr-x 1 root root  750 Feb  7 12:24 stream.sh
Once the script is ready, don't forget to test if it is working or not via the caddy configuration that we have explained in detail in the previous blog post. Making sure that the script is working is crucial before proceeding to the next steps, otherwise, you will have a hard time debugging the issues.

After the script is working, we can proceed to create the systemd service that will run this script. We will create a service file:

sudo nvim /etc/systemd/system/camera-stream.service
[Unit]
Description=RTSP to HLS Stream Converter for Camera 1
After=network-online.target
Wants=network-online.target

[Service]
Type=simple
ExecStart=/opt/stream-service/scripts/stream.sh
ExecStop=/bin/pkill -TERM ffmpeg
Restart=always
RestartSec=10
User=root
StandardOutput=append:/opt/stream-service/logs/camera.log
StandardError=append:/opt/stream-service/logs/camera.log
KillMode=mixed
TimeoutStopSec=20

[Install]
WantedBy=multi-user.target
After saving the file, don't forget to reload the systemd daemon to apply the changes:
sudo systemctl daemon-reload
Remember that we are not starting the service yet. We will start the service only when a user connects to the page. Still, you should test the service by starting it manually:
sudo systemctl start camera-stream
Again verify that the service is working as expected by testing via the test HTML page so that if there are any issues, you can debug them before proceeding to the next steps. Once you are done, don't forget to stop the service, and kill any remaining ffmpeg processes:
sudo systemctl stop camera-stream
sudo pkill -TERM ffmpeg

Caddy Configuration

Before starting the server, it is necessary to configure the Caddy server for the test page, and the api endpoints. We will edit Caddyfile:

sudo nvim /etc/caddy/Caddyfile
stream.test.example.com {
    handle_path /api/* {                                                                                                                                                                         uri strip_prefix /api
      reverse_proxy localhost:8080                                                                                                                                                        header {
        Access-Control-Allow-Origin *
      }
    }
    handle /* {                                                                                                                                                                         root * /var/www/hls
      file_server
      header {
        Access-Control-Allow-Origin *
        Cache-Control "no-cache, no-store, must-revalidate" 
      }
    }
}
In this configuration, we have two handlers. The first handler will handle the api requests, and the second handler will handle the HLS files. The api requests will be forwarded to the server app running on port 8080 after stripping the api prefix (as we do not consider it in the Go app), and the HLS files will be served from the /var/www/hls directory. Don't forget to reload the Caddy server to apply the changes:
sudo systemctl reload caddy
If you did not add or change domains, you can only reload caddy config as well (note that you should be in the directory containing the Caddyfile):
sudo caddy reload

Test Page

We need a test page to test the server app. We will create an HTML file under the /var/www/hls directory:

sudo nvim /var/www/hls/streamtest.html
<!DOCTYPE html>
    <html>
    <head>
        <title>Stream Test</title>
        <script src="https://cdn.jsdelivr.net/npm/hls.js@latest"></script>
        <style>
            .container {
                max-width: 800px;
                margin: 20px auto;
                padding: 20px;
                font-family: Arial, sans-serif;
            }
            .video-container {
                margin: 20px 0;
                background: #000;
                border-radius: 8px;
                overflow: hidden;
            }
            video {
                width: 100%;
                aspect-ratio: 16/9;
            }
            .controls {
                margin: 20px 0;
                padding: 15px;
                background: #f5f5f5;
                border-radius: 8px;
            }
            .status {
                margin-top: 10px;
                padding: 10px;
                background: #fff;
                border-radius: 4px;
            }
            button {
                padding: 8px 16px;
                margin-right: 10px;
                border-radius: 4px;
                border: none;
                background: #007bff;
                color: white;
                cursor: pointer;
            }
            button:hover {
                background: #0056b3;
            }
        </style>
    </head>
    <body>
        <div class="container">
            <div class="controls">
                <button onclick="startStream()">Start Stream</button>
                <div class="status" id="status">Ready to start stream</div>
            </div>
            <div class="video-container">
                <video id="video" controls playsinline></video>
            </div>
        </div>
    
        <script>
            const BASE_URL = 'https://stream2.kocaeli.bridgewiz.com';
            const API_URL = `${BASE_URL}/api`;
            let hls = null;
            let heartbeatInterval = null;
    
            async function startStream() {
                try {
                    document.getElementById('status').textContent = 'Initializing stream...';
    
                    const response = await fetch(`${API_URL}/initiate`, {
                        method: 'POST'
                    });
    
                    const data = await response.json();
    
                    if (data.status === 'success') {
                        document.getElementById('status').textContent = 'Waiting for stream to initialize...';
                        await new Promise(resolve => setTimeout(resolve, 10000));
                        document.getElementById('status').textContent = data.message;
                        startHeartbeat();
                        startPlayer(data.url);
                    } else {
                        document.getElementById('status').textContent = 'Failed to start stream';
                    }
                } catch (error) {
                    document.getElementById('status').textContent = 'Error: ' + error.message;
                }
            }
    
            function startPlayer(streamUrl) {
                const video = document.getElementById('video');
    
                if (Hls.isSupported()) {
                    if (hls) {
                        hls.destroy();
                    }
     hls = new Hls({
                        debug: false,
                        enableWorker: true,
                        lowLatencyMode: true,
                        backBufferLength: 90
                    });
    
                    hls.loadSource(`${BASE_URL}/playlist.m3u8`);
                    hls.attachMedia(video);
    
                    hls.on(Hls.Events.MANIFEST_PARSED, () => {
                        video.play().catch(e => {
                            console.error('Playback error:', e);
                            document.getElementById('status').textContent =
                                'Playback error: ' + e.message;
                        });
                    });
    
                    hls.on(Hls.Events.ERROR, (event, data) => {
                        if (data.fatal) {
                            switch(data.type) {
                                case Hls.ErrorTypes.NETWORK_ERROR:
                                    document.getElementById('status').textContent =
                                        'Network error, attempting recovery...';
                                    hls.startLoad();
                                    break;
                                case Hls.ErrorTypes.MEDIA_ERROR:
                                    document.getElementById('status').textContent =
                                        'Media error, attempting recovery...';
                                    hls.recoverMediaError();
                                    break;
                            }
                        }
                    });
                } else {
                    document.getElementById('status').textContent =
                        'HLS is not supported in your browser';
                }
            }
    function startHeartbeat() {
                // Clear existing heartbeat if any
                if (heartbeatInterval) {
                    clearInterval(heartbeatInterval);
                }
    
                // Send heartbeat every minute
                heartbeatInterval = setInterval(async () => {
                    try {
                        const response = await fetch(`${API_URL}/heartbeat`, {
                                            method: 'POST'
                        });
    
                        if (!response.ok) {
                            throw new Error('Heartbeat failed');
                        }
                    } catch (error) {
                        console.error('Heartbeat error:', error);
                        document.getElementById('status').textContent =
                            'Connection issues detected, attempting to recover...';
                    }
                }, 60000);
            }
    
            // Clean up on page unload
            window.addEventListener('beforeunload', () => {
                if (heartbeatInterval) {
                    clearInterval(heartbeatInterval);
                }
                if (hls) {
                    hls.destroy();
                }
            });
        </script>
    </body>
    </html>

One important point with this sample page is that it waits for 10 seconds after the stream is initiated before starting the player. This is because the FFmpeg process needs some time to start the transcoding and create the initial segments. However, depending on server load and other factors, this time may not be enough, in which case you will see an error on the page and the stream will not start. Still you can wait for few more seconds and click the button again to start the stream. Remember that this is a very basic implementation for testing and debug purposes, and you should improve it by adding a loading spinner, or a retry mechanism.

The Server App

We will use Go as the programming language for the server app. First, we will create the Go module:

sudo mkdir -p /opt/stream-service/src/stream-manager
cd /opt/stream-service/src/stream-manager
go mod init stream-manager
Then we will create the main.go file:
sudo nvim /opt/stream-service/src/stream-manager/main.go
The responsibilities of this app will be many:
  • Starting the systemd service on request
  • Keeping the service alive as long as heartbeat requests arrive from the clients
  • Stopping the service and FFmpeg processing when no heartbeat requests arrive for a certain amount of time
  • Treating initialize requests from other clients that arrive while the server is already running as a heartbeat request
package main

import (
    "encoding/json"
    "log"
    "net/http"
    "os/exec"
    "sync"
    "time"
)

type StreamManager struct {
    mu              sync.Mutex
    isActive        bool
    lastHeartbeat   time.Time
    timeoutDuration time.Duration
}

type Response struct {
    Status  string `json:"status"`
    Message string `json:"message"`
    URL     string `json:"url,omitempty"`
}

func NewStreamManager() *StreamManager {
    sm := &StreamManager{
        timeoutDuration: 5 * time.Minute,
    }

    // Start monitoring routine
    go sm.monitor()

    return sm
}

func (sm *StreamManager) startService() error {
    log.Printf("Attempting to start camera-stream.service...")
    cmd := exec.Command("systemctl", "start", "camera-stream.service")
    output, err := cmd.CombinedOutput()
    if err != nil {
        log.Printf("Error starting service: %v, output: %s", err, string(output))
        return err
    }
    log.Printf("Service start command completed successfully")
    return nil
}
func (sm *StreamManager) stopService() error {
    log.Printf("Attempting to stop camera-stream.service...")
    // First try to stop service normally
    cmd := exec.Command("systemctl", "stop", "camera-stream.service")
    output, err := cmd.CombinedOutput()
    if err != nil {
        log.Printf("Error stopping service: %v, output: %s", err, string(output))
        return err
    }

    // Double check if ffmpeg is still running
    time.Sleep(2 * time.Second)  // Give it a moment
    checkCmd := exec.Command("pgrep", "ffmpeg")
    if output, _ := checkCmd.CombinedOutput(); len(output) > 0 {
        log.Printf("FFmpeg still running, forcing kill...")
        killCmd := exec.Command("pkill", "-9", "ffmpeg")
        if err := killCmd.Run(); err != nil {
            log.Printf("Error force killing ffmpeg: %v", err)
        }
    }

    log.Printf("Service stop command completed successfully")
    return nil
}

func (sm *StreamManager) isServiceRunning() (bool, error) {
    cmd := exec.Command("systemctl", "is-active", "camera-stream.service")
    output, err := cmd.CombinedOutput()
    if err != nil {
        return false, nil  // Service is not running
    }
    return string(output) == "active\n", nil
}

func (sm *StreamManager) InitiateStream(w http.ResponseWriter, r *http.Request) {
    if r.Method != http.MethodPost {
        log.Printf("Method not allowed: %s", r.Method)
        http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
        return
    }

    sm.mu.Lock()
    defer sm.mu.Unlock()

    // Check if already active
    if sm.isActive {
        log.Printf("Stream already active, returning existing URL")
        resp := Response{
            Status:  "success",
            Message: "Stream already active",
            URL:     "/playlist.m3u8",
        }
        sendJSONResponse(w, resp)
        return
    }

    log.Printf("Starting new stream...")
    // Start the service
    if err := sm.startService(); err != nil {
        log.Printf("Failed to start service: %v", err)
        http.Error(w, "Failed to start stream", http.StatusInternalServerError)
        return
    }

    sm.isActive = true
    sm.lastHeartbeat = time.Now()

    log.Printf("Stream started successfully")
    resp := Response{
        Status:  "success",
        Message: "Stream initiated",
        URL:     "/playlist.m3u8",
    }
    sendJSONResponse(w, resp)
}

func (sm *StreamManager) Heartbeat(w http.ResponseWriter, r *http.Request) {
    if r.Method != http.MethodPost {
        http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
        return
    }

    sm.mu.Lock()
    defer sm.mu.Unlock()

    if !sm.isActive {
        http.Error(w, "No active stream", http.StatusNotFound)
        return
    }

    sm.lastHeartbeat = time.Now()

    resp := Response{
        Status:  "success",
        Message: "Heartbeat received",
    }
    sendJSONResponse(w, resp)
}

func (sm *StreamManager) monitor() {
    ticker := time.NewTicker(30 * time.Second)
    defer ticker.Stop()

    for range ticker.C {
        sm.mu.Lock()
        if sm.isActive && time.Since(sm.lastHeartbeat) > sm.timeoutDuration {
            log.Println("Stream timed out, stopping service...")
            if err := sm.stopService(); err != nil {
                log.Printf("Error stopping service: %v", err)
            }
            sm.isActive = false
        }
        sm.mu.Unlock()
    }
}
func sendJSONResponse(w http.ResponseWriter, resp Response) {
    w.Header().Set("Content-Type", "application/json")
    json.NewEncoder(w).Encode(resp)
}

func main() {
    sm := NewStreamManager()

    http.HandleFunc("/initiate", sm.InitiateStream)
    http.HandleFunc("/heartbeat", sm.Heartbeat)

    log.Println("Server starting on :8080...")
    log.Fatal(http.ListenAndServe(":8080", nil))
}

Once the file is saved, we will compile it and create a service, but as always, you should run it manually first to see if it is working as expected:

go run /opt/stream-service/src/stream-manager/main.go
If everything is working as expected, we will compile the app into the bin directory, and create a service for it:
go build -o /opt/stream-service/bin/stream-manager /opt/stream-service/src/stream-manager/main.go
sudo nvim /etc/systemd/system/stream-manager.service
[Unit]
Description=Stream Manager Service
After=network.target

[Service]
Type=simple
ExecStart=/opt/stream-service/bin/stream-manager
WorkingDirectory=/opt/stream-service
StandardOutput=append:/opt/stream-service/logs/manager.log
StandardError=append:/opt/stream-service/logs/manager.log
Restart=always
User=root

[Install]
WantedBy=multi-user.target/code>
After saving the file, don't forget to reload the systemd daemon to apply the changes:
sudo systemctl daemon-reload
And start the service:
sudo systemctl start stream-manager
Verify that the service is working as expected by testing via the test HTML page. If everything is working as expected, you can enable the service to start on boot:
sudo systemctl enable stream-manager

Conclusion

In this post, we have explained how to stream RTSP on an HTML page using HLS without transcoding the stream 24/7. We have created a server app that will start the transcoding service only when a user connects to the page, and will stop the service when no users are connected. This architecture is more cost-effective than the 24/7 transcoding, and is suitable for use cases where the stream is not expected to be watched continuously. Remember that this is a basic implementation, and you should improve it by adding more features like authentication, error handling, and more.

In the next blog post, we will explain how to integrate this camera stream into a Svelte/SvelteKit app in a modular way.