< the skull

24/7 - OWNCAST TV
=================
2026-06-20

Some time ago, I wrote a small script to control my Owncast instance. Basically,
that little program used ffmpeg to handle the streaming. Back then I was using
three separate machines: one where the media files lived, another running
Owncast, and a middle one running ffmpeg. I wish I could give more detail about
that bizarre setup, but I honestly do not remember exactly how it was wired
together.

What matters is that today I decided to improve the idea.

I will try to explain how I built a channel for Jellyfin and, at the same time,
how I managed to make that live channel stream through Owncast.

For the Jellyfin channel, the answer was simple and straightforward: ErsatzTV.

For Owncast, there is a bit more to unpack.

What I wanted sounded simple enough: take two folders full of video files and
turn them into a linear TV channel, playing 24 hours a day and streamed through
my Owncast instance. Not just "play a file" — I wanted something closer to a
real station, with a schedule, watched by the public on a page, and something I
could turn on and off whenever I wanted.

Getting there involved an abandoned tool, a GPU that worked in one place and
failed in another, a server refusing connections for a reason that was not the
obvious one, and seven rotten files sabotaging the whole thing. This text
documents what was done, why it was done, and, most importantly, the problems
solved along the way.

The hardware: an Unraid server, nicknamed `Nyx`, with an AMD GPU, and Owncast
already running as the destination.

The architecture, and why it was not my first choice
----------------------------------------------------

The split of responsibilities I wanted was clear:

  [ the station ]       [ the bridge ]       [ the antenna ]
    builds the schedule -> pushes the stream -> distributes it to the public

The first attempt was ffplayout — a robust broadcast tool, probably the most
"professional" option for this kind of thing. It died at the installation stage.
ffplayout does not publish a ready-made Docker image: the ffmpeg build it uses
includes non-free components that cannot be legally redistributed, so the
official installation expects you to build the image from scratch. On Unraid,
that means a heavy build process involving Rust and a frontend, with a final
image around 15 GB, plus the burden of manually maintaining updates. Too much
effort for something I did not actually need in all its complexity.

I looked at what Unraid's Community Apps had to offer. Most apps with "ffmpeg"
in the name are batch file transcoders — useless for continuous streaming.
Broadcaster and dizquetv are direct competitors to ErsatzTV, since they generate
channels, but they are not bridges. Restreamer by datarhei was the only real
candidate for a GUI-based bridge, but it spins up its own RTMP server on port
1935, which would collide with Owncast, and it is also a fairly heavy stack for
a one-to-one job.

The final choice:

  * ErsatzTV as the station: it builds and normalizes the schedule, and encodes
    using the GPU.
  * A raw ffmpeg container as the bridge: it grabs the channel and pushes it to
    Owncast.
  * Owncast as the antenna: it distributes the stream to the public.

The raw ffmpeg container beat Restreamer because it is minimal, does not open
any ports, only performs an outbound push, creates zero conflict, and — an
important detail — because stopping and starting that container becomes the
on/off switch for the stream. I needed to be able to stop the broadcast without
taking ErsatzTV down, and this architecture gave me that for free.

ErsatzTV: the station
---------------------

The right image and the GPU
---------------------------

The Community Apps template offered `-vaapi` and `-nvidia` variants. For AMD,
VAAPI is the right path. Two details showed up immediately:

  * The image has been unified. ErsatzTV now includes both VAAPI and NVIDIA
    support in the standard image; the suffixes are legacy. The Health Check
    warns you to remove `-vaapi` and use the default image instead:
    `ghcr.io/ersatztv/ersatztv:latest`. Hardware acceleration still works — the
    suffix is simply no longer needed.

  * VAAPI requires passing the GPU through. Without the `/dev/dri` device inside
    the container, hardware acceleration simply does not happen. The template
    text only mentions Intel and NVIDIA, but AMD uses the same path as Intel:
    add `/dev/dri` as a Device.

There was also a blue warning about "VAAPI Driver" suggesting the `iHD` or
`i965` drivers. Those are Intel drivers. On AMD, the Default driver is correct,
because Mesa autodetects `radeonsi`. Forcing an Intel driver would break things.
I safely ignored it.

Only the right folders, and read-only
-------------------------------------

