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" >}}
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.
🖖