Synchronize watched status between Jellyfin, Radarr, and Sonarr, and automatically organize media by moving watched content from a New folder to a Watched folder.
This project ensures consistent watched state across your media stack and automates file organization.
For both movies and series, the script:
- Syncs Jellyfin β Arr (apply watched tags)
- Syncs Arr β Jellyfin (restore watched state if missing)
- Moves watched media from
NewβWatched - Triggers a Jellyfin refresh (optional)
- Performs a post-refresh repair to restore watched state
- π Bidirectional sync between Jellyfin and Radarr/Sonarr
- π Automatic file organization via Arr (no direct file manipulation)
- π‘οΈ Safe refresh handling with post-refresh state repair
- π Retry logic for API/network resilience
- βοΈ Fully configurable via
.env - π§ͺ Tested with pytest
- π³ Docker-ready with cron scheduling
sync_watched.py
.env.example
requirements.txt
tests/
docker/
entrypoint.sh
Dockerfile
docker-compose.yml
pytest.ini
cd /home/amc/code/projects/sync-watched-project
python3 -m venv .venv
source .venv/bin/activate
pip install -r requirements.txt
cp .env.example .envThen edit .env with your actual values.
RADARR_URLRADARR_API_KEYJELLYFIN_URLJELLYFIN_API_KEYJELLYFIN_USERNAME
SONARR_URLSONARR_API_KEY
RADARR_WATCHED_TAG(default:watched)SONARR_WATCHED_TAG(default:watched)
Movies:
MOVIE_NEW_ROOT_FOLDERMOVIE_WATCHED_ROOT_FOLDER
Series:
SERIES_NEW_ROOT_FOLDERSERIES_WATCHED_ROOT_FOLDER
LOG_LEVELLOG_FILEREQUEST_TIMEOUTMOVE_WAIT_SECONDS
TZ(e.g.Europe/Berlin)CRON_SCHEDULE(e.g.0 */6 * * *)RUN_ON_START(true/false)
This project uses Arr (Radarr/Sonarr) as the source of truth for watched state.
After a Jellyfin refresh:
- Jellyfin may lose watched state for moved files
- The script automatically restores it from Arr tags
The script does not modify Jellyfin item paths directly.
Why:
- Jellyfin API path updates are unreliable (
POST /Items/{id}) - Can lead to errors or inconsistent state
Instead:
- Files are moved by Radarr/Sonarr
- Jellyfin detects changes via refresh
- State is repaired afterward
No manual lookup required:
- Jellyfin user ID resolved from username
- Tag IDs resolved from tag names in Arr
Before running:
- Folders must exist (
New,Watched) - Must be configured in Radarr/Sonarr
- Jellyfin must already see these paths
- Media must already be imported in Arr
- Watched tags must exist in Arr
python3 sync_watched.py --dry-runpython3 sync_watched.py--skip-move # do not move files
--skip-refresh # skip Jellyfin refreshdocker compose up -d --builddocker compose logs -fdocker compose downRUN_ON_START=true docker compose up -d --build| Schedule | Expression |
|---|---|
| Every 6 hours | 0 */6 * * * |
| Daily at 03:00 | 0 3 * * * |
| Twice daily | 0 3,15 * * * |
Uses Loguru.
Configure via .env:
LOG_LEVEL=INFO
LOG_FILE=/logs/sync.log- Console logging always enabled
- File logging optional with rotation
The script:
- validates environment variables at startup
- retries API calls (network/DNS safe)
- logs all failures clearly
- exits early on critical failures
- skips optional components safely (e.g. Sonarr)
Run:
pytest -qWith coverage:
pytest --cov=sync_watchedCoverage includes:
- API layer
- sync logic
- move operations
- retry handling
- edge cases
- Arr manages files
- Jellyfin reflects state
- Script ensures consistency
This avoids fragile direct manipulation of Jellyfin internals and keeps the system resilient.
This tool provides a safe, automated, and consistent workflow for:
- syncing watched state
- organizing media
- maintaining clean library structure
while avoiding common pitfalls in Jellyfinβs API behavior.