I’ve looked into different video solution for this blog before — and, at the time, settled on using Coconut.co for encoding, AWS S3 for hosting, and Video.js for playing.

Bunny Stream was on the table back then, but I wanted a more hands on solution. Well — this time around I wanted a hands off solution, where the videos just work without me having to worry about it 🙂

And for that — Bunny Stream is pretty awesome, so that’s what I’m using now 👍

Table of contents

Why change?

Why did I decide to change my video solution in the first place?

One of my posts, the RPi security alarm, got featured on a Linux podcast/talk show video. They were discussing the post as they scrolled thought it, and when they got to a video — it didn’t work 😞

Extremely disappointing… That, and some other issues I experienced myself, led to the decision to outsource video — and let someone else worry about it.

This is a Twitter thread from November 2023, when I started looking into alternatives:

I discovered last week that some of the videos on my blog didn’t work, froze on my phone, and random CORS errors 🤷 Down the rabbit-hole I went — looking for a video service, YouTube and Vimeo is out. I mainly looked at @MuxHQ and @BunnyCDN Stream.

I really liked @MuxHQ, embedding videos without iFrame. Support was nice and helpful. But playback restrictions requires signed URLs, which is hard on a statically generated website. Streaming minutes are expensive, a bit scary with no way to prevent hot-linking.

So I ended up with @BunnyCDN Stream, which is a lot cheaper, and supports playback restrictions. Nice dashboard, API and support. I think the iFrame player performance could be improved, I’ve sent a support ticket about that. Currently migrating videos.

I have tried @BunnyCDN Streams before, when it was in preview. It seems more polished now 🙂 I wrote about my previous video solution a few years ago.

Uploading and encoding videos

Alright, enough of that — let’s get info the details on how uploading and encoding happens.

This is the workflow:

  • Copy the new video file into videos/ folder
  • Run the Bash script all_videos.sh

Let’s go through the different steps, but first take a quick look at the bunny-stream project folder:

bunny-stream
├── all_videos.sh
├── bunny.py
├── fetch.py
├── video -> ~/vault/Videos/Web
└── videos.json

Bash script

The Bash script all_videos.sh checks all video files in the video/ folder — if they are not defined in videos.json, they are passed to the Python script fetch.py:

#!/bin/bash

masters=`find video/ -type f`

