Live Streaming

Live Streaming

Live Streaming

  • We can upload and host videos using a streaming protocol
    • A Video on Demand (VOD) service
  • What about live streaming?
    • The video doesn't exist yet when viewers start watching
    • Segments are created while the streamer is streaming
  • Most live streaming isn't truly live
    • There will be several seconds of delay in the stream
    • Acceptable loss to gain reliability and scale

RTMP

  • Real-Time Messaging Protocol
    • Runs over TCP. Default port 1935
    • The streamer pushes a continuous stream of video and audio to the server
  • Streaming software (OBS, ffmpeg, phones, consoles) speaks RTMP
  • Browsers don't
    • Viewers need HLS, served over HTTP
    • HTTP also works with caches and CDNs, which is how big platforms reach millions of viewers
  • RTMP is used for ingest: Getting the stream into your system

Stream Keys

  • A stream key is a secret that lets someone stream as a user
    • Anyone with the key can stream as that user
    • Treat it like an auth token
  • Every user gets their own key
  • Generate it randomly with at least 80 bits of entropy
  • Unlike auth tokens, you can't store only a hash
    • You need to show the key to its owner

Don't Leak the Key

  • The ingest server names each stream's directory after its stream key
    • /hls/f3a9c2e17b4d80a65e2f1c8d3b7a4e08/index.m3u8
  • If you host that directory as-is, the key is in the URL of every playlist and segment
    • Visible in the Network tab of every viewer
    • Any viewer could stream as that user
  • The key must never appear in any URL, playlist, or segment path requested by viewers
  • Host each stream at a public path instead
    • eg. /live/{username}/index.m3u8
    • Your server maps the public name to the stream key

The Pipeline

  • Typical setup (eg. Twitch/YouTube Live/etc.)
  Streamer (OBS)
        │   RTMP: rtmp://localhost:1935/live + stream key
        ▼
  Ingest server
        │   Converts RTMP stream to HLS: A playlist + segments
        ▼
  Web server  (Your app)
        │   HTTP: Normal GET requests for the playlist and segments
        ▼
  Viewers (hls.js)
  • Viewers never talk to the ingest server

The Ingest Server

  • Receives RTMP streams and turns them into HLS
    • Writes a playlist and segments to disk for each stream
    • Keeps updating them as long as the stream is live
  • Asks your app whether each stream is allowed
  • For the HW, you don't write this
    • Use the tiangolo/nginx-rtmp image
    • Same idea as using the postgres image instead of writing a database
  • Your job is everything around it
    • Stream keys, deciding who can stream, serving streams to viewers, the front end

The Ingest Server

nginx.conf

  • The ingest server is configured with an nginx.conf file
  • Copy it into the container at /etc/nginx/nginx.conf
events {}
rtmp {
    server {
        listen 1935;
        application live {
            live on;

            # Called when a stream starts and ends
            on_publish http://app:8080/api/live/start;
            on_publish_done http://app:8080/api/live/end;

            # Write each stream as HLS to /hls/{stream_key}/index.m3u8
            hls on;
            hls_path /hls;
            hls_nested on;
        }
    }
}

nginx.conf

  • Accept RTMP streams on port 1935 for the application named live
  • Streamers enter:
    • Server: rtmp://localhost:1935/live
    • Stream Key: Their key
  • The stream key becomes the name of the stream
events {}
rtmp {
    server {
        listen 1935;
        application live {
            live on;

            # Called when a stream starts and ends
            on_publish http://app:8080/api/live/start;
            on_publish_done http://app:8080/api/live/end;

            # Write each stream as HLS to /hls/{stream_key}/index.m3u8
            hls on;
            hls_path /hls;
            hls_nested on;
        }
    }
}

nginx.conf

  • When a stream starts, the ingest server sends a request to your app
  • app is the name of your app's service in your compose file
    • Service names are hostnames between containers
  • Change the paths to the endpoints you choose
