Skip to content

Repository files navigation

Dispatcharr Image Guardian

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.

Why this exists

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 astats peak/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.

Quick start

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-guardian

You 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.

How it works

┌─────────────────────────────────────────────────────────────────┐
│  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)      │
                └──────────────────────────┘

Probe phases

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.

Configuration

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.

Required

Var Description
DISPATCHARR_USER Dispatcharr admin username (used to obtain API token)
DISPATCHARR_PASS Dispatcharr admin password

Connection

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

Probing cadence

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

Detection thresholds

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)

Failover behaviour

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

Tuning guide

If you see specific symptoms, here's what to adjust:

  • False-positive frozen on legitimate low-motion content (a static news graphic, a slow camera pan): raise GUARDIAN_FROZEN_PHASH_DISTANCE from 1 to 2. Cost: may miss frozen streams whose first I-frame has minor noise. The default of 1 only flags near-byte-identical frames.
  • False-positive audio_noise on mono-channel ad reads or sustained applause: raise GUARDIAN_AUDIO_NOISE_FLOOR_DB from -25 toward -20 so only louder noise-like signals trip the gate.
  • Probe queue backlog when many channels are active: raise GUARDIAN_WORKER_COUNT from 1 to 2. 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_SECONDS from 8 to 6. 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"] from 2 to 3 (currently requires editing the source). A future release will expose this as an env var if needed.

Adding your own offline placeholders

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.

The easy way (recommended): drop an image in the folder

  1. 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.)

  2. Drop it into your bind-mounted offline_screens/ directory:

    mv my_offline_card.jpg ./image-guardian-data/offline_screens/
  3. 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)).

Bundled placeholders

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.

The advanced way: explicit JSON entries

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"]
  }
]

Match priority

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.

Known limitations

  • HEVC GOP wait on swap. When change_stream swaps 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_DELAY shortens this window but risks the verify firing before the new stream has stabilised.
  • Dispatcharr Redis metadata gap. Dispatcharr's /proxy/ts/status endpoint occasionally returns stream_id: null for an actively-streaming channel due to internal Redis state lag. The watchdog handles this gracefully (keys state by channel_uuid only) but you may see (unknown) in logs briefly post-failover.

Development

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:dev

Releases 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.

Contributing

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.

Credits

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

License

MIT. See LICENSE.

About

Content-aware failover sidecar for Dispatcharr. Detects black screens, frozen video, offline placeholders, garbled audio then tests backup streams before swapping.

Topics

Resources

Stars

1 star

Watchers

1 watching

Forks

Releases

Packages

Contributors

Languages