for master in $masters; do
    video_file="$(basename -- $master)"
    if [ "$( jq < videos.json "has(\"$video_file\")")" == "false" ]; then
        echo $master
        sleep 2
        python3 fetch.py --video $master
    fi
done

Python scripts

First we need to take a quick look at bunny.py — a tiny wrapper for the Bunny Stream fetch video API call:

import requests
import json

api_key = "xxxxxxxx-xxxx-xxxx-xxxxxxxxxxxx-xxxx-xxxx"
lib_id = "xxxxxx"


def fetch_video(dl_url: str, dl_headers: dict) -> dict:
    url = f"https://video.bunnycdn.com/library/{lib_id}/videos/fetch"

    payload = json.dumps({
        "url": dl_url,
        "headers": dl_headers
    })
    headers = {
        "accept": "application/json",
        "content-type": "application/*+json",
        "AccessKey": api_key
    }

    response = requests.post(url, data=payload, headers=headers)
    data = json.loads(response.text)

    return data

And now for the main event — fetch.py, this is the workflow:

  • Check if the video is already defined in videos.json — if so: abort
  • Call Bunny Stream, using bunny.api, to fetch video file from my web server
    • Using basic authentication
  • Store the video file name, and Bunny Stream ID in videos.json
import json
import argparse
import os
import bunny

parser = argparse.ArgumentParser()
parser.add_argument('--video', dest="filename", required=True)
args = parser.parse_args()


videos = {}

if __name__ == "__main__":
    if args.filename == "":
        raise ValueError("Input filename missing")

    video_path = args.filename.replace("video/", "")
    video_folder = os.path.dirname(video_path)
    video_file = os.path.basename(video_path)

    with open('videos.json') as json_file:
        videos = json.load(json_file)

    if video_file in videos:
        raise SystemExit('Error: Video file already in database')

    dl_url = "https://storage.my-web-server.com/videos/" + video_path
    dl_headers = { "Authorization": "Basic xxxxxxxxxxxxxxxxxxxxxxxx" }

    print(dl_url)

    bunny_stream = bunny.fetch_video(dl_url, dl_headers)

    print(json.dumps(bunny_stream, indent=4))

    videos[video_file] = {
        "bunny_id": bunny_stream["id"]
    }

    with open('videos.json', 'w') as outfile:
        json.dump(videos, outfile, indent=4)

Let’s try that now — to see it in action:

$ cd ~/dev/bunny-stream
$ python3 fetch.py --video video/homelab/file-server/file-server-blinkenlights.mp4 
https://storage.my-web-server.com/videos/homelab/file-server/file-server-blinkenlights.mp4
{
    "id": "3f6e1d45-796b-4638-9af7-f7b4c884ce31",
    "success": true,
    "message": "OK",
    "statusCode": 200
}

Master videos.json

Sweet — the fetch.py completed successfully, and a new entry was added to bunny-stream/videos.json. Let’s have a look:

{
    "file-server-blinkenlights.mp4": {
        "bunny_id": "3f6e1d45-796b-4638-9af7-f7b4c884ce31"
    }
}

The video file name, and Bunny Stream ID, was added to the file 👍

Caddy configuration

Just a quick detour to view the Caddy configuration for my storage web server:

:80 {
    root * /usr/share/caddy

    basic_auth /videos/* {
        user xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
    }

    file_server browse
}

Very basic stuff… No HTTPS, because that is handled by the reverse proxy.

The videos folder is added to the LXC container as a read-only mount point in Proxmox:

# pct set 102 -mp0 /srv/tank0/vault/Videos/Web,mp=/usr/share/caddy/videos,ro=1

Okay, back to the main topic!

Using videos on this site

Now; with the video uploaded to, and processed by, Bunny Stream — it can be used on this site. We now leave the bunny-stream project folder, and enter the project folder for this blog — with the following workflow:

  • Run command make new-video path=video/path/to/file.mp4
  • A new video ID is generated and appended to data/videos.json, along with the video path as specified

Makefile

The Makefile for this blog has many rules, but the one we are interested in is new-video, which is basically one really long jq command:

.PHONY: new-video
new-video:
ifdef path
    cat data/videos.json | jq '."${shell uuidgen}" += {"path": "${path}", "bunny": "$(shell cat ../bunny-stream/videos.json | jq -r '."$(notdir ${path})"'.bunny_id)"}' > data/videos.json.tmp
    mv data/videos.json.tmp data/videos.json
    cat data/videos.json | jq 'to_entries | .[-1:] | from_entries'
else
    $(error path is not set)
endif

Let’s try it for the video we previously uploaded:

$ cd ~/dev/cavelab-blog
$ make new-video path=video/homelab/file-server/file-server-blinkenlights.mp4
cat data/videos.json | jq '."2586cbcb-7353-4190-9d83-1cf8de1bd862" += {"path": "video/homelab/file-server/file-server-blinkenlights.mp4", "bunny": "3f6e1d45-796b-4638-9af7-f7b4c884ce31"}' > data/videos.json.tmp
mv data/videos.json.tmp data/videos.json
cat data/videos.json | jq 'to_entries | .[-1:] | from_entries'
{
  "2586cbcb-7353-4190-9d83-1cf8de1bd862": {
    "path": "video/homelab/file-server/file-server-blinkenlights.mp4",
    "bunny": "3f6e1d45-796b-4638-9af7-f7b4c884ce31"
  }
}

Blog videos.json

The new video we added was now appended to data/videos.json. It contains the newly created video ID, the video path, and Bunny Stream ID. As was printed in the command above:

{
  "2586cbcb-7353-4190-9d83-1cf8de1bd862": {
    "path": "video/homelab/file-server/file-server-blinkenlights.mp4",
    "bunny": "3f6e1d45-796b-4638-9af7-f7b4c884ce31"
  }
}

With the video ID separated from the Bunny Stream ID — we’re not vendor locked to Bunny Stream. The video ID will never change, but the properties of the video might. More on that later 👇

Hugo shortcode

Now for the final piece of the puzzle; a Hugo shortcode takes the video ID as an argument, looks up the Bunny Stream ID and inserts the embedded video player.

{{- $id := .Get "id" -}}
{{- $bunny_id := (index $.Site.Data.videos $id).bunny -}}

<figure class="center">
  {{- if $bunny_id -}}
    <div style="position:relative;padding-top:56.25%;">
      <iframe
        src="https://iframe.mediadelivery.net/embed/xxxxxx/{{$bunny_id}}?autoplay=false&loop=false&muted=false&preload=true"
        loading="lazy"
        style="border:0;position:absolute;top:0;height:100%;width:100%;" 
        allow="accelerometer;gyroscope;autoplay;encrypted-media;picture-in-picture;" 
        allowfullscreen="true">
      </iframe>
    </div>
  {{- else -}}
    {{- errorf "Missing Bunny Stream ID on video %s" $id -}}
  {{- end -}}
  {{- if .Get "caption" -}}
    <figcaption class="center">{{ .Get "caption" | markdownify }}</figcaption>
  {{- end -}}
</figure>

Using the shortcode

{{< video id="2586cbcb-7353-4190-9d83-1cf8de1bd862" caption="Video caption" >}}
Video caption

Why so complicated?

This may seem overly complicated, especially the part where a video has two IDs — one video ID, and one Bunny Stream ID. But as I mentioned; there is a good reason for this.

It prevents vendor lock-in — and makes it much easier to reupload videos, move to a different video solution, or even use multiple solutions at once.

I’ve changed my video solution a few times, and I may do so in the future. Having an immutable video ID means I only have to update the videos.json file and my shortcode. Not the content of my posts.

As a theoretical example of using multiple solutions; if a video doesn’t have a Bunny Stream ID, but a MUX ID — the MUX player can be embedded instead 🙂

Conclusion

I’m very happy with Bunny Stream, it just works. And if it stops working — well, that is someone else’s problem 🙂

I have no intention of migrating away from Bunny Stream, but should I ever need to — I’ll be glad I decided to have immutable video IDs.

This post got pretty long and technical, I also have a Python script to mass update the blog data file with Bunny Stream IDs — but I feel that is outside the scope of this post.

🖖