events {}
rtmp {
    server {
        listen 1935;
        application live {
            live on;

            # Called when a stream starts and ends
            on_publish http://app:8080/api/live/start;
            on_publish_done http://app:8080/api/live/end;

            # Write each stream as HLS to /hls/{stream_key}/index.m3u8
            hls on;
            hls_path /hls;
            hls_nested on;
        }
    }
}

nginx.conf

  • Write every live stream as HLS in /hls
    • Inside the ingest container
  • hls_nested: Each stream gets its own directory named after its stream key
    • /hls/{stream_key}/index.m3u8
    • /hls/{stream_key}/0.ts, 1.ts, ...
events {}
rtmp {
    server {
        listen 1935;
        application live {
            live on;

            # Called when a stream starts and ends
            on_publish http://app:8080/api/live/start;
            on_publish_done http://app:8080/api/live/end;

            # Write each stream as HLS to /hls/{stream_key}/index.m3u8
            hls on;
            hls_path /hls;
            hls_nested on;
        }
    }
}

on_publish

  • When a streamer connects, the ingest server sends a POST request to your app
  • The body is url-encoded
    • Same format as the HTML forms from HW2
    • Several fields. name is the stream key the streamer entered
  • on_publish_done sends the same format when the stream ends
    • Remove the stream from your active streams
POST /api/live/start HTTP/1.1
Host: app:8080
Content-Type: application/x-www-form-urlencoded

app=live&...&call=publish&name=f3a9c2e17b4d80a65e2f1c9d3b7a4e08&type=live
  • Respond 200 OK to allow the stream
  • Respond 403 Forbidden to reject it. The ingest server disconnects the streamer

Three Containers

  • Your app now has three services
    • app: Your Go server
    • db: Your database
    • ingest: nginx-rtmp. Write a Dockerfile in ./ingest
  • docker compose up must start all three
  • nginx looks up the hostname for on_publish and on_publish_done when it starts
    • If your app's container doesn't exist yet, the ingest server fails
  • Use depends_on to ensure your app starts first
services:
  ingest:
    build: ./ingest
    # ...
  db:
    image: postgres
    # ...
  app:
    build: .
    # ...

Live Playlists

  • Live HLS playlists change while you watch
    • New segments are added every few seconds
    • Old segments are removed from the playlist and deleted
    • No #EXT-X-ENDLIST until the stream is over
  • The player re-requests the playlist every few seconds to find new segments
#EXTM3U
#EXT-X-VERSION:3
#EXT-X-MEDIA-SEQUENCE:41
#EXT-X-TARGETDURATION:5
#EXTINF:5.000,
41.ts
#EXTINF:5.000,
42.ts
#EXTINF:5.000,
43.ts

Live Playlists

  • Caching: The playlist changes every few seconds
    • Your caching headers might cause browsers to reuse an old playlist
    • Don't send caching headers for live playlists
      • To be extra sure, send Cache-Control: no-store
  • Segments never change once written
    • Caching them is fine
  • One rendition per stream
    • Whatever quality the streamer sends. No ABR for this objective

Docker Volumes

Containers Have Their Own Files

  • Each container has its own filesystem
  • The ingest server writes streams to /hls in the ingest container
    • Your app can't see them
  • Files written inside a container are lost when the container is recreated
    • docker compose up --build --force-recreate
    • Uploaded avatars and videos disappear
  • We need storage that lives outside the container

Volumes

  • A volume maps storage outside the container to a directory inside the container
  • This mirrors all files in local directory ./streams and the container directory hls
    • You will be able to see all the hls files in your local directory
  • Recommended to use volumes starting with avatar uploads
    • You can see the files on your machine. Great for debugging
  • Mount the same storage into two containers and they can share files
    • Mounting ./streams to a directory in your app container lets your app access the streams
services:
  ingest:
    volumes:
      - ./streams:/hls
    # ...

Volume Gotchas

  • --renew-anon-volumes deletes anonymous volumes when testing and grading
    • Bind mounts and named volumes persist
  • A volume hides what the image had at that path
    • Mount ./uploads at /app/public and your front end disappears
    • Mount only the directories that hold uploaded or streamed files
  • Directories must exist before you write to them
    • Create them in your code at startup if they don't exist

Further Reading