A delay-buffer stream profile for Dispatcharr that absorbs IPTV CDN gaps so Plex Live TV stops dying.
upstream HTTP ──► RAM reservoir ──► paced release ──► ffmpeg remux ──► Dispatcharr ──► Plex
(urllib, eager (≤256MB, (byte-rate sleeps (video copy +
fetch, ~30s target @ PCR content dump_extra,
auto-reconnect) cushion) rate) audio → AC3)
Plex Live TV's tuner gives up after ~15s of input starvation. IPTV CDNs commonly deliver TS in short bursts with prime-time gaps that exceed this, plus corrupt packets with garbage DTS, mid-stream EOFs, and per-connection corrupt-loops. reservoarr.py sits between the CDN and Plex, eagerly drains the upstream into a RAM reservoir, and releases bytes to ffmpeg at the stream's measured PCR content rate — so playback runs ~30s behind live and gaps shorter than the cushion are invisible to Plex.
Single-file, stdlib-only at runtime (Python ≥3.11). Spawns ffmpeg as a subprocess. Logs telemetry to a configurable log dir.
- You run Dispatcharr in front of an IPTV provider whose CDN has prime-time gaps, mid-stream EOFs, or occasional corrupt packets.
- You watch through Plex Live TV (or any consumer that gives up after ~15s of silence).
- Symptoms: channels die mid-stream, won't tune on first try, A/V desync after a reconnect, or short black-frame stutters.
If your provider streams cleanly and Plex stays happy, you don't need this.
- Install the plugin: Dispatcharr → Plugins → Find Plugins → search
reservoarr→ Install. - Open the plugin settings (Plugins → reservoarr).
- (Optional) tick "Set as default Stream Profile" if you want every channel to use it without per-channel assignment. Leave off if you only want it on a subset of channels.
- Click Generate Stream Profile. A profile named
reservoarrappears in Settings → Stream Settings → Profiles. - Refresh the Dispatcharr browser tab (the profile picker is cached client-side).
If you didn't set it as default in step 3, assign it per-channel: Channels → Edit → Stream Profile → reservoarr.
That's it. Now tune a channel to confirm it's working.
Tuning (cushion size, watchdog thresholds, log directory, etc.) is via
RESV_*environment variables on the Dispatcharr container — not plugin UI fields. Defaults match production-validated behaviour and fit most providers. See docs/TUNABLES.md before overriding.
-
Put
reservoarr.pyinside the Dispatcharr container's mounted/datavolume (e.g./data/scripts/reservoarr.py). Make it executable (chmod +x). -
In Dispatcharr → Settings → Stream Settings → Profiles, add:
Field Value Name delay-bufferCommand /data/scripts/reservoarr.pyParameters {streamUrl} {userAgent}Active yes -
Set it as the instance-wide default (Settings → Stream Settings → Default Stream Profile) or per-channel under Channels → Edit → Stream Profile.
Every channel start spawns a fresh process — no container restart needed when you update the script.
The pattern below — get_url of a tagged release with a sha256 checksum, Renovate-tracked — keeps deployments reproducible.
# defaults/main.yml
# renovate: datasource=github-releases depName=brko7/reservoarr
reservoarr_version: "X.Y.Z" # latest from the releases page
reservoarr_sha256: <fill from release body># tasks/dispatcharr.yml
- name: Deploy delay-buffer reservoarr script
ansible.builtin.get_url:
url: "https://github.com/brko7/reservoarr/releases/download/v{{ reservoarr_version }}/reservoarr.py"
dest: "{{ appdata_dir }}/dispatcharr/data/scripts/reservoarr.py"
checksum: "sha256:{{ reservoarr_sha256 }}"
mode: "0755"The Stream Profile DB row can be created idempotently via Dispatcharr's manage.py shell:
from core.models import StreamProfile, CoreSettings
desired = {"command": "/data/scripts/reservoarr.py",
"parameters": "{streamUrl} {userAgent}", "is_active": True}
p, _ = StreamProfile.objects.get_or_create(name="delay-buffer", defaults=desired)
for k, v in desired.items():
setattr(p, k, v)
p.save()
CoreSettings._update_group("stream_settings", "Stream Settings",
{"default_stream_profile": p.id})After installing, start a channel in Plex Live TV (or your client of choice) and tail the telemetry log:
# Inside the Dispatcharr container (or on the host, against the bind-mount path):
tail -F /data/scripts/logs/delaybuf.log | grep "cushion="You should see one stats line per active stream every 15 seconds. The shape is:
2026-06-21T10:03:33+0000 [500004175] cushion=27s(pcr) buf=15.5MB out=4.66Mbps in=4.96Mbps crate=4.80Mbps in_total=1843MB reconnects=0 ccerr=0 pcrrej=0 disc=0 sync=0 pcr_back=0
Healthy after ~60s:
cushion=reaches~25–30s(pcr)and oscillates within ±6s of that. The(pcr)suffix means the cushion is measured off the PCR clock (good);(byte)is a degraded fallback.ccerr,pcrrej,disc,sync,pcr_backflat at zero.reconnects=0unless your provider is one of the unreliable ones.out=≈crate=± a few percent.
If something looks off, see Troubleshooting below. Full telemetry schema in docs/TELEMETRY.md.
For end-to-end smoke testing of a specific channel from the command line, see tools/smoke_channel.sh.
These are the failure modes real users have hit. Each lists what to check first.
The cushion is your only protection against starvation gaps. If it's too small for your CDN's worst gaps, the stream still dies — reservoarr only buys time, not infinity.
- Tail the log while the failure happens. Note what
cushion=was reading just before the death. - If cushion was already low (<10s) when the gap hit: raise
RESV_TARGET_Sabove your observed worst gap. Each +10s of target ≈ +6–8 MB RAM at typical HD bitrates. BumpRESV_MAX_BYTESproportionally. - If cushion was healthy (~30s) and you saw a single very long gap (>30s): the CDN is broken or rate-limited. Increase target and check provider status — no buffer can hide a sustained content stoppage.
cushion= plateaus at 5–15s and won't climb. Two common causes:
- Source is bitrate-degraded. Compare
crate=(PCR content rate) to your channel's nominal rate. Ifcrateis well under (e.g. a "5 Mbps" HD channel showingcrate=2 Mbps), the CDN is throttling and the front-load burst doesn't contain 30s of content. Nothing to fix in reservoarr; talk to your provider. - Cushion is stuck on
(byte)source. The PCR clock never locked. Checkpcrrej=; if it's climbing every window, the stream's PCR samples are unparseable. Trytools/parsecheck.pyon a captured upstream to confirm.
CC (continuity counter) errors flag genuine TS-packet damage. Some sources have a low baseline (1–2 per stats window); a sustained burst (≥3/window for multiple consecutive windows) is real corruption.
- If
ccerrramps up with audio/video desync: corruption is hitting decoded content. Watch forwould-fire: TS corruption detectedlines — that's the ingest-side detector picking it up. - The
#5detector ships in log-only mode by default (RESV_TS_RECONNECT=0). To arm it: setRESV_TS_RECONNECT=1on the container. The script will then force a reconnect + buffer flush when corruption sustains; see CHANGELOG v6.1.0 for the arming-test recipe and the open question about source-wide vs. edge-specific corruption.
The 2026-06-16 alignment bug (latent since v5, fixed in v6.2.1) was the cause of every prior AV-desync report in this codebase. If you're on v6.2.1 or later and still see it:
- Check the log for
Packet corrupt (stream = N, dts = M)lines (these are ffmpeg-stderr relayed). A loop of samedts3× in 120s triggers the corrupt-loop detector — the script will reconnect and flush. - If it's different dts each time, the source is genuinely emitting damaged frames; the
#5detector (log-only by default) catches this class and would-fire on it. Arm it as above. - If you see neither: please open an issue with the relevant
delaybuf.logexcerpt — pre-v6.2.1 contamination is now ruled out and a new AV-desync class would be worth investigating.
Plex's tuner times out after ~15s of input starvation. The prefill phase exists to push the first bytes within that window.
- Check the log for
prefill done: NMB in Nsafter a channel start. TheNsis wall-clock time to first frame. If it's >5s, your provider is slow to send the first bytes. - Do not raise
RESV_PREFILL_MAX_Spast 5s — Plex's hard ceiling is ~15s and Dispatcharr's connection timeout sits around 10s. - If your provider's first-bytes latency is consistently bad, this is a provider-side problem; no wrapper buffer can fix it.
Check RESV_LOG_DIR on the container (defaults to /data/scripts/logs/). The script os.makedirs's it at startup; if it can't (permission denied, read-only mount), the run continues but logs only to stderr (which Dispatcharr's transcode logger captures separately). Confirm the directory exists and is writable by the container's PUID.
Open a Discussion with:
delaybuf.logexcerpt around the event (15 min before/after)- Your
RESV_*env settings (or "defaults" if none) - Whether restarting the channel resolved it
Pacing happens in the wrapper, not in ffmpeg. The reservoir fetches upstream bytes eagerly while a controller releases them to ffmpeg at the stream's PCR content rate (bytes-per-second of media time), holding a ~30s cushion. The CDN's per-connection front-load burst banks the cushion; gaps shorter than the cushion drain it without the player ever noticing.
Why pace here and not with ffmpeg -re: the provider's streams carry occasional corrupt packets with garbage DTS values, and -re sleeps on them (observed 25s output freeze with a full reservoir). PCR is a measurement input — a garbage sample is rejected by a plausibility window and the chain re-anchors.
Three watchdogs ride alongside:
- Corrupt-loop (ffmpeg-stderr-side, default armed): same
dtsreported 3× → reconnect + flush. - Stall (#4, default armed): no ingest progress for
RESV_STALL_S→ reconnect WITHOUT flushing. - TS corruption (#5, default log-only): sustained CC/sync errors while bytes flow → reconnect + flush, when
RESV_TS_RECONNECT=1.
Deeper reading:
- docs/INVARIANTS.md — the rules anyone editing the script must respect, with the production failure each one is earned by.
- docs/TUNABLES.md — env-var reference, when to bump what.
- docs/TELEMETRY.md — log-line schema, lifecycle events, what healthy looks like.
- CHANGELOG.md — incident history per release; the design rationale lives here.
just venv # one-time: create .venv with pytest + ruff + pytest-xdist
just fixture # generate fixtures/synth.ts (deterministic, ~2s)
just test # unit tests (~1s)
just e2e # synthetic end-to-end (~90s wall-clock with xdist -n auto)
just all # lint + unit + e2e- Unit tests:
TsParser(PCR extraction, wrap-aware delta, backward-jump detection, CC continuity, sync recovery, content rate vs ffprobe truth on the fixture), pacing controller math, reconnect-precedence state machine, #5 detector rule replay against the real 2026-06-14 incident signature, and TS-packet alignment regression coverage (v6.2.1 fix). - End-to-end tests: spawn
tools/cdn_sim.pyas a real HTTP server andreservoarr.pyas a real subprocess, with the system ffmpeg. Asserts cushion build, PCR clock lock, 12s stall absorbed, >25s stall trips the watchdog without flushing, forced EOF reconnects cleanly, CC corruption logswould-fire:with zero false positives. - CI: GitHub Actions runs lint + version-sync on every push/PR, plus unit + e2e whenever code changes — provider-independent (synthetic fixture only).
See CONTRIBUTING.md before opening a PR, and docs/RELEASING.md for how releases are cut.
| Path | Purpose |
|---|---|
tools/cdn_sim.py |
HTTP server that replays a TS file with the IPTorrents-IPTV delivery shape: front-load burst, stall windows, forced EOF, CC-field corruption injection. |
tools/make_synth_ts.sh |
Generates the deterministic synthetic MPEG-TS fixture (testsrc2 + sine → H.264 + AC3 with PCR). Used by CI. |
tools/make_corrupt_ts.py |
Standalone CC-corruption generator (alternative to cdn_sim's in-band injection). |
tools/parsecheck.py |
Feeds a captured TS file through TsParser and compares the PCR-derived duration/rate to ffprobe truth. Useful for validating real CDN captures. |
tools/smoke_channel.sh |
Manual ops: tune a Dispatcharr channel end-to-end through the proxy and dump the reservoir telemetry. Parameterized via env (HOST, CONTAINER, PROXY_BASE, LOG_PATH) — not CI-runnable. |
tools/build_zip.sh |
Single source of truth for the reproducible plugin-zip recipe. Called by release.yml and by CI's reproducibility check. |
tools/check_versions.py |
Version-sync gate: asserts pyproject.toml ↔ plugin/plugin.json ↔ plugin/plugin.py parity and a matching CHANGELOG.md section. Runs in just lint and CI. |
- Bugs / feature requests → Issues (use the templates — they ask for the telemetry excerpts that make triage fast).
- Questions, setup help, tuning → Discussions.
- Security or Code-of-Conduct concerns → private advisory flow. See SECURITY.md and CODE_OF_CONDUCT.md.
reservoarr.py runs inside the Dispatcharr container with whatever privileges that container has. It fetches the upstream URL Dispatcharr hands it, pipes bytes through ffmpeg, and writes a log file — nothing else. No credentials, no database access, no outbound traffic beyond the configured upstream. See SECURITY.md for the full threat model.
MIT. See LICENSE.