
I spent most of last week horizontal. Not dramatically ill, just the sort of illness where the day consists of tea, bed, and a laptop balanced on a duvet at an angle no ergonomics guide would endorse.
This is exactly the situation a large media library is supposed to solve. Mine holds an embarrassing number of series and films, all neatly indexed by Jellyfin, all one click away. And every single time I opened it, I scrolled. Then I scrolled some more. Then I closed it again and stared at the ceiling, which at least did not require me to make a decision.
That is the failure mode nobody warns you about when you build a media server. A library is not entertainment. A library is a catalogue, and a catalogue asks you a question every time you open it: what do you want? When you are ill, tired, or simply out of decision budget for the day, that question is surprisingly expensive.
Old-fashioned television never asked. You turned it on, something was already happening, and you either watched it or you did not. The scheduling was somebody else’s problem.
So I built myself a television station.
What ErsatzTV Actually Does
ErsatzTV takes a media library and turns it back into linear channels. You define a channel, attach collections of content to it, describe how those should be ordered and scheduled, and ErsatzTV produces a continuous stream. It exposes that stream as an M3U playlist with an XMLTV guide, which means anything that speaks IPTV can consume it, including Jellyfin itself.
It supports the things that make a channel feel like a channel: schedules with time blocks, filler content between programmes, watermarks and channel logos in the corner, and per-channel transcoding profiles. You can genuinely end up with something that looks like a broadcaster, complete with a small logo in the top right, rather than a playlist with delusions of grandeur.
Crucially for a home server, it is lazy in the right way. ErsatzTV does not transcode continuously into the void. The stream starts when a client connects and stops when the last one disconnects. A channel that nobody is watching costs nothing beyond the scheduler knowing what would be playing. On a machine that is on all day anyway, that difference matters. I have no interest in heating my flat by broadcasting Star Trek to an empty room.
There was one obstacle. ErsatzTV ships binaries and containers for Linux, Windows, and macOS. My media server, voyager, runs FreeBSD.
Why This Was Worth Attempting At All
The usual reaction to “the vendor does not support my operating system” is to give up gracefully, and quite often that is correct. This case was different for two reasons.
First, ErsatzTV is a .NET application, and FreeBSD has a usable .NET toolchain in ports. Second, it does not implement video processing itself. All the heavy lifting is delegated to ffmpeg and ffprobe, which FreeBSD has had for approximately forever, including VAAPI support for Intel graphics.
So the interesting question was never “can FreeBSD decode this video”. FreeBSD was always going to be fine at that part. The question was whether the application layer contained any genuinely Linux-specific assumptions, or merely Linux-shaped ones.
That distinction turns out to be the whole story.
The Jail
I build these experiments in a Bastille jail, because a jail is cheap, disposable, and honest about what it can reach. If the experiment fails, bastille destroy removes every trace of the .NET SDK, the NuGet caches, and my optimism.
The jail gets exactly two things from the host: the media, and the GPU.
Media comes in as a read-only nullfs mount. ErsatzTV needs to read the same files Jellyfin reads, but it has absolutely no business writing to them:
root@voyager:~ # cat /usr/local/bastille/jails/ersatztv-test/fstab
/usr/local/bastille/releases/15.1-RELEASE /usr/local/bastille/jails/ersatztv-test/root/.bastille nullfs ro 0 0
/media_zfs/Series/ /usr/local/bastille/jails/ersatztv-test/root/media/Series nullfs ro 0 0
The first line is the standard thin-jail base mount that Bastille creates. The second is mine. /media_zfs/Series is the same dataset Jellyfin scans, mounted at /media/Series inside the jail and marked ro. Two applications now read the same library, one of them cannot modify it, and there is no second copy of anything.
If you want the full background on VNET, thin jails, and the isolation model, my FreeBSD Foundationals article on Jails covers the mechanics properly. This article assumes the jail already exists.
Letting a Jail See the GPU
The more interesting half is hardware acceleration. voyager has Intel integrated graphics driven by i915kms from drm-kmod, which gives the host /dev/dri/renderD128 and friends. Software transcoding several simultaneous 1080p channels on a home server CPU is technically possible and practically unpleasant, so I wanted the iGPU inside the jail.
Jails do not get the host’s /dev verbatim. They get a devfs view filtered by a ruleset, and the default jail ruleset quite deliberately hides everything interesting. So I defined my own ruleset for GPU-enabled jails in /etc/devfs.rules:
root@voyager:~ # cat /etc/devfs.rules
[devfsrules_jail_gpu=20]
add include $devfsrules_hide_all
add include $devfsrules_unhide_basic
add include $devfsrules_unhide_login
add path 'dri*' unhide
add path 'dri/*' unhide
add path 'drm*' unhide
add path 'drm/*' unhide
The structure here is worth reading carefully, because devfs rules are one of those subsystems that look like a firewall and behave like one too. The first line hides everything. The next two restore the basic device nodes any process expects (null, zero, random, and so on) plus the login-related ones. Only then do I selectively unhide the DRI and DRM device nodes. The result is a jail that can see the GPU and essentially nothing else.
Both the directories and their contents need unhiding, which is why each path appears twice, once bare and once with /*. Unhiding a directory alone gets you an empty directory and a confusing afternoon.
Attach the ruleset to the jail, restart it, and the render node shows up inside:
sysrc devfs_system_ruleset=20
service devfs restart
bastille config ersatztv-test set devfs_ruleset 20
bastille restart ersatztv-test
root@ersatztv-test:/usr/local/ersatztv # ls -l /dev/dri
total 0
lrwxr-xr-x 1 root wheel 8 Jul 26 12:24 card0 -> ../drm/0
lrwxr-xr-x 1 root wheel 10 Jul 26 12:24 renderD128 -> ../drm/128
Note that /dev/dri on FreeBSD consists of symlinks into /dev/drm, which is why the ruleset has to unhide both trees. Unhiding dri alone gets you two dangling symlinks and a puzzled ffmpeg.
At this point the jail has media and a GPU. Now for the application.
Building ErsatzTV on FreeBSD
Inside the jail, the prerequisites are unremarkable:
pkg install dotnet ffmpeg git
FreeBSD’s ffmpeg package is built with VAAPI support, which is the piece that matters. dotnet gives us the SDK and runtime.
Then clone the source and build it. This is where FreeBSD asks for a few concessions, all of which are about MSBuild rather than about ErsatzTV:
root@ersatztv-test:/usr/local/ersatztv # env \
DOTNET_SYSTEM_NET_DISABLEIPV6=1 \
DOTNET_CLI_USE_MSBUILD_SERVER=0 \
MSBUILDDISABLENODEREUSE=1 \
dotnet publish ErsatzTV/ErsatzTV.csproj \
-c Release \
--self-contained false \
--no-restore \
-m:1 \
-nr:false \
-o /usr/local/ersatztv
Three of those knobs deserve an explanation:
DOTNET_SYSTEM_NET_DISABLEIPV6=1tells .NET’s socket layer to stay on IPv4. The .NET runtime’s dual-stack socket handling is not entirely at home on FreeBSD, and this avoids a class of startup and binding oddities that have nothing to do with the application logic.DOTNET_CLI_USE_MSBUILD_SERVER=0,MSBUILDDISABLENODEREUSE=1, and-nr:falsebetween them disable MSBuild’s persistent build server and worker node reuse. Those are performance optimisations for developer workstations that rebuild the same solution forty times an hour. In a jail doing exactly one build, they add complexity and long-lived processes I do not want.-m:1limits the build to a single node. Slower, but far easier to read when something goes wrong.
--self-contained false keeps the output as a framework-dependent build. It relies on the FreeBSD dotnet runtime already installed rather than trying to bundle a runtime for a platform Microsoft does not publish one for. That is precisely the right call here: FreeBSD ports maintain the runtime, so let them.
The build completed without a single patch. That was already more than I expected.
The One-Line Problem
It ran, too. The web interface came up, it found ffmpeg, it scanned my media. Everything looked correct until I tried to enable hardware acceleration on an FFmpeg profile, and the VAAPI options simply were not there. No error, no warning, just an interface that quietly declined to offer me the feature.
The cause turned out to be exactly the kind of assumption I was hoping to find:
root@ersatztv-test:/usr/local/src/ersatztv # git diff ErsatzTV/Pages/FFmpegEditor.razor
diff --git a/ErsatzTV/Pages/FFmpegEditor.razor b/ErsatzTV/Pages/FFmpegEditor.razor
index a95719ba..e57054f8 100644
--- a/ErsatzTV/Pages/FFmpegEditor.razor
+++ b/ErsatzTV/Pages/FFmpegEditor.razor
@@ -154,7 +154,7 @@
}
</MudSelect>
</MudStack>
- @if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux))
+ @if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux) || RuntimeInformation.IsOSPlatform(OSPlatform.FreeBSD))
{
@if (_model.HardwareAcceleration is HardwareAccelerationKind.Vaapi)
{
That is the entire patch. One conditional in one Blazor page.
The logic is not wrong, exactly. It is asking “is this a platform where VAAPI exists?” and using “is this Linux?” as the test, because at the time that code was written those were the same question. VAAPI is not a Linux kernel feature though. It is a userspace API, libva plus a driver, and FreeBSD supports it perfectly well through drm-kmod and the same Intel media driver. The check conflated a capability with an operating system.
.NET already knows the difference. OSPlatform.FreeBSD has been part of the runtime for years. Nothing else needed changing: not the ffmpeg argument construction, not the device path handling, not the capability probing. Once the interface was willing to offer VAAPI, every layer below it worked on the first attempt, because those layers were talking to ffmpeg, and ffmpeg had never had a problem in the first place.
This is my favourite category of porting bug. There is no missing functionality, no unavailable syscall, no incompatible library. There is a piece of code that asked the wrong question and got a technically accurate answer.
Running It
With the patch applied and rebuilt, startup is uneventful in the best possible way:
root@ersatztv-test:/usr/local/ersatztv # env \
DOTNET_SYSTEM_NET_DISABLEIPV6=1 \
LIBVA_DRIVER_NAME=iHD \
dotnet ErsatzTV.dll
[12:25:55 INF] ErsatzTV version develop
[12:25:55 WRN] Give feedback at https://github.com/ErsatzTV/ErsatzTV or https://discord.ersatztv.org
[12:25:55 INF] Database is at /root/.local/share/ersatztv/ersatztv.sqlite3
[12:25:55 INF] Transcode folder is /root/.local/share/etv-transcode
[12:25:55 INF] Using Lucene (embedded) search index backend
[12:25:55 INF] Server will listen on streaming port 8409, UI port 8409 - try UI at http://localhost:8409
[12:25:55 INF] Worker service started
[12:25:55 INF] FFmpeg worker service started
[12:25:55 INF] Applying database migrations
[12:25:58 INF] Initializing database
[12:25:59 INF] Done applying database migrations
[12:25:59 INF] Cleaning database
[12:25:59 INF] Search index worker service started
[12:25:59 INF] Emptying transcode cache folder
[12:25:59 INF] Done emptying transcode cache folder
[12:25:59 INF] Cleaning channel cache
[12:25:59 INF] Located ffmpeg at /usr/local/bin/ffmpeg
[12:25:59 INF] Located ffprobe at /usr/local/bin/ffprobe
[12:25:59 INF] Done cleaning database
[12:25:59 INF] Initializing search index
[12:25:59 INF] Done initializing search index
[12:26:00 INF] Search index is already version 49
[12:26:00 INF] Scanner service started
[12:26:00 INF] Emby service started; secrets are at /root/.local/share/ersatztv/emby-secrets.json
[12:26:00 INF] Scheduler service started
[12:26:00 INF] Jellyfin service started; secrets are at /root/.local/share/ersatztv/jellyfin-secrets.json
[12:26:00 INF] Plex service started; secrets are at /root/.local/share/ersatztv/plex-secrets.json
There is nothing in that log that admits to running on an unsupported platform. It finds SQLite, ffmpeg, ffprobe, its transcode folder, and starts every worker service it expects to start.
LIBVA_DRIVER_NAME=iHD selects the Intel Media Driver rather than letting libva guess. On a machine with exactly one GPU that is usually unnecessary, but being explicit costs nothing and removes an entire category of “why is it using software encoding” confusion later.
One honest caveat: I am starting this by hand, from a shell, with the environment variables inline. For a real service that is not good enough. A production deployment wants a proper rc.d script so the thing can be enabled with sysrc ersatztv_enable=YES, controlled with service ersatztv start|stop|restart, run as an unprivileged user rather than root, and brought up automatically when the jail boots. This was an experiment to find out whether ErsatzTV runs on FreeBSD at all, so I have not written that script yet. It is the obvious next step.
The relevant part is what happens when a channel actually starts. The transcoding pipeline picks up the render node in /dev/dri, hands the work to the iGPU, and the CPU graph stays flat. Which is the point of the whole exercise.
The Station
With the infrastructure working, I got to do the fun part: programming a television channel.
Mine is a science fiction channel, assembled from what my library already contained:
| Series | Why it earns its slot |
|---|---|
| Star Trek: TOS, TNG, DS9, VOY | Several hundred episodes, near-uniform runtime, no continuity obligations |
| Stargate SG-1 | Reliably episodic, tonally consistent |
| Doctor Who | Wildly variable, which is its own kind of variety |
| Farscape | The one that keeps the mix from getting too comfortable |
The characteristics that make a series good television-station material are not the same ones that make it good binge material. What you want is a large episode count, consistent runtimes, and stories that mostly resolve within the hour. Ninety-eight percent of classic Trek qualifies. A tightly serialised prestige drama would be terrible for this, because arriving in the middle of episode six of a nine-part arc is not relaxing, it is homework with better production values.
ErsatzTV then handles the ordering. I can shuffle within a collection, play a series in order, block out time ranges, and let the scheduler produce something that feels curated rather than randomised. The channel logo in the corner is a small touch that does a disproportionate amount of work for the illusion.
Feeding It Back Into Jellyfin
The last step closes the loop nicely. ErsatzTV publishes an M3U playlist and an XMLTV guide, and Jellyfin has a Live TV feature that consumes exactly those.
So the channel is added to Jellyfin as a tuner. The same interface that was making me scroll endlessly now has a Live TV section with a small set of channels that are simply on. I do not browse them. I open one, and something is already happening.
The data path is pleasantly circular:
ZFS dataset /media_zfs/Series
|
+--> Jellyfin jail (library, on-demand browsing)
|
+--> ErsatzTV jail (ro) (scheduling + VAAPI transcoding)
|
| M3U + XMLTV
v
Jellyfin Live TV (channels that are just on)
|
v
TV / phone / laptop
One dataset, read by two applications, presented in two different ways: a catalogue for the times when I know what I want, and a channel for the times when I do not.
And because ErsatzTV only transcodes while somebody is watching, the second mode costs nothing when I am not using it. Turn the TV off and the station goes dark. That is a far better default than the real broadcast industry ever managed.
What This Says About Porting
The technically interesting result here is how little there was to do.
FreeBSD had the runtime, in ports. FreeBSD had ffmpeg with VAAPI, in ports. FreeBSD had the Intel graphics driver, in drm-kmod. FreeBSD’s jail and devfs machinery gave me a cleaner device isolation story than a container would have. Every hard piece of this problem was solved before I started, by people who did the work years ago.
The only thing standing between all of that and a working application was a single if that asked whether the operating system was Linux when it meant to ask whether VAAPI was available. That is not a criticism of ErsatzTV, which is a genuinely lovely piece of software and generous with its debug output. It is just what happens when a capability is common enough on one platform that the platform becomes shorthand for the capability.
It is worth remembering when your own code reaches for a platform check. IsOSPlatform(Linux) is rarely the actual question. The actual question is usually “does this device node exist”, “is this library present”, or “does this API return something sensible”. Those questions have the pleasant property of being true on platforms nobody thought to test.
I was ill, bored, and mildly annoyed at my own media library. A week later I have a science fiction channel that runs on an operating system the project does not support, on hardware acceleration the interface was not willing to admit existed, in a jail that can see precisely one GPU and one read-only directory.
I still do not know what is on tonight. That is entirely the point. ^_^
Comments
You can use your Mastodon or other ActivityPub account to comment on this article by replying to the associated post.
Search for the copied link on your Mastodon instance to reply.
Loading comments...