⚠️ AI-Generated Project — This project was built with the assistance of Claude AI by Anthropic. Review all code before running it in your environment.
A lightweight webhook receiver that listens for Sonarr and Radarr events and triggers a targeted partial Plex library scan — no full library refreshes needed. Optionally integrates with rclone VFS to clear the cache before scanning (enable only if you use an rclone mount).
Sonarr / Radarr → media-servarr-sync → [rclone vfs/forget + vfs/refresh] → Plex partial scan
(optional, USE_RCLONE=true)
- Targeted scans — only the affected show/movie folder is scanned, not the whole library
- rclone optional — works standalone without rclone; enable with
USE_RCLONE=trueif you use a VFS mount - Deduplication — duplicate webhooks for the same folder are dropped while a sync is already in-flight
- Configurable delay — wait N seconds after the webhook before scanning (gives Sonarr/Radarr time to finish writing)
- Minimum file age — optionally hold off scanning until a file is at least N seconds old
- Retry on timeout — Plex scan attempts retry up to 3 times with automatic reconnection on stale sessions
- Health endpoint —
/healthexposes queue depth, Plex connectivity, rclone mode, and recent sync history - Stats API —
/api/statsreturns aggregate sync counts, queue state, and last sync info — ready for Homepagecustomapiwidget - Manual trigger UI — password-protected web UI at
/for ad-hoc scans - Sign in with Plex — log in via Plex.tv's PIN-based OAuth instead of a password; bootstraps
PLEX_TOKENautomatically on first sign-in - Settings page — view and edit every configurable value from the web UI at
/settings. Values already set via environment variables are shown locked (env always wins); everything else is editable and saved to/data/settings.db, taking effect immediately with no restart. Includes one-click Plex server discovery for the signed-in account - Sync history — paginated sync results (path, status, duration, errors) with server-side path search and status filter (All / OK / Failed)
- Episode display — for Sonarr events, the episode filename (e.g.
Show.S01E01.mkv) is shown beneath the season folder path; batch imports show an episode-count badge that reveals all individual filenames on hover (filenames wrap fully, no truncation) - Quality & profile tags — each history entry displays colour-coded tags for the file quality (blue), quality profile (green, resolved via the Sonarr/Radarr API), and any custom formats (purple); hover a tag to see its label
- Filterable tags — click any quality or profile tag to filter the history list to matching entries; active filters appear as dismissible pills in the filter bar and are preserved across search and pagination
- Tag colour legend — a horizontal fixed panel to the left of the Sync UI explains what each tag colour means; visible on wide viewports where there's room beside the main column, hidden on narrow screens
- Configurable auto-refresh — set the refresh interval for Now Playing and Sync history independently via a number input on each card;
0= live (1-second polling),-1= off, any positive integer = interval in seconds with a live countdown. Preference persists inlocalStorageacross page loads and tab switches - Now Playing — active Plex streams shown directly on the Sync tab: player info, artwork, progress bar, stream quality (including HW Transcode detection), and an interactive map of the player's approximate location. On wide viewports the card pins as a fixed left sidebar below the tag legend
- Server stats — live CPU % and RAM % sparkline charts (System vs Plex process) and current LAN/WAN bandwidth, shown directly on the Sync tab. Powered by the Plex
/statistics/resourcesand/statistics/bandwidthAPIs; polled every 30 s with no external CDN. On wide viewports the card pins as a fixed right sidebar - Full library scan — trigger a full Plex section scan directly from the Sync tab via a library selector dropdown
- Invite management — create time-limited invite links for new Plex users; configure allowed libraries, permissions, max uses, and expiry; track and revoke accepted invites
- Single-page navigation — tab switching uses PJAX (in-place content swap) with no full page reload
- Self-hosted fonts — IBM Plex Mono and IBM Plex Sans are served from the container; no external CDN requests, works behind strict reverse proxies
git clone https://github.com/johnfawkes/media-servarr-sync.git
cd media-servarr-synccp .env.example .env
# Edit .env with your valuesdocker compose up -dAll configuration is done via environment variables (or a .env file in the project root).
| Variable | Required | Default | Description |
|---|---|---|---|
PLEX_URL |
✔️ | http://127.0.0.1:32400 |
URL of your Plex Media Server |
PLEX_TOKEN |
✔️ | — | Your Plex authentication token (how to find it) |
PLEX_TIMEOUT |
60 |
Plex API call timeout. Accepts same duration format as WEBHOOK_DELAY e.g. 60, 2m |
|
PLEXAPI_HEADER_IDENTIFIER |
media-servarr-sync |
Stable client identifier sent to Plex — prevents a new device being registered on every container restart | |
TZ |
UTC |
IANA timezone for log timestamps and sync history, e.g. America/New_York, Europe/London |
|
PORT |
5000 |
Port the webhook receiver listens on | |
WEBHOOK_DELAY |
30 |
Time to wait after receiving a webhook before acting. Accepts 30, 30s, 5m, 1h |
|
MINIMUM_AGE |
0 |
Minimum file age before scanning. Same format as WEBHOOK_DELAY. 0 disables |
|
SYNC_COOLDOWN |
5m |
After a path finishes processing, ignore further webhooks for it during this window. Prevents duplicate history entries when Sonarr fires a trailing Rename event after a Download. Set to 0 to disable. |
|
HISTORY_DAYS |
7 |
Number of days to retain sync history. Older entries are auto-deleted. | |
SECTION_MAPPING |
✔️ | {} |
JSON map of path prefixes → Plex library section IDs |
PATH_REPLACEMENTS |
{} |
JSON map: Sonarr/Radarr path prefix → path as seen inside this container | |
SONARR_URL |
no | — | Sonarr base URL, e.g. http://sonarr:8989. Enables quality profile and custom format lookups for Sonarr sync history entries |
SONARR_API_KEY |
no | — | Sonarr API key (Settings → General → Security). Required alongside SONARR_URL |
RADARR_URL |
no | — | Radarr base URL, e.g. http://radarr:7878. Enables quality profile and custom format lookups for Radarr sync history entries |
RADARR_API_KEY |
no | — | Radarr API key (Settings → General → Security). Required alongside RADARR_URL |
MANUAL_USER |
admin |
Username for the manual trigger UI | |
MANUAL_PASS |
changeme |
Password for the manual trigger UI | |
SECRET_KEY |
✔️ | random |
Secret used to sign session cookies. Generate with python3 -c "import secrets; print(secrets.token_hex(32))" |
MEDIA_ROOT |
/mnt/media |
Host path to your media root, mounted into the container (only needed if MINIMUM_AGE > 0) |
|
CONTAINER_MEDIA |
/mnt/media |
Path inside this container where MEDIA_ROOT is mounted — must match the path Plex uses to see your files |
Set USE_RCLONE=true only if you serve your media through an rclone VFS mount. If you use a direct disk, NFS, MergerFS, or any non-rclone mount, leave this false and ignore all other RCLONE_* variables.
| Variable | Required | Default | Description |
|---|---|---|---|
USE_RCLONE |
false |
Enable rclone VFS cache clearing before each scan | |
RCLONE_RC_URL |
if rclone | — | rclone RC endpoint, e.g. http://rclone:5572 |
RCLONE_RC_USER |
— | rclone RC username (if auth is enabled) | |
RCLONE_RC_PASS |
— | rclone RC password (if auth is enabled) | |
RCLONE_MOUNT_ROOT |
— | Absolute path of the rclone mount root, e.g. /mnt/media |
|
RCLONE_PATH_REPLACEMENTS |
{} |
JSON map: Sonarr/Radarr path prefix → rclone host path |
WEBHOOK_DELAY, MINIMUM_AGE, and SYNC_COOLDOWN accept:
| Format | Meaning |
|---|---|
30 or 30s |
30 seconds |
5m |
5 minutes |
1h |
1 hour |
1d |
1 day |
These are JSON objects mapping path prefixes. Matching is case-insensitive and longest-prefix wins.
PATH_REPLACEMENTS — translates paths from how Sonarr/Radarr report them to the path Plex uses (i.e. your CONTAINER_MEDIA prefix):
PATH_REPLACEMENTS={ "/data/tv": "/mnt/media/tv", "/data/movies": "/mnt/media/movies" }RCLONE_PATH_REPLACEMENTS — only needed when USE_RCLONE=true. Translates paths to the form needed for the rclone RC call:
RCLONE_PATH_REPLACEMENTS={ "/data/tv": "/mnt/media/tv" }SECTION_MAPPING — maps a path prefix to a Plex library section ID. To find your section IDs, visit:
http://YOUR_PLEX_IP:32400/library/sections?X-Plex-Token=YOUR_TOKEN
SECTION_MAPPING={ "/mnt/media/tv": "1", "/mnt/media/movies": "2" }- Go to Settings → Connect → + (Add Connection) → Webhook
- Set the URL to:
http://media-servarr-sync:5000/webhook/sonarr - Enable events: On Import, On Upgrade
- Go to Settings → Connect → + (Add Connection) → Webhook
- Set the URL to:
http://media-servarr-sync:5000/webhook/radarr - Enable events: On Import, On Upgrade
| Endpoint | Method | Auth | Description |
|---|---|---|---|
/webhook/sonarr |
POST | None | Sonarr webhook receiver |
/webhook/radarr |
POST | None | Radarr webhook receiver |
/ |
GET / POST | Session | Sync tab — manual trigger, history, Now Playing, Server Stats |
/invites |
GET | Session | Invite management tab |
/settings |
GET / POST | Session | View/edit config not pinned by an env var |
/invite/<token> |
GET | None | Public invite landing page |
/health |
GET | None | JSON health check + recent history |
/api/stats |
GET | None | Aggregate sync stats for dashboards |
/api/server-stats |
GET | Session | Plex server CPU, RAM, and bandwidth stats |
{
"status": "ok",
"plex_connected": true,
"rclone_enabled": false,
"queue_depth": 0,
"worker_alive": true,
"recent_history": [
{
"ts": "2025-01-15T21:04:33+00:00",
"label": "SONARR",
"path": "/mnt/media/tv/Breaking Bad/",
"status": "ok",
"error": "",
"duration_s": 43.2
}
]
}Returns aggregate statistics suitable for a Homepage customapi widget.
{
"syncs": {
"total": 142,
"ok": 139,
"failed": 3,
"sonarr": 98,
"radarr": 41,
"manual": 3,
"avg_duration_s": 18.4
},
"queue": { "depth": 0, "in_flight": 0 },
"worker": { "alive": true },
"last_sync": {
"at": "2026-03-02T14:22:01+00:00",
"status": "ok",
"label": "SONARR",
"path": "/mnt/media/tv/Breaking Bad/"
},
"retention_days": 7
}Counts reflect the current HISTORY_DAYS retention window.
- Media Servarr Sync:
href: http://media-servarr-sync:5000
widget:
type: customapi
url: http://media-servarr-sync:5000/api/stats
refreshInterval: 30000
mappings:
- field: syncs.total
label: Total Syncs
format: number
- field: syncs.ok
label: Successful
format: number
- field: syncs.failed
label: Failed
format: number
- field: queue.depth
label: Queued
format: numberUSE_RCLONE=false
PLEX_URL=http://192.168.1.100:32400
PLEX_TOKEN=xxxx
SECTION_MAPPING={ "/mnt/media/tv": "1", "/mnt/media/movies": "2" }Sonarr and Plex share the same paths inside Docker — no PATH_REPLACEMENTS needed.
USE_RCLONE=true
RCLONE_RC_URL=http://rclone:5572
RCLONE_MOUNT_ROOT=/mnt/media
PATH_REPLACEMENTS={ "/data/tv": "/mnt/media/tv" }
RCLONE_PATH_REPLACEMENTS={ "/data/tv": "/mnt/media/tv" }
SECTION_MAPPING={ "/mnt/media/tv": "1" }networks:
media:
external: true
services:
media-servarr-sync:
image: media-servarr-sync:latest
networks:
- media
environment:
USE_RCLONE: "true"
RCLONE_RC_URL: http://rclone:5572
# ... other varsdocker build -t media-servarr-sync .MIT



