Episode Guard watches Tautulli for what you're playing and tells Sonarr to grab upcoming episodes before you get there. No more finishing an episode and finding the next one isn't downloaded.
Every time Tautulli sees you watching a TV episode, Episode Guard:
- Looks ahead a configurable number of episodes and grabs anything missing. Before triggering a search, it checks the Sonarr queue — if an episode is already downloading, queued, or pending, the search is skipped to avoid duplicate grabs. If an episode is in a failed or warning state, it re-searches normally.
- Near the end of a season, checks what's next:
- If the next season hasn't aired or is still airing, marks the whole season monitored so Sonarr picks up episodes as they release, and searches for any aired-but-missing episodes
- If the next season has fully aired, searches all missing episodes by ID directly (bypasses monitored status)
- Sends a notification via Apprise for anything it acts on
You can also point Tautulli webhooks at Episode Guard so it reacts the moment you press play, rather than waiting for the next poll.
| Event | What it means |
|---|---|
| Webhook | Tautulli sent a play event, Episode Guard woke up immediately |
| Confirmed | Currently playing, on disk by definition |
| Grabbed | Search triggered in Sonarr for a missing episode |
| On Disk | Upcoming episode already on disk, or not aired yet. One row per episode |
| In Queue | Episode is already downloading or queued in Sonarr, search skipped |
| Monitored | Upcoming episode wasn't monitored, fixed so Sonarr grabs it when it airs |
| Season Monitored | Near season end, next season marked monitored |
| Error | Something went wrong, check the details column |
Each upcoming episode gets its own row so you can see exactly what was checked and why. The details column shows a plain-English summary. For unaired episodes it includes the expected air date.
Set your environment variables in the environment section of your docker-compose.yml. At least one media source (Tautulli or Jellyfin) must be configured — both can run simultaneously:
services:
episodeguard:
image: episodeguard
environment:
# Tautulli (Plex) — optional if Jellyfin is configured
TAUTULLI_URL: http://tautulli:8181
TAUTULLI_API_KEY: your_tautulli_api_key
# Jellyfin — optional if Tautulli is configured
JELLYFIN_URL: http://jellyfin:8096
JELLYFIN_API_KEY: your_jellyfin_api_key
SONARR_URL: http://sonarr:8989
SONARR_API_KEY: your_sonarr_api_key
SECRET_KEY: your_random_secret
APP_URL: http://episodeguard.yourdomain.com
ports:
- "8988:8988"
volumes:
- ./data:/dataThen start it:
docker compose up -dThe container runs as the built-in node user (uid 1000). If you bind-mount the data directory, the host folder needs to be writable by uid 1000.
First check what uid your host user is:
id your-usernameIf it is uid 1000, just make sure the data folder is writable:
chmod -R u+w ./dataIf it is a different uid, transfer ownership to 1000 directly:
chown -R 1000:1000 ./dataIf the data directory does not exist yet, create it with the right ownership before starting the container:
mkdir -p ./data && chown 1000:1000 ./dataOpen http://localhost:8988 and you'll be redirected to the login page.
On first run, a local admin account is created and the temporary password is printed to the container logs:
docker logs episodeguard 2>&1 | grep -A5 "FIRST RUN"Change the password in Settings > Account > Change Local Password after logging in.
Episode Guard supports OIDC (recommended) with a local account fallback.
- Create an OAuth2/OIDC application in your provider:
- Redirect URI:
http://your-episodeguard-host:8988/auth/callback/<provider-id>(the ID is shown after you save the provider in Settings) - Scopes:
openid email profile
- Redirect URI:
- In Episode Guard Settings > OIDC Providers, click Add Provider and fill in the display name, Issuer URL, Client ID, and Client Secret
- In Allowed OIDC Users, add each user by their Subject ID from your provider's admin panel
- Users can now log in via the OIDC button on the login page
The local admin account stays available as a fallback if OIDC is unreachable.
If no OIDC provider is configured, or the provider is unavailable, the login page shows the local login form directly. Use the admin account created on first run.
| Variable | Required | Description |
|---|---|---|
TAUTULLI_URL |
one of Tautulli/Jellyfin | e.g. http://tautulli:8181 |
TAUTULLI_API_KEY |
one of Tautulli/Jellyfin | Tautulli > Settings > Web Interface |
JELLYFIN_URL |
one of Tautulli/Jellyfin | e.g. http://jellyfin:8096 |
JELLYFIN_API_KEY |
one of Tautulli/Jellyfin | Jellyfin > Dashboard > API Keys |
SONARR_URL |
yes | e.g. http://sonarr:8989 |
SONARR_API_KEY |
yes | Sonarr > Settings > General |
SECRET_KEY |
yes | Random secret for session signing and OIDC secret encryption. Generate with openssl rand -base64 32 |
APP_URL |
no | Full URL Episode Guard is reachable at, used for OIDC redirect URIs. Default: http://localhost:8988 |
PORT |
no | Override the default port (8988) |
DATA_DIR |
no | Path inside container for SQLite DB. Default: /data |
These live in the Settings tab, stored in SQLite:
| Setting | Default | What it does |
|---|---|---|
poll_interval_seconds |
300 | How often to poll Tautulli |
lookahead_episodes |
3 | Episodes ahead to check and grab |
season_end_buffer |
3 | Episodes from the finale before checking the next season |
log_retention_days |
30 | How long to keep activity log entries |
apprise_url |
(none) | Apprise endpoint for notifications |
apprise_events |
episode_grabbed |
Comma-separated events to notify on |
webhook_enabled |
off | React on play via webhook, polling drops to a fallback |
webhook_secret |
(none) | Optional secret to validate incoming webhook requests |
session_max_age_hours |
24 | How long before users are signed out |
Episode Guard accepts webhooks from both Tautulli and Jellyfin at the same endpoint. The source is detected automatically from the payload shape — no configuration needed.
- Tautulli > Settings > Notification Agents > add a Webhook
- Set the Webhook URL to
http://your-episodeguard-host:8988/api/webhook - Under Triggers, enable Playback Start
- Under Data, set Method to POST and paste this JSON body:
{
"media_type": "{media_type}",
"grandparent_title": "{show_name}",
"parent_media_index": "{season_num}",
"media_index": "{episode_num}",
"grandparent_guids": ["tvdb://{thetvdb_id}"]
}Season and episode numbers need to be quoted strings. Tautulli's JSON validator rejects bare integers in templates, but the handler parses them with parseInt so this works fine.
When webhook mode is on, the dashboard status bar shows Webhook active. To confirm webhooks are landing, check the activity log. A successful webhook shows as a Webhook row followed immediately by the processing results. If you play something and see no Webhook row, Tautulli isn't reaching the endpoint. Check the URL and that Episode Guard is accessible from the Tautulli host.
Episodes triggered by webhook are skipped by polling for 4 hours to avoid double-processing. Polling still runs as a fallback for any play events Tautulli fails to deliver.
To block unauthorised requests to the webhook endpoint:
- Set a Webhook Secret in Settings > Webhook Security
- In Tautulli, add a custom HTTP header to the webhook agent:
Header name: X-Webhook-Token
Header value: <your secret>
Tautulli path: Notification Agents > your webhook > Headers tab.
If a secret is configured and the header is missing or wrong, Episode Guard returns 401 and ignores the request.
- In Jellyfin, go to Dashboard > Plugins > Catalog and install the Webhook plugin, then restart Jellyfin
- Go to Dashboard > Plugins > Webhook and add a new destination:
- URL:
http://your-episodeguard-host:8988/api/webhook - Notification Type: Playback Start
- Template: Default (no customisation needed)
- URL:
- If you have a webhook secret configured, add it under Headers:
X-Webhook-Token: <your secret>
Episode Guard detects Jellyfin payloads automatically. For reliable show matching, ensure your Jellyfin series have TVDB metadata populated (Dashboard > Libraries > refresh metadata). If TVDB is missing, Episode Guard falls back to title matching against Sonarr. If a title matches multiple series in Sonarr, the episode is skipped with a warning in the logs.
When both Tautulli and Jellyfin are configured, Episode Guard polls both simultaneously and merges the results. If the same episode is detected on both sources at once (e.g. two users watching on different servers), it is processed once — the duplicate is dropped before any Sonarr calls are made.
The app header shows the current version (e.g. v2.0.0) next to the Episode Guard logo. It reads from /api/version, which pulls the version field from package.json at startup.
docker build -t episodeguard .All changes go through a branch and pull request — nothing gets merged directly to main.
- Create a branch off
mainwith a short descriptive name:git checkout -b fix/webhook-auth
- Make your changes and commit them with a clear message.
- Push the branch and open a pull request against
main. - The PR description should explain what changed and why.
There's no formal review requirement for a solo project, but the branch/PR flow keeps the history clean and makes it easy to revert if something breaks.
