All of this started with an ambitious goal of creating a bidirectional video streaming service - something like a video call - using a gRPC server which I covered in my last post.
Now that I have basics of gRPC figured out, it’s time to deep dive into how to stream videos over the internet. The very basic and beginner level way is to use a client-side video client or a <video> tag available in HTML5 and give it a src attribute linking to the video file hosted on the cloud.
However, this method is definitely not how most of the videos are available on the internet - partly because it requires the client to first fetch the entire video and then play it. Imagine how this would play out in case of longer, heavier videos. Another reason this approach is avoided is because anyone can download your videos by simply going to the source URL.
The solution to this problem is to send your video to the client in smaller packets and deliver more as the client plays through the video. This is how most of the video streaming services such as YouTube, Netflix, Amazon Prime, Hulu, etc deliver their video content to their users.
Ok so let’s create a very basic streaming server in Golang.
I have a video called ‘input.mp4’ which I need to stream into my browser. But I don’t want to stream the complete mp4 file directly because of the reasons mentioned above. I need a way to split my mp4 file into smaller chunks and an index file with information about which chunk contains which segment of the video.
Enter HTTP Live Streaming, also known as HLS.
HLS was first developed by Apple in order to stream video and audio over HTTP from any basic web server without spending a lot of time/ effort/ money on a heavyweight streaming server. This approach was heavily used across all Apple devices and later it became the common standard for streaming videos across the Internet.
Another similar format is DASH, which is an acronym for Dynamic Adaptive Streaming over HTTP.
The main difference between HLS and DASH is the video codec. HLS requires the videos to be encoded with H264 codecs. DASH is flexible in terms of covering other codecs - which is why DASH is more likely to become the universal standard.
Video encoding is one difficult concept to grasp and I don’t completely understand it as well. But for now let’s just say that it’s a way of compressing and converting source video so it can be played across various devices.
I’ve worked with HLS in this example, for ease. But in future, I’ll do another post about DASH.
Using the FFMPEG library, I was able to convert my MP4 file into HLS format.
To do this, I ran a simple command in Terminal after installing the FFMPEG package through Homebrew.
ffmpeg -i input.mp4 -profile:v baseline -level 3.0 -s 640x360 -start_number 0 -hls_time 10 -hls_list_size 0 -f hls index.m3u8
The hls_time flag defines the duration of each segment. Here it’s set to 10 which means that if the video is 100 seconds long, FFMEG will split it into 10 .ts segments which will be 10 seconds long.
The output files will include 10 .ts segment files and 1 index.m3u8 manifest file with details about which .ts file includes which segment of the video.
This is what the index.m3u8 file looks like -
#EXTM3U
#EXT-X-VERSION:3
#EXT-X-TARGETDURATION:12
#EXT-X-MEDIA-SEQUENCE:0
#EXTINF:12.320000,
index0.ts
#EXTINF:8.720000,
index1.ts
#EXTINF:12.280000,
index2.ts
Now once the MP4 to HLS conversion is complete, I can now code up a Golang server to serve the m3u8 file whenever any HLS video client requests to stream this video and serve the .ts segment files as the client starts to play the video.
package main
import (
"fmt"
"net/http"
"strconv"
"github.com/gorilla/mux"
)
func main() {
http.Handle("/", handlers())
http.ListenAndServe(":8000", nil)
}
func handlers() *mux.Router {
router := mux.NewRouter()
router.HandleFunc("/", indexPage).Methods("GET")
router.HandleFunc("/media/{mId:[0-9]+}/stream/", streamHandler).Methods("GET")
router.HandleFunc("/media/{mId:[0-9]+}/stream/{segName:index[0-9]+.ts}", streamHandler).Methods("GET")
return router
}
func indexPage(w http.ResponseWriter, r *http.Request) {
http.ServeFile(w, r, "index.html")
}
func streamHandler(response http.ResponseWriter, request *http.Request) {
vars := mux.Vars(request)
mId, err := strconv.Atoi(vars["mId"])
if err != nil {
response.WriteHeader(http.StatusNotFound)
return
}
segName, ok := vars["segName"]
if !ok {
mediaBase := getMediaBase(mId)
m3u8Name := "index.m3u8"
serveHlsM3u8(response, request, mediaBase, m3u8Name)
} else {
mediaBase := getMediaBase(mId)
serveHlsTs(response, request, mediaBase, segName)
}
}
func getMediaBase(mId int) string {
mediaRoot := "assets/media"
return fmt.Sprintf("%s/%d", mediaRoot, mId)
}
func serveHlsM3u8(w http.ResponseWriter, r *http.Request, mediaBase, m3u8Name string) {
mediaFile := fmt.Sprintf("%s/hls/%s", mediaBase, m3u8Name)
http.ServeFile(w, r, mediaFile)
w.Header().Set("Content-Type", "application/x-mpegURL")
}
func serveHlsTs(w http.ResponseWriter, r *http.Request, mediaBase, segName string) {
mediaFile := fmt.Sprintf("%s/hls/%s", mediaBase, segName)
http.ServeFile(w, r, mediaFile)
w.Header().Set("Content-Type", "video/MP2T")
}
I have used Mux to configure a HTTP server on port 8000. The target request URL is configured as - localhost:8000/media/<video-id>/stream
Whenever any HLS client makes a GET request to this URL, they are served with the index.m3u8 manifest files with information regarding all the segments and their respective .ts files.
All the subsequent requests for segments are made to - localhost:8000/media/<video-id>/stream/<segment-id>.ts
I’ve also configured all the GET requests to the root URL to be served with an index.html file, which includes the HLS.JS video player with a link to video ID 1 that is being served through the Golang server on this URL - localhost:8000/media/1/stream
<script src="https://cdn.jsdelivr.net/npm/hls.js@latest"></script>
<!-- Or if you want a more recent canary version -->
<!-- <script src="https://cdn.jsdelivr.net/npm/hls.js@canary"></script> -->
<video id="video"></video>
<script>
var video = document.getElementById('video');
if(Hls.isSupported()) {
var hls = new Hls();
hls.loadSource('http://localhost:8000/media/1/stream/');
hls.attachMedia(video);
hls.on(Hls.Events.MANIFEST_PARSED,function() {
video.play();
});
} else if (video.canPlayType('application/vnd.apple.mpegurl')) {
video.src = 'http://localhost:8000/media/1/stream/';
video.addEventListener('loadedmetadata',function() {
video.play();
});
}
</script>
So now I can run my web server by opening Terminal and running ‘go run main.go’ and my streaming server is up on port 8000. Now when I open localhost:8000/ on my browser, I’m served with an HTML file which includes a video player playing the HLS video file.
The HLS.JS player first requests the index.m3u8 file which lets it know which .ts file to request first and make subsequent requests for next segment files.
Alright basics of gRPC and video streaming are clear. Now time to move on to a bigger challenge of doing all this automatically - user uploads a video file, a service encodes it into HLS or DASH, moves the HLS files into a cloud storage, and the streaming client requests the relevant video from the storage or a Content Delivery Network (CDN).
I guess this is the proper way of solving big problems - break them into smaller problems which are easier to solve one at a time.
Based in India. Building a few helpful things. Find me here talking about anything and everything. Keeping a track of my ideas so they don't get lost in the void. Tweet at me if you need me.