A Dispatcharr plugin that moves a channel to its next source when the provider keeps delivering the stream, but at a fraction of the bitrate the content needs.
Every failover in the chain waits for a source to fail. This one never does. The bytes keep arriving, just not enough of them: the picture needs 4.4 Mbps and 1.0 Mbps shows up. Nothing times out, nothing errors, nothing switches. The buffer drains, the player runs out of segments, and viewers sit there watching a stall that no log explains.
Underfed reads the number that already exists and that nothing else acts on. It also reads a second one: the timestamp discontinuities ffmpeg logs when a source's sound drifts away from the picture.
- Dispatcharr with the plugin system (the Plugins page)
- reservoarr as the stream profile, which writes the telemetry this plugin reads
- A Dispatcharr API key
From the Plugin Hub, or by unzipping the release into /data/plugins, which creates the
underfed folder, and pressing refresh on the Plugins page. Enable the plugin, fill in the API key, press Apply.
Leave Observe only on for the first evening. It records what it would have switched and changes nothing. Read the journal under Check status, then turn it off.
| Setting | What it does |
|---|---|
| API key | A Dispatcharr API key, from Settings → Users. The watcher needs it to read channel status and to change source |
| Observe only | Records what it would have done without doing it |
| Trigger below | Share of the content rate under which a source counts as underfed. 70 is a sensible floor: a healthy source sits at 100 |
| Confirm for | How long a shortfall, or the timestamp discontinuities, must last before acting. Short dips recover on their own |
| Switches per hour | Per channel, over the last hour, restarts of the watcher included. Stops it bouncing between two sources that are both weak |
| Ignore first | Right after a channel opens the measured content rate is not trustworthy yet |
| Stable after | A source that holds up this long is treated as recovered. A shorter recovery keeps the shortfall counting, so a source that flickers is still caught |
| Timestamp discontinuities | Per minute, per source. A healthy source logs a handful, one whose sound drifts away from the picture hundreds. At this many, for Confirm for, the source is switched. 0 turns it off |
| Refused source held back | A source the provider answers 403 to goes last in its chain, ahead of the fallback card, so the next tune-in does not start on it again; it climbs back after these minutes. 0 turns it off |
| Excluded channels | One channel name per line |
| reservoarr log | Where reservoarr writes delaybuf.log. Change it only if RESV_LOG_DIR was moved |
| Dispatcharr URL | Reached from inside the container |
| Action | What it does |
|---|---|
| Apply settings | Starts the watcher, or restarts it with the new settings. Saving a setting changes nothing until Apply runs |
| Check status | Whether the watcher is running, and the journal of what it switched, skipped and failed |
| Replay the log | Runs the current thresholds over the whole log and reports how many times each source would have triggered. Nothing is touched, so it is the safe way to try a threshold before it goes live. It counts triggers, not switches: viewers, exclusions, the slate and the hourly limit are not in the log |
| Restart watcher | Starts it again if it is down, with the settings of the last Apply and the API key as saved now. Also runs by itself when a channel starts, at most once a minute |
| Stop watcher | Stops it. Channels keep whatever source they are on |
reservoarr holds a cushion of stream and prints a line every fifteen seconds for every feed it is pulling:
2026-09-08T20:18:57+0000 [202121.ts] cushion=0s(pcr) buf=0.1MB out=1.18Mbps
in=1.00Mbps crate=4.44Mbps in_total=1589MB reconnects=2 ccerr=1 ...
crate is what the picture needs. cushion is how many seconds of stream are left in hand.
What the provider is sending is read off in_total, the bytes the feed has taken in so far:
the difference over the last 45 seconds. in says the same thing averaged over two minutes,
so after a sudden drop it lags by a minute or more; it is used only until a feed has 30
seconds of in_total behind it, and for lines without the counter. When the ingest sits well
under crate and the cushion has reached zero, the source is being starved and the viewer is
about to see it. A feed whose crate is under 0.5 Mbps is never judged: a content rate that
low is not trustworthy.
The watcher follows that file, and when a feed stays under the threshold for the
confirmation window it calls POST /proxy/ts/next_stream/<uuid>, which is the same thing
the Dispatcharr interface does when you change source by hand. The channel moves to the next
entry in its chain and the viewer keeps watching.
Some sources arrive with their audio and video timestamps minutes apart. ffmpeg keeps one
offset for the whole input and flips it on every packet, so each packet gets the time ffmpeg
expected: a gap in the video disappears instead of freezing the picture, and the sound falls
behind by that much. Every gap adds to it, and reopening the channel only starts the count
again. reservoarr's ffmpeg logs it in delaybuf.log, in pairs:
2026-09-14T17:35:25+0000 [542059.ts] ffmpeg: [vist#0:0/h264 @ …] timestamp discontinuity (stream id=256): 331826689, new offset= 0
2026-09-14T17:35:25+0000 [542059.ts] ffmpeg: [aist#0:1/aac @ …] timestamp discontinuity (stream id=257): -331826689, new offset= 331826689
A healthy source logs a handful of these a minute; that one logged up to 1,836. When a source stays at Timestamp discontinuities or above for the confirmation window, counted over the last minute, the watcher moves the channel on the same way.
It refuses to act when any of these is true:
- the channel is not streaming, or nobody is watching it
- the channel is in Excluded channels
- the watcher has seen the source for less than the warm-up window, where
cratemay still be settling: it counts from the first telemetry line it reads for that source, so it starts over after a gap of more than 45 seconds in the log or a watcher restart - the channel has already been switched too often this hour
- the next entry in the chain is the fallback slate
- there is no entry after the current one
Everything it does, and everything it declines to do, goes to a journal with the numbers that justified it.
The other two failures are about a source that is delivering badly. This one is about a source
that is not delivering at all: the provider answers HTTP 403 and reservoarr retries, backs off
and gives up, and the channel walks on to the next entry. That much Dispatcharr already does.
What it does not do is remember. tried_stream_ids dies with the session, so the next tune-in
starts again from the top of the chain — on the same refused source, paying the same wait. Over
five days of one installation's logs: 192 episodes of refusal, 154 of them ending in a give-up,
and 32 sources out of 53 refused in more than one episode; one of them eighteen times.
So a refusal moves the source last in its channel's chain, ahead of the fallback card, which stays where it is. The chain is what the next tune-in reads, so the next viewer starts on a source that was not refusing a minute ago. After the configured minutes the source climbs back to the position it held, and the journal records both moves.
One refusal is enough: an episode lasts 6 seconds at the median but 140 at the ninth decile, and a viewer who opens the channel in the meantime pays all of it. The wait before it climbs back is thirty minutes by default: of 135 returns on the same source, 43 came within ten minutes and 60 within half an hour — while 49 came more than three hours later, and those are caught by the next refusal anyway.
A channel whose chain has one source only, or whose refused source is already last, is left alone: there is nowhere lower to go.
A chain whose sources all come from the same upstream. If Sky Sport Uno FHD, HD, SD
and HEVC are four encodes of one feed, moving between them moves nothing. Underfed can
only reach for what the chain offers, so put a genuinely different list second.
It also cannot help a source that stops dead. That case already has a watchdog: reservoarr
reconnects after RESV_STALL_S, and Dispatcharr walks the chain after three failed attempts.
python -m venv .venv && .venv/bin/pip install -e ".[dev]"
pytest && mypy . && ruff check . && python scripts/build_zip.py
The same four checks run in CI on every push. Decisions, traps and the release routine are in docs/MEMORY.md.
MIT