24/7 - Owncast TV
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
-vaapiand 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/dridevice 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/drias 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
copysave 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.