I did not want to expose the entire media folder to ErsatzTV — only two specific
subfolders. The solution was to mount each one as its own subdirectory in
read-only mode:

  /mnt/user/skull/Mídia/Strangeland/   ->  /media/Strangeland     (RO)
  /mnt/user/skull/Mídia/Melted Stuff/  ->  /media/Melted Stuff    (RO)

This way, the container only sees those two folders, and ErsatzTV can never
write to the files. It only needs to read them anyway. Free protection.

Library, channel, and schedule
------------------------------

The flow inside ErsatzTV is made of three linked pieces, and the last one is the
part everyone forgets:

  * Library: `Other Videos`, the correct type for loose video files. ErsatzTV
    indexes the files here.
  * Collection: the bucket containing what will play.
  * Schedule with Playout Mode set to Flood: the logic that continuously fills
    the channel with items from the collection.
  * Playout: connects the Schedule to the Channel and generates the timeline.
    Without the Playout, nothing plays.

I created the channel, `MadArabTV`, channel number 1, using HLS Segmenter mode
and a 1080p H.264 profile through VAAPI. I tested the `.../iptv/channels.m3u`
URL in VLC, and it played. The station was on the air.

*

The bridge: the ffmpeg container
--------------------------------

The bridge is a minimal container, `jrottenberg/ffmpeg`, running in Host network
mode so it can reach ErsatzTV and Owncast on the same machine. It uses
`--restart=unless-stopped`, which means it restarts automatically if it crashes,
but respects a manual Stop. The command grabs the ErsatzTV channel and pushes it
to Owncast over RTMP.

The first version copied the video and converted only the audio to AAC, because
Owncast's web player does not play AC3. That should have been the end of it.

It was not.

*

Pain #1: Owncast refusing the stream
------------------------------------

I started the bridge, and ffmpeg died immediately:

  Error submitting a packet to the muxer: Connection reset by peer

The obvious reading would be: "wrong stream key." That was wrong — the reading,
not the key. Owncast's own log told a different story:

  Inbound stream connected from 192.168.50.7:...
  Processing video using codec VA-API
  amdgpu: unknown (family_id, chip_external_rev): (145, 16)
  Failed to initialise VAAPI connection: resource allocation failed
  your copy of ffmpeg may not support your selected codec of h264_vaapi

`Inbound stream connected` means the key was correct and Owncast accepted the
stream. What killed it was that Owncast was configured to re-encode the video
using VA-API — and the Mesa driver inside the Owncast container did not
recognize the AMD GPU: `amdgpu: unknown family_id`. The transcode failed,
Owncast disconnected, and the bridge's ffmpeg saw that as a "connection reset."

This was the most valuable lesson in the whole setup: "connection reset" is
rarely what it looks like. The destination log, not the source log, revealed the
cause. Without checking the Owncast logs, I would have wasted hours recreating
stream keys.

The curious detail is that the same GPU works in ErsatzTV and fails in Owncast.
The difference is the Mesa version inside each container: ErsatzTV's Mesa is
recent enough to recognize the card; Owncast's Mesa is too old for it.

The fix: passthrough
--------------------

ffmpeg was already delivering ready-to-use H.264 + AAC. Owncast did not need to
re-encode anything — it only needed to pass the stream through. I enabled Video
Passthrough in Owncast, so it uses the incoming stream as-is. No transcode, no
VA-API, problem solved, and as a bonus, zero transcoding CPU usage inside
Owncast.

*

Pain #2: crashes during transitions
-----------------------------------

With passthrough enabled, the stream finally worked — for about 40 seconds. Then
it dropped. And it kept dropping "randomly", at apparently random moments. The
logs gave two clues:

  Stream #0:0: Video: h264 ..., 47.95 fps    (in one clip)
  Stream #0:0: Video: h264 ..., 29.97 fps    (in another)
  [mpegts] Packet corrupt (stream = 0, dts = ...)

There were two separate problems:

  * Variable framerate between clips. Some videos were 30 fps, some 29.97 fps,
    others 48 fps. Since both the bridge and Owncast were only copying, nobody
    was normalizing anything. The defect passed through the chain and RTMP broke
    during clip changes. It played fine in VLC, which tolerates FPS changes, but
    broke in streaming, which does not.

  * Zero-duration files. ErsatzTV's Health Check reported 7 files with zero
    duration — corrupted, incomplete, or unreadable files. Whenever the channel
    rotation hit one of them, the transcode produced garbage and the stream
    broke. Those were the trigger for the "random" crashes. They were not random
    at all; they were bad files.

