Frigate's camera wall on channel 1.

Getting Frigate's birdseye camera grid to play as a Live TV channel on a Roku through Jellyfin. Five stacked failures, and a root cause in a composite that renders slower than the 10 fps it claims.

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. Without GO2RTC_ALLOW_ARBITRARY_EXEC=true the 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.mp4 to keep one persistent connection. ffprobe read 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 separate curl and ffprobe runs, 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 on not meta.live. VideoPlayerView.bs also runs a playbackFailureTest that 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.bs says multichannel AAC decode is unreliable on Rokus. StreamBuilder.cs intersects 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.

Frigate birdseyeCPU composite, sub-10 fpsgo2rtcrestreamcam-hls (ffmpeg)wall-clock re-time, 10 fpsdeep-buffer HLS, :8091Jellyfin relayvideo copy, audio copyre-segment, to Roku

Still open

  • Three passes for two cameras: Frigate encodes, my ffmpeg re-encodes, Jellyfin re-segments. -use_wallclock_as_timestamps 1 -c:v copy might drop my encode, but upstream keyframes are about five seconds apart (-g 50 at 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.