Markdown version: https://ai.hiina.space/reports/friend-group-tv-channel-playout/03-three-ways-to-stop-streaming-into-the.md ยท index

Index of everything on this site: https://ai.hiina.space/index.md

Friend group TV channel playout: 3. Three ways to stop streaming into the void

3. Three ways to stop streaming into the void

Let the engine idle for you

ErsatzTV and Tunarr both share one ffmpeg session per channel among all viewers, start it lazily, and decide when to stop by how recently anyone requested the playlist. HLS has no persistent connection, so request recency is the only signal any of them has. In the ErsatzTV app, ffmpeg.segmenter.timeout_seconds defaults to 1 minute, and a per-channel ChannelIdleBehavior enum offers StopOnDisconnect or KeepRunning (StartFFmpegSessionHandler.cs, ChannelIdleBehavior.cs). Standalone Next uses the 90 s heartbeat described above.

Tunarr adds a feature that suits a friend group: an on-demand channel mode. Normally a Tunarr lineup runs on wall-clock time, so a returning viewer lands wherever the schedule has reached (StreamProgramCalculator.ts). With on-demand enabled (per channel, onDemand: { enabled }), Tunarr pauses the channel as soon as the last real client disconnects. It saves a cursor, cursor + (pauseTime - lastResumed) - rewindMs, and resumes from that point next time, so nobody misses episode 3 because nobody was watching (OnDemandChannelService.ts, SessionManager.ts). The source has one trap. If the lineup changed after the last resume, the cursor resets to zero and the channel starts again from the beginning. I found no user-facing docs page for this mode.

Next works differently. Its playout items carry absolute RFC 3339 start and finish times, so a scheduled block plays at its wall-clock time whether or not anyone is watching (playout reference). For a "cartoon hour at 9 pm" channel, that is what you want. For a "resume where we left off" channel, Tunarr's behavior comes through the queue instead (see the queue section).

Wrap a script in MediaMTX runOnDemand

If you keep MediaMTX as the front end, runOnDemand runs a command when a reader requests a path that has no publisher, and sends it SIGINT runOnDemandCloseAfter (default 10 s) after the last reader leaves. Readers wait up to runOnDemandStartTimeout (default 10 s) for the command to start publishing. The command receives MTX_PATH and MTX_QUERY in its environment (mediamtx.yml). The HLS caveat is that the HLS muxer itself registers as a reader of the path. It goes away only after hlsMuxerCloseAfter (default 60 s) with no HTTP requests (muxer.go). The maintainer confirms that with HLS-only viewers the stop always takes about a minute, and recommends WebRTC for exact reader counting (issue #3903). Plan on about 70 s of wasted work after the last tab closes. You can lower hlsMuxerCloseAfter to 15 to 20 s, at the risk of dropping a viewer whose player stalls for a moment. Leave hlsAlwaysRemux off. The source exempts only sourceOnDemand paths from it, so an always-on muxer would plausibly act as a permanent reader of a runOnDemand path and keep the command running (server.go). That last point is my inference from the source, not something I tested.

The script follows the "schedule as timeline" pattern. Define the schedule as a deterministic list of items with durations and an epoch. At startup, compute the current item and offset. Tunarr's version is about 60 lines: elapsed time modulo channel duration, a binary search over precomputed start offsets, and a skip to the next item when less than about a second of the current one remains (StreamProgramCalculator.ts). Then exec a single long-running ffmpeg that reads an ffconcat list, with an inpoint on the first entry, and publishes with -re to rtsp://localhost:$RTSP_PORT/$MTX_PATH. The concat demuxer requires every file to share codecs and timebase, and its docs warn that inpoint is imprecise on inter-frame codecs (FFmpeg concat).

My recommendation, which is inference rather than anything measured, is to normalize the whole library once at ingest: one resolution, constant frame rate, fixed keyframe interval, H.264 and AAC at 48 kHz stereo. The on-demand ffmpeg can then run -c copy. The "encoding into the void" during those idle 70 s becomes a remux costing almost nothing. With anime OPs/EDs at around 90 s and commercials at 15 to 30 s, avoid restarting ffmpeg for each clip. Each republish leaves a visible gap, and with runOnDemandRestart set, MediaMTX waits 5 s before restarting a command that exited (cmd.go).

Pre-normalize once and compute a live playlist

The zero-CPU option skips transcoding at view time altogether. Encode every clip once into HLS segments with identical parameters. A small service then emits a sliding-window live m3u8 whose position comes from the schedule and the clock. When nobody watches, only a static file server is running. Eyevinn Channel Engine (Apache-2.0, 5.1.3) builds 24/7 live HLS channels from VOD HLS. You plug in an AssetManager whose getNextVod() returns the next asset, which also works as a queue hook (README, npm). nginx-vod-module does "simulated live" from MP4s in mapped mode, using JSON with playlistType: live, firstClipTime, clipFrom/clipTo and discontinuity flags (README). For a handful of friends, a hand-rolled generator of about 150 lines is probably lighter than either (my estimate). It has to follow RFC 8216's live-playlist rules: EXT-X-DISCONTINUITY at every item boundary, a constant TARGETDURATION, and a MEDIA-SEQUENCE that only increases, which in practice means a persisted segment counter that survives restarts (RFC 8216). Joining costs one playlist fetch plus player buffering, roughly 6 to 10 s with 2 s segments (estimate). The tradeoffs are disk space for a second copy of the library, and losing per-channel overlays and on-the-fly graphics. I could not confirm whether Channel Engine does any work for a channel nobody is watching.


Text licensed CC BY 4.0 (attribution: Hiina, ai.hiina.space). Source: https://github.com/hiinaspace/ai-hiina-space