*

The decision that cleaned everything up: encode once, on the GPU
----------------------------------------------------------------

Instead of throwing a heavy CPU re-encode onto the bridge, the right move was to
use the fact that ErsatzTV was already encoding successfully on the GPU. All I
had to do was let ErsatzTV handle the full job — including framerate
normalization, which it does natively in HLS Segmenter mode with Normalize Video
enabled — and turn the bridge back into pure `copy`:

  ErsatzTV (GPU encode + normalization on AMD) -> ffmpeg (copy, ~0 CPU) -> Owncast (passthrough) -> public

One encode, on the GPU. Everything else is just forwarding. The bridge's CPU
usage stays practically at zero, which mattered because the goal was never to
beat the processor to death, especially with the machine already running hot at
idle.

Killing the ghost files
-----------------------

The last step was cleaning up the 7 zero-duration files. Since the folders were
mounted read-only inside ErsatzTV, deletion had to happen on the host. A
temporary ffmpeg container scanned the folders with `ffprobe`, identified every
file with empty duration, unreadable duration, or duration below one second, and
deleted it:

  docker run --rm -v "/mnt/user/skull/Mídia:/mnt/user/skull/Mídia" \
    --entrypoint /bin/bash jrottenberg/ffmpeg:latest -c '
  shopt -s globstar nullglob
  base="/mnt/user/skull/Mídia"
  for f in "$base/Strangeland"/**/* "$base/Melted Stuff"/**/*; do
    [ -f "$f" ] || continue
    case "${f,,}" in
      *.mp4|*.avi|*.mkv|*.mov|*.m4v|*.ts|*.wmv|*.flv|*.webm|*.mpg|*.mpeg) ;;
      *) continue ;;
    esac
    d=$(ffprobe -v error -show_entries format=duration -of default=nokey=1:noprint_wrappers=1 "$f" 2>/dev/null)
    if [ -z "$d" ] || [ "$d" = "N/A" ] || awk "BEGIN{exit !($d < 1)}"; then
      rm -v -- "$f"
    fi
  done
  '

The trick with `-v "/host:/host"` — mounting the host path at the same path
inside the container — makes the paths printed by the script identical to the
real host paths, with no translation needed.

After deleting the 7 files, there was one confusing detail left: the warning
kept appearing. Not because the files were still there, but because ErsatzTV
reads that Health Check from its internal database, not directly from the disk
in real time. A library re-scan reconciled the database with the filesystem,
removed the orphaned entries, and the warning disappeared.

*

What remained standing
----------------------

A 24/7 linear TV channel, built from two media folders and streamed through
Owncast:

  * A single encode, done by ErsatzTV on the AMD GPU. The bridge and Owncast
    only pass it through.
  * Bridge CPU usage near zero — pure `copy`.
  * One-click on/off: stopping the broadcast means hitting Stop on the ffmpeg
    container in the Docker tab. ErsatzTV keeps running the channel internally;
    Owncast goes back to "offline." Start brings it back.
  * Protected media: mounted read-only, and only the two selected folders —
    never the whole library.

*

Lessons for next time
---------------------

  * The error you see is rarely the cause. "Connection reset by peer" was not
    the stream key — it was the destination's VA-API transcode failing. The log
    on the other side solved it.
  * The driver inside the container matters. The same GPU can work in one
    container with newer Mesa and fail in another with older Mesa. When
    acceleration fails, suspect the environment, not just the hardware.
  * Do not re-encode what is already ready. If the source already outputs the
    right codec, passthrough and `copy` save CPU and remove failure points. Put
    the encode in one place only — preferably where the GPU is.
  * Linear streams do not forgive bad media. Zero-duration files or variable
    framerate can break the broadcast during clip transitions, even when those
    files play fine in a desktop player. Auditing the library is worth it.
  * Stale cache is half a bug. The Health Check warning about already-deleted
    files was just an outdated database state. Before assuming something failed,
    check whether the tool is simply reading old state.


< the skull