Jellyfin QuickSync in Kubernetes (and the subtitle trap)
Building the lab: Part 6 of 8. ← Where AI fits in the lab Next: Two tunnels are better than one
Two things broke my media box in the same week. For a while, I did not see that they were the same problem.
The first was the GPU. The pod could see the graphics device, but it could not use it. The second was subtitles. The file had them, but the player refused them and the stream died. In both cases, the thing was there, but not usable. The difference was one setting, in a place I was not looking. I only understood that later.
These are my notes on both, mostly so I do not have to work them out twice.
Part one: give the pod the GPU
The media box has an Intel i5 with a built-in GPU. QuickSync is the only real reason this box earns its place: it can convert video in hardware. So a Jellyfin stream should cost a few percent of the GPU instead of a whole CPU. I trust that, mostly. It is why I bought the box.
On the machine, everything looks ready. The driver is loaded. The device files exist.
$ ls -l /dev/dri
crw-rw---- 1 root video 226, 0 card0
crw-rw-rw- 1 root render 226, 128 renderD128So the obvious move is the one every guide gives you: mount /dev/dri into the pod, and call it done.
volumeMounts:
- name: dri
mountPath: /dev/dri
volumes:
- name: dri
hostPath:
path: /dev/dri
type: DirectoryThe device shows up inside the pod, but the video still runs on the CPU. ffmpeg fills a core and a half, and the driver says it cannot find a display:
No VA display found for device /dev/dri/renderD128For a while, I believed that message and went looking at drivers. It does not mean what it says. The device is there, and the drivers are there. The real problem is this: seeing a device is not the same as being allowed to open it. What allows it is the device cgroup, not the file permissions. A hostPath mount puts the file inside the pod’s filesystem, but it does nothing to the pod’s list of allowed devices. So the open fails, the driver reports “no display”, and you go hunting in the wrong place. That took me a very long time to work out.
I proved it to myself the cheap way. I just opened the file:
# on the machine
$ ( exec 3<>/dev/dri/renderD128 && echo OK )
OK
# inside the pod
$ ( exec 3<>/dev/dri/renderD128 && echo OK )
sh: cannot create /dev/dri/renderD128: Operation not permittedOpen on the machine. Denied in the pod. That one line was the whole answer.
The fix
There are two ways to give the device to the container. The blunt way is to run the pod as privileged:
containers:
- name: jellyfin
securityContext:
privileged: trueThat gives it every device on the machine, which is more than the GPU and more than I want. But on a single-purpose media box it is the one-line answer, and it worked at once. The tidier way is the Intel GPU device plugin. It registers the device, so you can ask for exactly that device:
resources:
limits:
gpu.intel.com/i915: 1I used privileged first, just to get it working, and told myself I would switch later. I have not. I am not sure that is the right call, and I would rather say so than write it up like it was a decision.
That was only half of it. The container was not the only gate. Jellyfin also has to be told to use the hardware. By default, it is not. The encoding settings need a type:
{ "HardwareAccelerationType": "qsv",
"VaapiDevice": "/dev/dri/renderD128",
"EnableHardwareEncoding": true }Then I checked the chain, instead of trusting a checkbox. The bundled ffmpeg has the QSV encoders, and the driver opens:
$ ffmpeg -init_hw_device qsv=hw:/dev/dri/renderD128 ... -c:v h264_qsv -f null -
libva info: va_openDriver() returns 0returns 0 was the sentence I wanted. After that, a stream shows the right arguments, and the CPU stops being the bottleneck:
-init_hw_device vaapi=va:...,driver=iHD
-init_hw_device qsv=qs@va
-codec✌️0 h264_qsvOne more detail, which cost me half a confused hour. The VAAPI drivers in the official Jellyfin image are not in the system path. If you test by hand, you have to point at them yourself. If you do not, you get the same misleading “no display” error, and you think the fix failed.
LIBVA_DRIVERS_PATH=/usr/lib/jellyfin-ffmpeg/lib/dri \
LIBVA_DRIVER_NAME=iHD ffmpeg ...Jellyfin does that for you when it runs its own ffmpeg. You only need to remember it when you are the one running it.
Part two: the subtitle trap
With transcoding finally cheap, I played an episode and hit the next wall. The moment I turned subtitles on, the player said they were not supported and dropped the stream. I was confused, because I could see the subtitles in the file.
They were. There were four of them, all English. The trouble was the kind:
$ ffprobe -select_streams s -show_entries stream=codec_name ...
codec_name=hdmv_pgs_subtitle
codec_name=hdmv_pgs_subtitlePGS is an image format: each subtitle is a picture, not text. A browser cannot draw it as text on top. The only way to show it is to burn it into the video, frame by frame, on the CPU. That is exactly the cost I had just spent an afternoon removing. Worse, a player that knows it cannot draw the stream sometimes does not fall back to burning it in; it just refuses.
The fix is not to fight the image format; it is to add the text one. An external SRT is text, the browser draws it natively, and playback stays a simple copy. That is a job for Bazarr. Bazarr was already running, but it turned out it had never downloaded anything, because I had never set it up.
The problems, in the order I hit them
Setting up Bazarr looked like a form to fill in. It was a small obstacle course. Each failure taught me something.
- It could not reach Sonarr. The error was
Bad Request - Invalid Hostname. That is not about connecting; it is about theHostheader. The *arr apps only answer hostnames on their allow-list, and the name Bazarr used was not on it. Looking back, it is obvious, but the message says nothing about the *arr config. - Its settings API ignored me. I posted a neat JSON blob, and it returned success but changed nothing. The endpoint does not take a blob; it takes single form fields, one per setting. This is the one that taught me that code 204 is not proof that anything happened.
- A default profile broke the database. Setting a default language profile made every new series fail with
FOREIGN KEY constraint failed. The workaround is to leave the default empty and set the profile on each item. It is a bug in this Bazarr build, but the workaround is small. - It thought there was nothing to do. With the connection working and the provider logged in, the search found nothing. Why? Bazarr counted the embedded subtitles as already covering the language. The file did have English subtitles, but they were the PGS ones the browser cannot use. So I told it to ignore the embedded and image tracks. Then it treated English as missing and fetched SRT files, which landed next to the video.
This was the same lesson as the GPU, and I did not see it the first time. “The file has subtitles” and “the player can use the subtitles” are two different things. The software will happily tell you the first is true while failing the second.
What it bought
An SRT next to the file makes the whole chain cheap. The stream no longer has to be converted to burn in pictures. It is a simple copy, the text subtitle rides along, and the CPU goes back to sleep. The same episode that wanted three cores now barely registers.
In the end, both halves had the same shape: a device that was there but not allowed, and a subtitle that was there but could not be shown. Each one was fixed by finding the one gate that mattered.
The media finally worked. So, naturally, I started worrying about the front door.