I wanted Frigate's birdseye view, its auto-composited grid of every camera, as channel 1 on the bedroom TV. The TV is a TCL Roku. The media server is Jellyfin, whose Live TV feature takes an M3U playlist of streams, and Frigate restreams birdseye. On paper, a one-line M3U.
It took five fixes. I did the watching; a Claude Code session drove the SSH,
the packet captures and a lot of ffprobe runs.
Setup
- Frigate 0.17.1 in an Incus container on
inc1, with its bundled go2rtc on ports 1984 and 8554. - Jellyfin 10.11 in LXC 100 on
pve1, a Proxmox box. - The plan: a go2rtc URL in an M3U, the M3U added to Jellyfin as an "M3U Tuner", and the Roku app tunes the channel.
1. Roku can't play RTSP
rtsp://inc1:8554/birdseye made a channel and a black screen. Jellyfin's M3U
tuner passes the URL through as the channel's MediaSource.Path, and Roku's
Video node only takes streamFormat values like hls, dash, mp4 and
mkv. rtsp isn't on the list. I switched to go2rtc's HLS at
/api/stream.m3u8?src=birdseye.
2. go2rtc's HLS holds one second of buffer
That played, then rebuffered every few seconds. go2rtc's HLS is a low-latency
browser fallback behind WebRTC and MSE: a rolling window of two half-second
segments, TARGETDURATION:1. A Roku wants several seconds of runway, so one
late segment or Wi-Fi retransmit drains it. No encoder setting changes the
window.
3. Roku wants audio, birdseye has none
The driveway camera, an Axis with a microphone, played while birdseye didn't.
Birdseye is video only, and Roku's HLS player stalls without an audio track. I
added a silent anullsrc AAC track with a go2rtc exec: source. Two gotchas,
both in Frigate's restream docs:
exec:is off by default. WithoutGO2RTC_ALLOW_ARBITRARY_EXEC=truethe stream silently disappears.- Write the output placeholder as
{{output}}. Frigate templates the go2rtc block first, so a single{output}gets eaten and the config won't load.
Wrong turns
- The MP4 endpoint. I tried having Jellyfin transcode go2rtc's
stream.mp4to keep one persistent connection.ffproberead it as 989 fps (avg_frame_rate = 90000/91) for a 10 fps source. Jellyfin's transcoder believed it and fell to half realtime. - The "cycling" exec. The exec ffmpeg's PID kept changing, and I blamed HLS
polling. go2rtc does stop
exec:sources when the last consumer leaves, but its HLS has a five-second keepalive. Most of the churn came from my own separatecurlandffproberuns, each opening a fresh session.
4. A real HLS, built locally
I stopped using go2rtc's HLS. On the Jellyfin box, one ffmpeg per camera reads
from go2rtc, adds audio where needed, and writes two-second keyframe-aligned
segments into an eight-segment playlist. A small threaded Python server on port
8091 serves them as application/vnd.apple.mpegurl. Three systemd units,
Restart=always. The real cameras are a straight copy.
It was smooth for about a day, then buffered now and then.
5. Birdseye runs slower than it says
Nothing was starved. The producer had zero restarts, segments came back in 30 ms, the stream was about 600 kbps, and the Roku sat on 5 GHz at 526 Mbps and -48 dBm. (The SSID is "WRT54G". It's a UniFi AP.)
Counting frames over twenty seconds of wall clock found it: birdseye advertises
10 fps and delivers 4.7 to 9, depending on motion. In Frigate 0.17.1 the
composite is built on the CPU in Python, a numpy canvas with one cv2.resize
per tile, driven by detect frames. Both my cameras detect at 5 fps, so the
composite floats between about 5 fps and the hardcoded 10 fps cap in
birdseye.py, and no setting pins it.
go2rtc reads that pipe with -r 10, hardcoded in Frigate's generated config,
so every frame gets stamped as 100 ms of video. At 6 or 7 real frames a second
the stream's timeline falls behind the wall clock. A live player drains one
media-second per wall-second, so a source that refills slower runs it dry at
any buffer depth. My sixteen-second buffer only delayed the stall.
For birdseye only, the producer now re-times to the wall clock:
ffmpeg -use_wallclock_as_timestamps 1 -rtsp_transport tcp -i rtsp://inc1:8554/birdseye \
-f lavfi -i anullsrc=r=48000:cl=stereo -map 0:v -map 1:a \
-fps_mode cfr -r 10 -c:v libx264 -preset veryfast -g 20 -c:a libmp3lame ...
-use_wallclock_as_timestamps 1 stamps each frame when it arrived, and
-fps_mode cfr -r 10 duplicates frames to hold a real 10 fps. The picture only
changes as often as the composite renders, but the clock matches the wall.
libx264 veryfast at 720p costs about 7% of one core, and the whole pipeline
stays under 10% CPU on the Jellyfin box. That held.
6. The Roku app forces a relay, and MP3
The Roku never plays my :8091 HLS directly. Every connection to it comes from
Jellyfin's IP: for Live TV, Jellyfin runs its own ffmpeg that re-segments the
stream for the TV, and it was transcoding my silent AAC to 256 kbps MP3. Both
come from the Roku app's code.
- The relay. In jellyfin-roku's
LoadVideoContentTask.bs, the direct-play attempt is gated onnot meta.live.VideoPlayerView.bsalso runs aplaybackFailureTestthat force-transcodes when the reported and measured durations differ by more than about 30 seconds, which a live stream always does. The reason string: "Roku thought it could direct play this media, but the playback duration was incorrect." - The MP3. The app's HLS transcoding profile leaves AAC out on purpose; a
comment in
deviceCapabilities.bssays multichannel AAC decode is unreliable on Rokus.StreamBuilder.csintersects that list with aac, ac3, eac3 and mp3, and MP3 is what's left.
So the silent track is now libmp3lame, and the relay should have nothing to
transcode on birdseye. I haven't confirmed it. Both Axis cameras dropped off at
11:02:09 the morning I made the change, at the same second (shared power behind
a TL-SG108PE), so there was no live session to check.
Still open
- Three passes for two cameras: Frigate encodes, my ffmpeg re-encodes, Jellyfin
re-segments.
-use_wallclock_as_timestamps 1 -c:v copymight drop my encode, but upstream keyframes are about five seconds apart (-g 50at 10 fps), so the playlist would lag. Untried. 7% of a core can wait. - Is the relay actually copying the audio now? That needs checking once the cameras have power.
- The driveway's real AAC track probably gets transcoded to MP3 on the relay too. I haven't checked.
I also removed the dead birdseye_tv and garage_tv exec streams from the
Frigate config (the go2rtc API won't delete config-defined streams, so that
waited for a restart) and raised the container's SHM cap from 178 MB to 512 MB,
which Frigate had been warning about since before any of this.
If you're doing this
- Roku doesn't play RTSP. Convert to HLS.
- go2rtc's HLS is for browsers. For a TV, repackage it with a real buffer.
- Don't trust
ffprobe's frame rate from a live fMP4 endpoint. Check it against the RTSP source. - When Jellyfin transcodes something you didn't ask for, read the client's device profile before the server's settings.
- If a stream claims more fps than it delivers, re-time it to the wall clock. A bigger buffer won't fix it.
The wall's been on channel 1, steady, at about five frames a second. For a driveway that's plenty.