Content-aware failover sidecar for Dispatcharr. When your IPTV upstream silently goes wrong — a frozen frame, a provider "offline" placeholder, garbled audio that still plays, decoded-as-noise samples — Image Guardian detects it and triggers a clean failover to a healthy stream from your channel's backup list.
Dispatcharr ships with a built-in byte-rate watchdog ("Buffer Watchdog") that detects when an upstream stops delivering bytes. That covers maybe 30% of real IPTV failures. The other 70% — bytes flowing but content is broken — looks healthy at the byte level and gets passed through to your TV unchanged.
The community sidecar sergeantpanda/streamwatchdog improved on this with a deeper byte-rate check, but it still operates on byte flow, not on what the bytes mean once they're decoded. Image Guardian probes the actual decoded content: pixels, perceptual hashes, audio sample statistics. It catches things byte-rate watchdogs structurally cannot:
- Provider offline placeholders (the "service unavailable" cards some providers serve when their channel goes down) via perceptual hash matching
- Frozen video (last good frame replaying) via two-frame perceptual hash compare
- Black-screen-with-live-audio via luminance + variance check on a captured frame
- Garbled audio that still plays (decoded but corrupted samples) via
astatspeak/RMS analysis — out-of-range samples (peak >+1 dBFS), clipping, noise-like statistics
It also does content-aware candidate vetting before failover: before swapping a viewer's session to a backup stream, it probes the backup with the same checks. If the backup is also broken, it skips and tries the next one. This avoids the classic "fail over from broken stream A to also-broken stream B" cascade.
Add the service to your existing Dispatcharr docker-compose.yml:
services:
image-guardian:
image: ghcr.io/Wemmy0/dispatcharr-image-guardian:latest
container_name: image-guardian
restart: unless-stopped
networks: [dispatcharr]
environment:
DISPATCHARR_URL: http://dispatcharr:9191
DISPATCHARR_USER: ${DISPATCHARR_USER}
DISPATCHARR_PASS: ${DISPATCHARR_PASS}
volumes:
- ./image-guardian-data:/data/image-guardian
depends_on: [dispatcharr]See docker-compose.example.yml for a complete example including optional Watchtower auto-updates.
Set DISPATCHARR_USER and DISPATCHARR_PASS in a .env file next to your compose file, then:
docker compose up -d image-guardian
docker compose logs -f image-guardianYou should see:
[2026-05-15 01:03:23] image-guardian daemon starting (poll=1.5s, workers=1, hashes=0)
[2026-05-15 01:03:23] auth OK (attempt 1)
That's it. When a real viewer tunes a channel, you'll see NEW channel: and then a probing cadence kicks in. Trigger a failover by tuning a channel whose primary upstream is broken, and watch the candidate walk in the logs.
┌─────────────────────────────────────────────────────────────────┐
│ Poller loop (every 1.5s) │
│ ────────────────────────── │
│ GET /proxy/ts/status │
│ → identify channels with ≥1 real viewer │
│ → schedule due probes onto work queue │
└────────────────────────┬────────────────────────────────────────┘
│
┌──────────▼──────────┐
│ Worker (1 thread) │
│ ───────────────── │
│ For each due probe:│
└──────────┬──────────┘
│
┌────────────▼─────────────┐
│ Signal 1: pHash match │ Highest priority. Cheap.
│ Single grabbed frame │ Single-hit failover.
│ vs offline_hashes.json │
└────────────┬─────────────┘
│
┌────────────▼─────────────────────┐
│ Signal 2: freeze + silence │ freezedetect d=5
│ Signal 2b: audio corruption │ astats Peak/RMS
│ Single 8s ffmpeg call │
└────────────┬─────────────────────┘
│
┌────────────▼─────────────┐
│ Signal 3: probe failure │ Both probes couldn't
│ │ open the stream.
└────────────┬─────────────┘
│
┌────────────▼──────────────────────────────────────┐
│ Detection → counter increment → threshold check │
│ Threshold hit? → trigger_failover() │
└────────────────┬──────────────────────────────────┘
│
┌────────────▼─────────────────┐
│ Phase-3 candidate probing │
│ Walk channel's backup list │
│ For each: │
│ • frame decode + pHash │
│ • black-frame check │
│ • two-frame freeze cmp │
│ • audio sanity (astats) │
│ First healthy → change_stream
│ All bad → next_stream blind │
└────────────┬─────────────────┘
│
┌────────────▼─────────────┐
│ +10s verify probe │
│ Healthy → 30s cooldown │
│ Bad → chain failover │
│ (up to 3 attempts) │
└──────────────────────────┘
Each channel goes through three phases keyed off the time since the current stream started:
| Phase | Window | Probe cadence | Threshold style |
|---|---|---|---|
| startup | 0–60s | T+4, T+8, T+15, T+30, T+60 | Aggressive (single-hit failover) |
| warm | 60s–5min | every 45s | Standard (2 hits for freeze/audio) |
| steady | 5min+ | every 90s | Standard |
The startup phase exists because if you tune a channel and the first frame is already frozen, you don't want to wait 90 seconds for the steady-state probe to catch it. Aggressive thresholds during startup catch tune-in failures fast.
All configuration is via environment variables. The defaults are calibrated against real-world IPTV provider behaviour; most users won't need to change anything beyond the required credentials.
| Var | Description |
|---|---|
DISPATCHARR_USER |
Dispatcharr admin username (used to obtain API token) |
DISPATCHARR_PASS |
Dispatcharr admin password |
| Var | Default | Description |
|---|---|---|
DISPATCHARR_URL |
http://dispatcharr:9191 |
Where Dispatcharr is reachable on the Docker network |
GUARDIAN_DIR |
/data/image-guardian |
Where offline_hashes.json is read from |
GUARDIAN_USER_AGENT |
ImageGuardian/1.0 |
UA on all probe HTTP requests |
| Var | Default | Description |
|---|---|---|
GUARDIAN_POLL_INTERVAL |
1.5 |
Seconds between /proxy/ts/status polls |
GUARDIAN_WORKER_COUNT |
1 |
Parallel ffmpeg probes (raise if CPU spare) |
GUARDIAN_PROBE_SECONDS |
8 |
Per-channel probe ffmpeg duration |
GUARDIAN_WARM_INTERVAL |
45 |
Probe interval in warm phase |
GUARDIAN_STEADY_INTERVAL |
90 |
Probe interval in steady phase |
| Var | Default | Description |
|---|---|---|
GUARDIAN_PHASH_THRESHOLD |
6 |
Max pHash distance to count as a known offline match |
GUARDIAN_FREEZE_SECONDS |
5 |
freezedetect d= value (ongoing probe) |
GUARDIAN_SILENCE_DB |
-50 |
silencedetect n= value |
GUARDIAN_SILENCE_SECONDS |
4 |
silencedetect d= value |
GUARDIAN_FROZEN_PHASH_DISTANCE |
1 |
Candidate freeze threshold (≤this = frozen) |
GUARDIAN_AUDIO_OOR_PEAK_DB |
1.0 |
Audio peak above this dBFS = corrupted decode |
GUARDIAN_AUDIO_CLIP_PEAK_DB |
-0.5 |
Clipping threshold (peak) |
GUARDIAN_AUDIO_CLIP_RMS_DB |
-12.0 |
Clipping threshold (RMS) |
GUARDIAN_AUDIO_NOISE_CREST_DB |
4.0 |
Max crest factor for noise-like signal |
GUARDIAN_AUDIO_NOISE_FLOOR_DB |
-25.0 |
Min RMS for noise check (avoids silence false-pos) |
| Var | Default | Description |
|---|---|---|
GUARDIAN_VERIFY_DELAY |
10 |
Seconds between failover and verify probe |
GUARDIAN_COOLDOWN_VERIFIED |
30 |
Cooldown after a successful verify |
GUARDIAN_COOLDOWN_FULL |
120 |
Cooldown after hitting max consecutive failovers |
GUARDIAN_MAX_CONSECUTIVE_FAILOVERS |
3 |
Storm guard — give up after N back-to-back failovers |
If you see specific symptoms, here's what to adjust:
- False-positive
frozenon legitimate low-motion content (a static news graphic, a slow camera pan): raiseGUARDIAN_FROZEN_PHASH_DISTANCEfrom1to2. Cost: may miss frozen streams whose first I-frame has minor noise. The default of1only flags near-byte-identical frames. - False-positive
audio_noiseon mono-channel ad reads or sustained applause: raiseGUARDIAN_AUDIO_NOISE_FLOOR_DBfrom-25toward-20so only louder noise-like signals trip the gate. - Probe queue backlog when many channels are active: raise
GUARDIAN_WORKER_COUNTfrom1to2. Cost: two simultaneous HEVC decodes on the host. - Failover decisions feel too sluggish (waiting too long before failing over a known-bad stream): drop
GUARDIAN_PROBE_SECONDSfrom8to6. Cost: shorter window for freezedetect to fire, may miss some freezes. - Failover decisions feel too jumpy (flapping between providers when content has brief glitches): raise the audio_corrupt threshold in
THRESHOLDS["warm"]from2to3(currently requires editing the source). A future release will expose this as an env var if needed.
Some IPTV providers serve a recognisable "channel offline" placeholder card when their channel is down. Image Guardian can fail over from these in a single hit if it knows what the placeholder looks like.
-
Capture a frame of the placeholder while viewing the broken channel:
ffmpeg -i "PROVIDER_URL" -frames:v 1 -ss 5 my_offline_card.jpg(Or screenshot it from the player and crop.)
-
Drop it into your bind-mounted
offline_screens/directory:mv my_offline_card.jpg ./image-guardian-data/offline_screens/
-
Restart the container:
docker compose restart image-guardian
That's it. On startup the daemon hashes every .jpg / .jpeg / .png in the folder and adds them to the in-memory registry. The filename (sans extension) becomes the entry name, which is what shows up in the log when a match fires (✗ pHash match: my_offline_card (distance=2)).
The container ships with offline_screens/trex_monkey.jpg (a real T-Rex provider offline card showing a monkey holding an HDMI cable in front of a TV). Any user of T-Rex gets this detection out of the box. Pull requests adding other common provider placeholders are welcome.
If you have a pHash but no source image (e.g. someone published a hash on a forum), add it to offline_hashes.json (bind-mounted at /data/image-guardian/offline_hashes.json):
[
{
"name": "shared_provider_card",
"phash": "baaec4cecc666303",
"description": "Black card with 'Channel temporarily unavailable' text",
"added": "2026-05-15",
"providers": ["some-iptv"]
}
]JSON entries are loaded first, then user offline_screens/ images, then bundled offline_screens/ images. Duplicate pHashes are deduped (first-loaded wins), so you can override a bundled entry by adding an image with a matching hash to your user folder.
The pHash check is O(N) over registered entries per probe, with O(1) hash comparison. Hundreds of entries are fine.
- HEVC GOP wait on swap. When
change_streamswaps the upstream, HEVC video decoders cannot resume until they receive the next I-frame from the new stream. Audio recovers immediately because audio frames are self-contained. The user sees ~2-5s of frozen-video-with-audio after a swap. This is fundamental to HEVC and not fixable without re-encoding at the proxy (which Dispatcharr doesn't currently do). - Provider sister-relationships matter. If two of your provider streams share an upstream source (different CDN, same content origin), they fail simultaneously. The watchdog correctly skips both via candidate probing — but if all three of your providers go down at once, no amount of failover can help; this is a provider-level outage, not something the watchdog can paper over.
- Garbled audio with no other symptoms is detected, but only on the next probe cycle after the swap. If you've just swapped to a corrupted stream, you'll hear ~5-10s of garbled audio before the next probe catches it and chains another failover. Reducing
GUARDIAN_VERIFY_DELAYshortens this window but risks the verify firing before the new stream has stabilised. - Dispatcharr Redis metadata gap. Dispatcharr's
/proxy/ts/statusendpoint occasionally returnsstream_id: nullfor an actively-streaming channel due to internal Redis state lag. The watchdog handles this gracefully (keys state bychannel_uuidonly) but you may see(unknown)in logs briefly post-failover.
Source lives in image_guardian.py. Single-file Python, no external state beyond the offline_hashes.json registry. To iterate locally:
git clone https://github.com/YOUR_USERNAME/dispatcharr-image-guardian.git
cd dispatcharr-image-guardian
# Test the script compiles
python3 -m py_compile image_guardian.py
# Build the container locally
docker build -t image-guardian:dev .
# Run against your dev Dispatcharr
docker run --rm \
--network dispatcharr \
-e DISPATCHARR_URL=http://dispatcharr:9191 \
-e DISPATCHARR_USER=youruser \
-e DISPATCHARR_PASS=yourpass \
-v $(pwd)/data:/data/image-guardian \
image-guardian:devReleases are built and published by GitHub Actions (see .github/workflows/docker-publish.yml). Push a semver git tag (git tag v0.2.0 && git push --tags) to publish a new :latest. Pushes to main build a :main image for testing but don't move :latest.
PRs welcome, but treat this as a personal-use tool shared with the community rather than a supported product. There's no SLA, no roadmap, and merges happen when I have time. If you have a fix or detection improvement that's worked for you in production, open a PR with a brief log excerpt showing the before/after behaviour.
For bugs: open an issue with docker logs image-guardian output covering the incident, the relevant Dispatcharr version, and the provider behaviour you're seeing.
Dispatcharr Image Guardian was built in collaboration with Claude (Anthropic's coding assistant). Claude wrote most of the code under iterative direction; the design choices, detection threshold tuning, provider integration knowledge, and real-world validation on a production Dispatcharr deployment are by @Wemmy0.
Significant prior art from the Dispatcharr community:
- Dispatcharr — the project this slots into
sergeantpanda/streamwatchdog— the byte-rate community watchdog this builds beyond
MIT. See LICENSE.