Repository files navigation

CleanArr

English · Русский

Automatic cascade cleanup for your self-hosted media stack.
CleanArr listens for Jellyfin ItemDeleted webhooks and cascades deletion to Radarr, Sonarr, Seerr, and supported torrent clients — automatically, safely, and without touching files it doesn't own.

Quick start · Linux packages · Screenshots · How it works · Configuration · Torrent clients · Compatibility · Safety · Troubleshooting · Operations · Roadmap · Contributing

Python 3.12React 19License MITDockerLinux packages


What is CleanArr?

When you delete something in Jellyfin, you usually have to manually clean up the same item in Radarr, Sonarr, Seerr, and your torrent clients. CleanArr automates this entire chain:

  1. Jellyfin fires an ItemDeleted webhook
  2. CleanArr resolves the item in Radarr/Sonarr using strict ID matching (TMDB → IMDB → path)
  3. Torrent hashes are routed to qBittorrent, Transmission, Deluge, and rTorrent — only when Arr history proves ownership
  4. The entry is removed from Radarr/Sonarr
  5. Matching requests, issues, and media records are cleaned up in Seerr

Pack torrents, shared files, and anything that can't be safely attributed are always skipped.


Screenshots

Sign in

Sign in screen

Create admin account

First-run — create admin account

Setup wizard

Guided setup wizard — Jellyfin step

Dashboard

Dashboard — all services healthy, Live mode

Activity log

Activity log with deletion history

Jellyfin service modal

Jellyfin service editor — webhook auto-configure

Settings

Settings — General configuration


Features

  • Cascade deletion — one webhook triggers a full cleanup chain: Jellyfin → Radarr/Sonarr → torrent clients → Seerr
  • Multi-instance routing — every enabled Radarr, Sonarr, and torrent-client profile participates without numeric ID collisions
  • Strict ID matching — resolves items by TMDB/TVDB/IMDB ID and path; no fuzzy guessing
  • Conservative guardrails — pack torrents and files shared between items are never deleted; CleanArr logs the reason and skips
  • Confirmed preflight — enabled before every manual deletion; shows exact media IDs, Arr instance, torrent client/hash/path, downstream mutations, and safety skips
  • Durable background cleanup — manual jobs, partial results, and retry state survive process restarts and report live step-by-step progress
  • Idempotent execution — completed Jellyfin deliveries are suppressed for seven days, partial failures remain retryable, and one safety lock serializes all destructive work in a CleanArr instance
  • Live health monitoring — probes all connected services every 30 s; status visible on the dashboard
  • Webhook auto-configure — one-click setup of the Jellyfin Webhook plugin directly from the UI
  • Activity log — every processed event is stored with full action breakdown; searchable by title, system, action, or status
  • Guided setup wizard — first-run wizard walks you through connecting each service step by step
  • Multi-profile downloaders — save qBittorrent, Transmission, Deluge, and rTorrent profiles together; enabled profiles participate while one preferred profile is retained for setup and display
  • Downloads and cleanup recommendations — inspect bounded, normalized torrent observations and Jellyfin-based cleanup candidates without turning unknown data into deletion permission
  • Local and SSO authentication — local password login plus strict OpenID Connect validation, PKCE, nonce, and explicit user/group/claim access policies
  • Dark / light mode — follows system preference

Quick start

Docker Compose

git clone https://github.com/mambastick/Cleanarr.git
cd Cleanarr
# Start (review environment variables in the compose file first)
docker compose -f deploy/docker-compose.yml up -d

Open http://localhost:8089 — the setup wizard walks you through the rest.

Before an image upgrade, create and export a verified SQLite backup:

docker compose -f deploy/docker-compose.yml exec -T cleanarr python3 -c 'import sqlite3; source=sqlite3.connect("/config/cleanarr.db"); backup=sqlite3.connect("/config/cleanarr.pre-upgrade.db"); source.backup(backup); print(backup.execute("PRAGMA integrity_check").fetchone()[0]); backup.close(); source.close()'
docker compose -f deploy/docker-compose.yml cp \
cleanarr:/config/cleanarr.pre-upgrade.db ./cleanarr.pre-upgrade.db

The check must print ok. To roll back, pin the previous image, stop the service, copy the verified backup back to /config/cleanarr.db, and start it again. Keep the failed database under a different name until the restore is verified.

Docker (manual)

docker run -d \
--name cleanarr \
-p 8089:8089 \
-e DRY_RUN=true \
-v cleanarr-config:/config \
ghcr.io/mambastick/cleanarr:latest

Native Linux packages

Every release provides .deb and .rpm packages for amd64 and arm64. They install CleanArr under /opt/cleanarr, create a dedicated system user, and provide a hardened systemd service.

# Debian / Ubuntu
sudo apt install ./cleanarr_<version>_amd64.deb
# Fedora / RHEL-compatible distributions
sudo dnf install ./cleanarr-<version>-1.x86_64.rpm
sudo systemctl enable --now cleanarr

The default configuration is stored in /etc/cleanarr/cleanarr.env; application data is stored in /var/lib/cleanarr. Packages require systemd and Python 3.12. See the complete native package guide, including upgrade, removal, backup, and checksum instructions.

Kubernetes

kubectl apply -f deploy/k8s/namespace.yaml
kubectl apply -f deploy/k8s/pvc.yaml
# Edit secret.example.yaml with your values first
kubectl apply -f deploy/k8s/secret.example.yaml
kubectl apply -f deploy/k8s/deployment.yaml
kubectl apply -f deploy/k8s/service.yaml
kubectl apply -f deploy/k8s/ingress.yaml

The deployment uses strategy: Recreate because the config PVC is ReadWriteOnce.


Configuration

All settings can be changed at runtime from the Settings tab. Environment variables provide defaults on first start.

VariableDefaultDescription
DRY_RUNtrueSet to false to enable real deletions
LOG_LEVELINFODEBUG, INFO, WARNING, ERROR
HTTP_TIMEOUT_SECONDS15Timeout for calls to downstream services
DB_PATH/config/cleanarr.dbSQLite database path — must be on a persistent volume
CONFIG_STATE_PATH/config/runtime-config.jsonLegacy runtime-config migration path
ADMIN_SHARED_TOKENOptional static token that bypasses session auth (useful for automation)
WEBHOOK_SHARED_TOKENauto-generatedShared secret verified on every inbound webhook. Auto-generated on first start; rotate from Settings → General
UI_LANGUAGEenInitial UI language: en or ru
JELLYFIN_LANGUAGEenPreferred metadata language for Jellyfin integration
SSO_MODEpassword_onlyAuthentication mode: password_only, both, or sso_only
SSO_ENABLEDfalseEnables the OpenID Connect integration
SSO_ISSUER_URLOpenID Connect issuer URL
SSO_CLIENT_IDOpenID Connect client ID
SSO_CLIENT_SECRETOpenID Connect client secret
SSO_REDIRECT_URICallback URL, usually https://cleanarr.example/api/auth/sso/callback
SSO_SCOPESopenid profile emailOpenID Connect scopes
SSO_ALLOWED_USERSComma-separated usernames/emails/subjects allowed to sign in
SSO_ALLOWED_GROUPSComma-separated group values allowed to sign in
SSO_GROUP_CLAIMgroupsID-token claim containing group values
SSO_REQUIRED_CLAIMOptional additional ID-token claim required for access
SSO_REQUIRED_VALUERequired value; configure together with SSO_REQUIRED_CLAIM
SESSION_COOKIE_SECUREautoForce Secure on/off; set true when TLS terminates at a reverse proxy not trusted for forwarded headers

Important:DB_PATH must point to a persistent volume. Without it, all service configurations and activity history are lost on restart.

Existing jellyseerr profiles are migrated in place to the canonical seerr configuration on startup. The legacy JELLYSEERR_URL / JELLYSEERR_API_KEY variables and /api/config/jellyseerr routes remain backward-compatible aliases.

SSO remains disabled until at least one explicit user/group allowlist or a required claim/value pair is configured. See the complete OIDC and reverse proxy guide before enabling both or sso_only mode.

The downloader step can save several qBittorrent, Transmission, Deluge, and rTorrent profiles. A profile may be saved disabled for later setup; enabled and preferred/default are separate states. Test the exact current draft before treating it as ready: changing its client kind, URL, or credentials invalidates the frontend's saved connection-test fingerprint.


Jellyfin webhook setup

The easiest way is to use Auto-configure in the Jellyfin service editor (click the pencil icon on the Jellyfin card in the Dashboard). It installs the correct config into the Jellyfin Webhook plugin automatically.

Manual setup: install the Webhook plugin in Jellyfin → Dashboard → Plugins → Catalog, then add a Generic destination:

  • URL:http://your-cleanarr-host:8089/webhook/jellyfin
  • Method:POST
  • Header:X-Webhook-Token: <your-token>
  • Notification type:Item Deleted only
  • Template:
{
"notification_type": "{{json_encodeNotificationType}}",
"item_type": "{{json_encodeItemType}}",
"item_id": "{{json_encodeItemId}}",
"name": "{{json_encodeName}}",
"path": null,
"tmdb_id": {{#if_existProvider_tmdb}}{{Provider_tmdb}}{{else}}null{{/if_exist}},
"tvdb_id": {{#if_existProvider_tvdb}}{{Provider_tvdb}}{{else}}null{{/if_exist}},
"imdb_id": {{#if_existProvider_imdb}}"{{json_encodeProvider_imdb}}"{{else}}null{{/if_exist}},
"series_name": {{#if_existSeriesName}}"{{json_encodeSeriesName}}"{{else}}null{{/if_exist}},
"series_id": {{#if_existSeriesId}}"{{json_encodeSeriesId}}"{{else}}null{{/if_exist}},
"season_number": {{#if_existSeasonNumber}}{{SeasonNumber}}{{else}}null{{/if_exist}},
"episode_number": {{#if_existEpisodeNumber}}{{EpisodeNumber}}{{else}}null{{/if_exist}},
"episode_end_number": {{#if_existEpisodeNumberEnd}}{{EpisodeNumberEnd}}{{else}}null{{/if_exist}},
"occurred_at": "{{json_encodeUtcTimestamp}}"
}

How it works

Movie deletion

  1. Resolve in Radarr by tmdb_id → imdb_id → path (strict, no fuzzy matching)
  2. Collect torrent hashes from Radarr download history
  3. Delete safe hashes in every owning torrent client, optionally together with local data
  4. Delete the Radarr entry
  5. Delete matching Seerr requests, issues, and media records

Series deletion

  1. Resolve in Sonarr by tvdb_id → tmdb_id → imdb_id → path
  2. Delete torrent hashes exclusively owned by the series
  3. Delete the Sonarr series entry
  4. Delete all Seerr requests, issues, and media for the series

Season deletion

  1. Resolve parent series in Sonarr
  2. Unmonitor all episodes in the target season
  3. Delete only episode files and hashes fully covered by the season scope
  4. Update or remove matching Seerr season requests

Episode deletion

  1. Resolve parent series in Sonarr
  2. Unmonitor the target episode range
  3. Delete episode file and hash only when fully isolated
  4. Delete matching Seerr episode issues; retain a season-scoped request unless the event provably covers the complete season, then update or remove it

Guardrails: pack torrents (multiple series/seasons in one archive) and shared files are never deleted — CleanArr logs the reason and skips destructive actions.


API reference

MethodPathAuthDescription
POST/webhook/jellyfinX-Webhook-Token headerMain ingestion endpoint
GET/api/dashboardsessionDashboard snapshot for the SPA
GET/api/configsessionRuntime configuration
PUT/api/config/generalsessionUpdate general settings
GET/api/config/exportsessionExport a credential-free configuration document
POST/api/config/importsessionMerge a redacted configuration in fail-safe mode
GET/api/support/bundlesessionRedacted operational support snapshot
GET/metricssession or admin tokenPrivacy-safe Prometheus metrics
POST/api/config/jellyfin/setup-webhooksessionAuto-configure the Jellyfin Webhook plugin
POST/api/actions/delete/previewsessionMutation-free single-item deletion preview
POST / GET/api/actions/delete/jobssessionQueue or list hash-bound single deletion jobs
GET / DELETE/api/actions/delete/jobs/{job_id}sessionInspect or dismiss a terminal deletion job
POST/api/actions/delete/batches/previewsessionMutation-free item-level batch preview
POST / GET/api/actions/delete/batchessessionSubmit or list bounded hash-bound batches
GET/api/actions/delete/batches/{batch_id}sessionInspect batch and child outcomes
GET / POST/api/downloads, /api/downloads/refreshsessionBounded cursor read model and refresh
GET/api/downloads/{client_id}/{info_hash}sessionOne normalized torrent observation
POST/api/downloads/actionssessionReversible pause/resume only; idempotency required
GET/api/downloads/cleanup-candidatessessionBounded Jellyfin-based cleanup recommendations
POST/api/auth/loginAdmin login
GET/api/auth/statusCurrent authentication capabilities and session state
GET/api/auth/sso/loginStart the OpenID Connect login flow
GET/health/livenoneLiveness probe
GET/health/readynoneReadiness probe

Repository layout

cleanarr/
├── backend/ # Python 3.12 / FastAPI
│ └── src/cleanarr/
│ ├── api/ # Routes, schemas, dashboard, auth
│ ├── application/ # Cascade deletion logic, configuration service
│ ├── domain/ # Models, config, errors
│ └── infrastructure/ # HTTP clients, SQLite stores, settings
├── frontend/ # React 19 + Vite + TypeScript + shadcn/ui
│ └── src/
├── deploy/
│ ├── Dockerfile # Multi-stage build (node:24 → python:3.12-slim)
│ ├── docker-compose.yml
│ └── k8s/ # Kubernetes manifests
├── packaging/ # DEB/RPM metadata, systemd unit, build scripts
└── docs/
├── LINUX_PACKAGES.md # Native Linux package guide
├── OPERATIONS.md # Metrics, support bundle, config transfer
├── RELEASING.md # Bilingual release policy
└── screenshots/

Stack

LayerTechnology
BackendPython 3.12, FastAPI, httpx, Pydantic v2, uvicorn
FrontendReact 19, Vite, TypeScript, shadcn/ui, Tailwind CSS v4, Sonner, Motion
StorageSQLite (config + activity log)
ContainerMulti-stage Docker build — node:24-bookworm-slim → python:3.12-slim

Development

# Backendcd backend
python -m venv .venv &&source .venv/bin/activate
pip install -e ".[dev]"# Frontend (build static assets served by the backend)cd frontend
pnpm install && pnpm build
# Run backend with hot reloadcd backend
uvicorn cleanarr.api.app:app --host 0.0.0.0 --port 8089 --reload

For full hot-reload on both sides, run the frontend dev server in parallel — it proxies /api, /health, and /webhook to port 8089:

# Terminal 2cd frontend
pnpm dev

Tests

cd backend
ruff format --check src tests
ruff check src tests
mypy src
pytest -q
cd ../frontend
pnpm lint
pnpm test -- --run
pnpm build
pnpm exec playwright test --project=chromium

Release notes are maintained in both Russian and English. See Release process.


License

MIT

About

Cascade media cleanup — deletes from Radarr, Sonarr, Jellyseerr & qBittorrent when you remove from Jellyfin

Topics

Resources

Code of conduct

Contributing

Security policy

Stars

15 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content

Repository files navigation

CleanArr

English · Русский

Automatic cascade cleanup for your self-hosted media stack.
CleanArr listens for Jellyfin ItemDeleted webhooks and cascades deletion to Radarr, Sonarr, Seerr, and supported torrent clients — automatically, safely, and without touching files it doesn't own.

Quick start · Linux packages · Screenshots · How it works · Configuration · Torrent clients · Compatibility · Safety · Troubleshooting · Operations · Roadmap · Contributing

Python 3.12React 19License MITDockerLinux packages


What is CleanArr?

When you delete something in Jellyfin, you usually have to manually clean up the same item in Radarr, Sonarr, Seerr, and your torrent clients. CleanArr automates this entire chain:

  1. Jellyfin fires an ItemDeleted webhook
  2. CleanArr resolves the item in Radarr/Sonarr using strict ID matching (TMDB → IMDB → path)
  3. Torrent hashes are routed to qBittorrent, Transmission, Deluge, and rTorrent — only when Arr history proves ownership
  4. The entry is removed from Radarr/Sonarr
  5. Matching requests, issues, and media records are cleaned up in Seerr

Pack torrents, shared files, and anything that can't be safely attributed are always skipped.


Screenshots

Sign in

Sign in screen

Create admin account

First-run — create admin account

Setup wizard

Guided setup wizard — Jellyfin step

Dashboard

Dashboard — all services healthy, Live mode

Activity log

Activity log with deletion history

Jellyfin service modal

Jellyfin service editor — webhook auto-configure

Settings

Settings — General configuration


Features

  • Cascade deletion — one webhook triggers a full cleanup chain: Jellyfin → Radarr/Sonarr → torrent clients → Seerr
  • Multi-instance routing — every enabled Radarr, Sonarr, and torrent-client profile participates without numeric ID collisions
  • Strict ID matching — resolves items by TMDB/TVDB/IMDB ID and path; no fuzzy guessing
  • Conservative guardrails — pack torrents and files shared between items are never deleted; CleanArr logs the reason and skips
  • Confirmed preflight — enabled before every manual deletion; shows exact media IDs, Arr instance, torrent client/hash/path, downstream mutations, and safety skips
  • Durable background cleanup — manual jobs, partial results, and retry state survive process restarts and report live step-by-step progress
  • Idempotent execution — completed Jellyfin deliveries are suppressed for seven days, partial failures remain retryable, and one safety lock serializes all destructive work in a CleanArr instance
  • Live health monitoring — probes all connected services every 30 s; status visible on the dashboard
  • Webhook auto-configure — one-click setup of the Jellyfin Webhook plugin directly from the UI
  • Activity log — every processed event is stored with full action breakdown; searchable by title, system, action, or status
  • Guided setup wizard — first-run wizard walks you through connecting each service step by step
  • Multi-profile downloaders — save qBittorrent, Transmission, Deluge, and rTorrent profiles together; enabled profiles participate while one preferred profile is retained for setup and display
  • Downloads and cleanup recommendations — inspect bounded, normalized torrent observations and Jellyfin-based cleanup candidates without turning unknown data into deletion permission
  • Local and SSO authentication — local password login plus strict OpenID Connect validation, PKCE, nonce, and explicit user/group/claim access policies
  • Dark / light mode — follows system preference

Quick start

Docker Compose

git clone https://github.com/mambastick/Cleanarr.git
cd Cleanarr
# Start (review environment variables in the compose file first)
docker compose -f deploy/docker-compose.yml up -d

Open http://localhost:8089 — the setup wizard walks you through the rest.

Before an image upgrade, create and export a verified SQLite backup:

docker compose -f deploy/docker-compose.yml exec -T cleanarr python3 -c 'import sqlite3; source=sqlite3.connect("/config/cleanarr.db"); backup=sqlite3.connect("/config/cleanarr.pre-upgrade.db"); source.backup(backup); print(backup.execute("PRAGMA integrity_check").fetchone()[0]); backup.close(); source.close()'
docker compose -f deploy/docker-compose.yml cp \
cleanarr:/config/cleanarr.pre-upgrade.db ./cleanarr.pre-upgrade.db

The check must print ok. To roll back, pin the previous image, stop the service, copy the verified backup back to /config/cleanarr.db, and start it again. Keep the failed database under a different name until the restore is verified.

Docker (manual)

docker run -d \
--name cleanarr \
-p 8089:8089 \
-e DRY_RUN=true \
-v cleanarr-config:/config \
ghcr.io/mambastick/cleanarr:latest

Native Linux packages

Every release provides .deb and .rpm packages for amd64 and arm64. They install CleanArr under /opt/cleanarr, create a dedicated system user, and provide a hardened systemd service.

# Debian / Ubuntu
sudo apt install ./cleanarr_<version>_amd64.deb
# Fedora / RHEL-compatible distributions
sudo dnf install ./cleanarr-<version>-1.x86_64.rpm
sudo systemctl enable --now cleanarr

The default configuration is stored in /etc/cleanarr/cleanarr.env; application data is stored in /var/lib/cleanarr. Packages require systemd and Python 3.12. See the complete native package guide, including upgrade, removal, backup, and checksum instructions.

Kubernetes

kubectl apply -f deploy/k8s/namespace.yaml
kubectl apply -f deploy/k8s/pvc.yaml
# Edit secret.example.yaml with your values first
kubectl apply -f deploy/k8s/secret.example.yaml
kubectl apply -f deploy/k8s/deployment.yaml
kubectl apply -f deploy/k8s/service.yaml
kubectl apply -f deploy/k8s/ingress.yaml

The deployment uses strategy: Recreate because the config PVC is ReadWriteOnce.


Configuration

All settings can be changed at runtime from the Settings tab. Environment variables provide defaults on first start.

VariableDefaultDescription
DRY_RUNtrueSet to false to enable real deletions
LOG_LEVELINFODEBUG, INFO, WARNING, ERROR
HTTP_TIMEOUT_SECONDS15Timeout for calls to downstream services
DB_PATH/config/cleanarr.dbSQLite database path — must be on a persistent volume
CONFIG_STATE_PATH/config/runtime-config.jsonLegacy runtime-config migration path
ADMIN_SHARED_TOKENOptional static token that bypasses session auth (useful for automation)
WEBHOOK_SHARED_TOKENauto-generatedShared secret verified on every inbound webhook. Auto-generated on first start; rotate from Settings → General
UI_LANGUAGEenInitial UI language: en or ru
JELLYFIN_LANGUAGEenPreferred metadata language for Jellyfin integration
SSO_MODEpassword_onlyAuthentication mode: password_only, both, or sso_only
SSO_ENABLEDfalseEnables the OpenID Connect integration
SSO_ISSUER_URLOpenID Connect issuer URL
SSO_CLIENT_IDOpenID Connect client ID
SSO_CLIENT_SECRETOpenID Connect client secret
SSO_REDIRECT_URICallback URL, usually https://cleanarr.example/api/auth/sso/callback
SSO_SCOPESopenid profile emailOpenID Connect scopes
SSO_ALLOWED_USERSComma-separated usernames/emails/subjects allowed to sign in
SSO_ALLOWED_GROUPSComma-separated group values allowed to sign in
SSO_GROUP_CLAIMgroupsID-token claim containing group values
SSO_REQUIRED_CLAIMOptional additional ID-token claim required for access
SSO_REQUIRED_VALUERequired value; configure together with SSO_REQUIRED_CLAIM
SESSION_COOKIE_SECUREautoForce Secure on/off; set true when TLS terminates at a reverse proxy not trusted for forwarded headers

Important:DB_PATH must point to a persistent volume. Without it, all service configurations and activity history are lost on restart.

Existing jellyseerr profiles are migrated in place to the canonical seerr configuration on startup. The legacy JELLYSEERR_URL / JELLYSEERR_API_KEY variables and /api/config/jellyseerr routes remain backward-compatible aliases.

SSO remains disabled until at least one explicit user/group allowlist or a required claim/value pair is configured. See the complete OIDC and reverse proxy guide before enabling both or sso_only mode.

The downloader step can save several qBittorrent, Transmission, Deluge, and rTorrent profiles. A profile may be saved disabled for later setup; enabled and preferred/default are separate states. Test the exact current draft before treating it as ready: changing its client kind, URL, or credentials invalidates the frontend's saved connection-test fingerprint.


Jellyfin webhook setup

The easiest way is to use Auto-configure in the Jellyfin service editor (click the pencil icon on the Jellyfin card in the Dashboard). It installs the correct config into the Jellyfin Webhook plugin automatically.

Manual setup: install the Webhook plugin in Jellyfin → Dashboard → Plugins → Catalog, then add a Generic destination:

  • URL:http://your-cleanarr-host:8089/webhook/jellyfin
  • Method:POST
  • Header:X-Webhook-Token: <your-token>
  • Notification type:Item Deleted only
  • Template:
{
"notification_type": "{{json_encodeNotificationType}}",
"item_type": "{{json_encodeItemType}}",
"item_id": "{{json_encodeItemId}}",
"name": "{{json_encodeName}}",
"path": null,
"tmdb_id": {{#if_existProvider_tmdb}}{{Provider_tmdb}}{{else}}null{{/if_exist}},
"tvdb_id": {{#if_existProvider_tvdb}}{{Provider_tvdb}}{{else}}null{{/if_exist}},
"imdb_id": {{#if_existProvider_imdb}}"{{json_encodeProvider_imdb}}"{{else}}null{{/if_exist}},
"series_name": {{#if_existSeriesName}}"{{json_encodeSeriesName}}"{{else}}null{{/if_exist}},
"series_id": {{#if_existSeriesId}}"{{json_encodeSeriesId}}"{{else}}null{{/if_exist}},
"season_number": {{#if_existSeasonNumber}}{{SeasonNumber}}{{else}}null{{/if_exist}},
"episode_number": {{#if_existEpisodeNumber}}{{EpisodeNumber}}{{else}}null{{/if_exist}},
"episode_end_number": {{#if_existEpisodeNumberEnd}}{{EpisodeNumberEnd}}{{else}}null{{/if_exist}},
"occurred_at": "{{json_encodeUtcTimestamp}}"
}

How it works

Movie deletion

  1. Resolve in Radarr by tmdb_id → imdb_id → path (strict, no fuzzy matching)
  2. Collect torrent hashes from Radarr download history
  3. Delete safe hashes in every owning torrent client, optionally together with local data
  4. Delete the Radarr entry
  5. Delete matching Seerr requests, issues, and media records

Series deletion

  1. Resolve in Sonarr by tvdb_id → tmdb_id → imdb_id → path
  2. Delete torrent hashes exclusively owned by the series
  3. Delete the Sonarr series entry
  4. Delete all Seerr requests, issues, and media for the series

Season deletion

  1. Resolve parent series in Sonarr
  2. Unmonitor all episodes in the target season
  3. Delete only episode files and hashes fully covered by the season scope
  4. Update or remove matching Seerr season requests

Episode deletion

  1. Resolve parent series in Sonarr
  2. Unmonitor the target episode range
  3. Delete episode file and hash only when fully isolated
  4. Delete matching Seerr episode issues; retain a season-scoped request unless the event provably covers the complete season, then update or remove it

Guardrails: pack torrents (multiple series/seasons in one archive) and shared files are never deleted — CleanArr logs the reason and skips destructive actions.


API reference

MethodPathAuthDescription
POST/webhook/jellyfinX-Webhook-Token headerMain ingestion endpoint
GET/api/dashboardsessionDashboard snapshot for the SPA
GET/api/configsessionRuntime configuration
PUT/api/config/generalsessionUpdate general settings
GET/api/config/exportsessionExport a credential-free configuration document
POST/api/config/importsessionMerge a redacted configuration in fail-safe mode
GET/api/support/bundlesessionRedacted operational support snapshot
GET/metricssession or admin tokenPrivacy-safe Prometheus metrics
POST/api/config/jellyfin/setup-webhooksessionAuto-configure the Jellyfin Webhook plugin
POST/api/actions/delete/previewsessionMutation-free single-item deletion preview
POST / GET/api/actions/delete/jobssessionQueue or list hash-bound single deletion jobs
GET / DELETE/api/actions/delete/jobs/{job_id}sessionInspect or dismiss a terminal deletion job
POST/api/actions/delete/batches/previewsessionMutation-free item-level batch preview
POST / GET/api/actions/delete/batchessessionSubmit or list bounded hash-bound batches
GET/api/actions/delete/batches/{batch_id}sessionInspect batch and child outcomes
GET / POST/api/downloads, /api/downloads/refreshsessionBounded cursor read model and refresh
GET/api/downloads/{client_id}/{info_hash}sessionOne normalized torrent observation
POST/api/downloads/actionssessionReversible pause/resume only; idempotency required
GET/api/downloads/cleanup-candidatessessionBounded Jellyfin-based cleanup recommendations
POST/api/auth/loginAdmin login
GET/api/auth/statusCurrent authentication capabilities and session state
GET/api/auth/sso/loginStart the OpenID Connect login flow
GET/health/livenoneLiveness probe
GET/health/readynoneReadiness probe

Repository layout

cleanarr/
├── backend/ # Python 3.12 / FastAPI
│ └── src/cleanarr/
│ ├── api/ # Routes, schemas, dashboard, auth
│ ├── application/ # Cascade deletion logic, configuration service
│ ├── domain/ # Models, config, errors
│ └── infrastructure/ # HTTP clients, SQLite stores, settings
├── frontend/ # React 19 + Vite + TypeScript + shadcn/ui
│ └── src/
├── deploy/
│ ├── Dockerfile # Multi-stage build (node:24 → python:3.12-slim)
│ ├── docker-compose.yml
│ └── k8s/ # Kubernetes manifests
├── packaging/ # DEB/RPM metadata, systemd unit, build scripts
└── docs/
├── LINUX_PACKAGES.md # Native Linux package guide
├── OPERATIONS.md # Metrics, support bundle, config transfer
├── RELEASING.md # Bilingual release policy
└── screenshots/

Stack

LayerTechnology
BackendPython 3.12, FastAPI, httpx, Pydantic v2, uvicorn
FrontendReact 19, Vite, TypeScript, shadcn/ui, Tailwind CSS v4, Sonner, Motion
StorageSQLite (config + activity log)
ContainerMulti-stage Docker build — node:24-bookworm-slim → python:3.12-slim

Development

# Backendcd backend
python -m venv .venv &&source .venv/bin/activate
pip install -e ".[dev]"# Frontend (build static assets served by the backend)cd frontend
pnpm install && pnpm build
# Run backend with hot reloadcd backend
uvicorn cleanarr.api.app:app --host 0.0.0.0 --port 8089 --reload

For full hot-reload on both sides, run the frontend dev server in parallel — it proxies /api, /health, and /webhook to port 8089:

# Terminal 2cd frontend
pnpm dev

Tests

cd backend
ruff format --check src tests
ruff check src tests
mypy src
pytest -q
cd ../frontend
pnpm lint
pnpm test -- --run
pnpm build
pnpm exec playwright test --project=chromium

Release notes are maintained in both Russian and English. See Release process.


License

MIT

About

Cascade media cleanup — deletes from Radarr, Sonarr, Jellyseerr & qBittorrent when you remove from Jellyfin

Topics

Resources

Code of conduct

Contributing

Security policy

Stars

15 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Repository files navigation

CleanArr

English · Русский

Automatic cascade cleanup for your self-hosted media stack.
CleanArr listens for Jellyfin ItemDeleted webhooks and cascades deletion to Radarr, Sonarr, Seerr, and supported torrent clients — automatically, safely, and without touching files it doesn't own.

Quick start · Linux packages · Screenshots · How it works · Configuration · Torrent clients · Compatibility · Safety · Troubleshooting · Operations · Roadmap · Contributing

Python 3.12React 19License MITDockerLinux packages


What is CleanArr?

When you delete something in Jellyfin, you usually have to manually clean up the same item in Radarr, Sonarr, Seerr, and your torrent clients. CleanArr automates this entire chain:

  1. Jellyfin fires an ItemDeleted webhook
  2. CleanArr resolves the item in Radarr/Sonarr using strict ID matching (TMDB → IMDB → path)
  3. Torrent hashes are routed to qBittorrent, Transmission, Deluge, and rTorrent — only when Arr history proves ownership
  4. The entry is removed from Radarr/Sonarr
  5. Matching requests, issues, and media records are cleaned up in Seerr

Pack torrents, shared files, and anything that can't be safely attributed are always skipped.


Screenshots

Sign in

Sign in screen

Create admin account

First-run — create admin account

Setup wizard

Guided setup wizard — Jellyfin step

Dashboard

Dashboard — all services healthy, Live mode

Activity log

Activity log with deletion history

Jellyfin service modal

Jellyfin service editor — webhook auto-configure

Settings

Settings — General configuration


Features

  • Cascade deletion — one webhook triggers a full cleanup chain: Jellyfin → Radarr/Sonarr → torrent clients → Seerr
  • Multi-instance routing — every enabled Radarr, Sonarr, and torrent-client profile participates without numeric ID collisions
  • Strict ID matching — resolves items by TMDB/TVDB/IMDB ID and path; no fuzzy guessing
  • Conservative guardrails — pack torrents and files shared between items are never deleted; CleanArr logs the reason and skips
  • Confirmed preflight — enabled before every manual deletion; shows exact media IDs, Arr instance, torrent client/hash/path, downstream mutations, and safety skips
  • Durable background cleanup — manual jobs, partial results, and retry state survive process restarts and report live step-by-step progress
  • Idempotent execution — completed Jellyfin deliveries are suppressed for seven days, partial failures remain retryable, and one safety lock serializes all destructive work in a CleanArr instance
  • Live health monitoring — probes all connected services every 30 s; status visible on the dashboard
  • Webhook auto-configure — one-click setup of the Jellyfin Webhook plugin directly from the UI
  • Activity log — every processed event is stored with full action breakdown; searchable by title, system, action, or status
  • Guided setup wizard — first-run wizard walks you through connecting each service step by step
  • Multi-profile downloaders — save qBittorrent, Transmission, Deluge, and rTorrent profiles together; enabled profiles participate while one preferred profile is retained for setup and display
  • Downloads and cleanup recommendations — inspect bounded, normalized torrent observations and Jellyfin-based cleanup candidates without turning unknown data into deletion permission
  • Local and SSO authentication — local password login plus strict OpenID Connect validation, PKCE, nonce, and explicit user/group/claim access policies
  • Dark / light mode — follows system preference

Quick start

Docker Compose

git clone https://github.com/mambastick/Cleanarr.git
cd Cleanarr
# Start (review environment variables in the compose file first)
docker compose -f deploy/docker-compose.yml up -d

Open http://localhost:8089 — the setup wizard walks you through the rest.

Before an image upgrade, create and export a verified SQLite backup:

docker compose -f deploy/docker-compose.yml exec -T cleanarr python3 -c 'import sqlite3; source=sqlite3.connect("/config/cleanarr.db"); backup=sqlite3.connect("/config/cleanarr.pre-upgrade.db"); source.backup(backup); print(backup.execute("PRAGMA integrity_check").fetchone()[0]); backup.close(); source.close()'
docker compose -f deploy/docker-compose.yml cp \
cleanarr:/config/cleanarr.pre-upgrade.db ./cleanarr.pre-upgrade.db

The check must print ok. To roll back, pin the previous image, stop the service, copy the verified backup back to /config/cleanarr.db, and start it again. Keep the failed database under a different name until the restore is verified.

Docker (manual)

docker run -d \
--name cleanarr \
-p 8089:8089 \
-e DRY_RUN=true \
-v cleanarr-config:/config \
ghcr.io/mambastick/cleanarr:latest

Native Linux packages

Every release provides .deb and .rpm packages for amd64 and arm64. They install CleanArr under /opt/cleanarr, create a dedicated system user, and provide a hardened systemd service.

# Debian / Ubuntu
sudo apt install ./cleanarr_<version>_amd64.deb
# Fedora / RHEL-compatible distributions
sudo dnf install ./cleanarr-<version>-1.x86_64.rpm
sudo systemctl enable --now cleanarr

The default configuration is stored in /etc/cleanarr/cleanarr.env; application data is stored in /var/lib/cleanarr. Packages require systemd and Python 3.12. See the complete native package guide, including upgrade, removal, backup, and checksum instructions.

Kubernetes

kubectl apply -f deploy/k8s/namespace.yaml
kubectl apply -f deploy/k8s/pvc.yaml
# Edit secret.example.yaml with your values first
kubectl apply -f deploy/k8s/secret.example.yaml
kubectl apply -f deploy/k8s/deployment.yaml
kubectl apply -f deploy/k8s/service.yaml
kubectl apply -f deploy/k8s/ingress.yaml

The deployment uses strategy: Recreate because the config PVC is ReadWriteOnce.


Configuration

All settings can be changed at runtime from the Settings tab. Environment variables provide defaults on first start.

VariableDefaultDescription
DRY_RUNtrueSet to false to enable real deletions
LOG_LEVELINFODEBUG, INFO, WARNING, ERROR
HTTP_TIMEOUT_SECONDS15Timeout for calls to downstream services
DB_PATH/config/cleanarr.dbSQLite database path — must be on a persistent volume
CONFIG_STATE_PATH/config/runtime-config.jsonLegacy runtime-config migration path
ADMIN_SHARED_TOKENOptional static token that bypasses session auth (useful for automation)
WEBHOOK_SHARED_TOKENauto-generatedShared secret verified on every inbound webhook. Auto-generated on first start; rotate from Settings → General
UI_LANGUAGEenInitial UI language: en or ru
JELLYFIN_LANGUAGEenPreferred metadata language for Jellyfin integration
SSO_MODEpassword_onlyAuthentication mode: password_only, both, or sso_only
SSO_ENABLEDfalseEnables the OpenID Connect integration
SSO_ISSUER_URLOpenID Connect issuer URL
SSO_CLIENT_IDOpenID Connect client ID
SSO_CLIENT_SECRETOpenID Connect client secret
SSO_REDIRECT_URICallback URL, usually https://cleanarr.example/api/auth/sso/callback
SSO_SCOPESopenid profile emailOpenID Connect scopes
SSO_ALLOWED_USERSComma-separated usernames/emails/subjects allowed to sign in
SSO_ALLOWED_GROUPSComma-separated group values allowed to sign in
SSO_GROUP_CLAIMgroupsID-token claim containing group values
SSO_REQUIRED_CLAIMOptional additional ID-token claim required for access
SSO_REQUIRED_VALUERequired value; configure together with SSO_REQUIRED_CLAIM
SESSION_COOKIE_SECUREautoForce Secure on/off; set true when TLS terminates at a reverse proxy not trusted for forwarded headers

Important:DB_PATH must point to a persistent volume. Without it, all service configurations and activity history are lost on restart.

Existing jellyseerr profiles are migrated in place to the canonical seerr configuration on startup. The legacy JELLYSEERR_URL / JELLYSEERR_API_KEY variables and /api/config/jellyseerr routes remain backward-compatible aliases.

SSO remains disabled until at least one explicit user/group allowlist or a required claim/value pair is configured. See the complete OIDC and reverse proxy guide before enabling both or sso_only mode.

The downloader step can save several qBittorrent, Transmission, Deluge, and rTorrent profiles. A profile may be saved disabled for later setup; enabled and preferred/default are separate states. Test the exact current draft before treating it as ready: changing its client kind, URL, or credentials invalidates the frontend's saved connection-test fingerprint.


Jellyfin webhook setup

The easiest way is to use Auto-configure in the Jellyfin service editor (click the pencil icon on the Jellyfin card in the Dashboard). It installs the correct config into the Jellyfin Webhook plugin automatically.

Manual setup: install the Webhook plugin in Jellyfin → Dashboard → Plugins → Catalog, then add a Generic destination:

  • URL:http://your-cleanarr-host:8089/webhook/jellyfin
  • Method:POST
  • Header:X-Webhook-Token: <your-token>
  • Notification type:Item Deleted only
  • Template:
{
"notification_type": "{{json_encodeNotificationType}}",
"item_type": "{{json_encodeItemType}}",
"item_id": "{{json_encodeItemId}}",
"name": "{{json_encodeName}}",
"path": null,
"tmdb_id": {{#if_existProvider_tmdb}}{{Provider_tmdb}}{{else}}null{{/if_exist}},
"tvdb_id": {{#if_existProvider_tvdb}}{{Provider_tvdb}}{{else}}null{{/if_exist}},
"imdb_id": {{#if_existProvider_imdb}}"{{json_encodeProvider_imdb}}"{{else}}null{{/if_exist}},
"series_name": {{#if_existSeriesName}}"{{json_encodeSeriesName}}"{{else}}null{{/if_exist}},
"series_id": {{#if_existSeriesId}}"{{json_encodeSeriesId}}"{{else}}null{{/if_exist}},
"season_number": {{#if_existSeasonNumber}}{{SeasonNumber}}{{else}}null{{/if_exist}},
"episode_number": {{#if_existEpisodeNumber}}{{EpisodeNumber}}{{else}}null{{/if_exist}},
"episode_end_number": {{#if_existEpisodeNumberEnd}}{{EpisodeNumberEnd}}{{else}}null{{/if_exist}},
"occurred_at": "{{json_encodeUtcTimestamp}}"
}

How it works

Movie deletion

  1. Resolve in Radarr by tmdb_id → imdb_id → path (strict, no fuzzy matching)
  2. Collect torrent hashes from Radarr download history
  3. Delete safe hashes in every owning torrent client, optionally together with local data
  4. Delete the Radarr entry
  5. Delete matching Seerr requests, issues, and media records

Series deletion

  1. Resolve in Sonarr by tvdb_id → tmdb_id → imdb_id → path
  2. Delete torrent hashes exclusively owned by the series
  3. Delete the Sonarr series entry
  4. Delete all Seerr requests, issues, and media for the series

Season deletion

  1. Resolve parent series in Sonarr
  2. Unmonitor all episodes in the target season
  3. Delete only episode files and hashes fully covered by the season scope
  4. Update or remove matching Seerr season requests

Episode deletion

  1. Resolve parent series in Sonarr
  2. Unmonitor the target episode range
  3. Delete episode file and hash only when fully isolated
  4. Delete matching Seerr episode issues; retain a season-scoped request unless the event provably covers the complete season, then update or remove it

Guardrails: pack torrents (multiple series/seasons in one archive) and shared files are never deleted — CleanArr logs the reason and skips destructive actions.


API reference

MethodPathAuthDescription
POST/webhook/jellyfinX-Webhook-Token headerMain ingestion endpoint
GET/api/dashboardsessionDashboard snapshot for the SPA
GET/api/configsessionRuntime configuration
PUT/api/config/generalsessionUpdate general settings
GET/api/config/exportsessionExport a credential-free configuration document
POST/api/config/importsessionMerge a redacted configuration in fail-safe mode
GET/api/support/bundlesessionRedacted operational support snapshot
GET/metricssession or admin tokenPrivacy-safe Prometheus metrics
POST/api/config/jellyfin/setup-webhooksessionAuto-configure the Jellyfin Webhook plugin
POST/api/actions/delete/previewsessionMutation-free single-item deletion preview
POST / GET/api/actions/delete/jobssessionQueue or list hash-bound single deletion jobs
GET / DELETE/api/actions/delete/jobs/{job_id}sessionInspect or dismiss a terminal deletion job
POST/api/actions/delete/batches/previewsessionMutation-free item-level batch preview
POST / GET/api/actions/delete/batchessessionSubmit or list bounded hash-bound batches
GET/api/actions/delete/batches/{batch_id}sessionInspect batch and child outcomes
GET / POST/api/downloads, /api/downloads/refreshsessionBounded cursor read model and refresh
GET/api/downloads/{client_id}/{info_hash}sessionOne normalized torrent observation
POST/api/downloads/actionssessionReversible pause/resume only; idempotency required
GET/api/downloads/cleanup-candidatessessionBounded Jellyfin-based cleanup recommendations
POST/api/auth/loginAdmin login
GET/api/auth/statusCurrent authentication capabilities and session state
GET/api/auth/sso/loginStart the OpenID Connect login flow
GET/health/livenoneLiveness probe
GET/health/readynoneReadiness probe

Repository layout

cleanarr/
├── backend/ # Python 3.12 / FastAPI
│ └── src/cleanarr/
│ ├── api/ # Routes, schemas, dashboard, auth
│ ├── application/ # Cascade deletion logic, configuration service
│ ├── domain/ # Models, config, errors
│ └── infrastructure/ # HTTP clients, SQLite stores, settings
├── frontend/ # React 19 + Vite + TypeScript + shadcn/ui
│ └── src/
├── deploy/
│ ├── Dockerfile # Multi-stage build (node:24 → python:3.12-slim)
│ ├── docker-compose.yml
│ └── k8s/ # Kubernetes manifests
├── packaging/ # DEB/RPM metadata, systemd unit, build scripts
└── docs/
├── LINUX_PACKAGES.md # Native Linux package guide
├── OPERATIONS.md # Metrics, support bundle, config transfer
├── RELEASING.md # Bilingual release policy
└── screenshots/

Stack

LayerTechnology
BackendPython 3.12, FastAPI, httpx, Pydantic v2, uvicorn
FrontendReact 19, Vite, TypeScript, shadcn/ui, Tailwind CSS v4, Sonner, Motion
StorageSQLite (config + activity log)
ContainerMulti-stage Docker build — node:24-bookworm-slim → python:3.12-slim

Development

# Backendcd backend
python -m venv .venv &&source .venv/bin/activate
pip install -e ".[dev]"# Frontend (build static assets served by the backend)cd frontend
pnpm install && pnpm build
# Run backend with hot reloadcd backend
uvicorn cleanarr.api.app:app --host 0.0.0.0 --port 8089 --reload

For full hot-reload on both sides, run the frontend dev server in parallel — it proxies /api, /health, and /webhook to port 8089:

# Terminal 2cd frontend
pnpm dev

Tests

cd backend
ruff format --check src tests
ruff check src tests
mypy src
pytest -q
cd ../frontend
pnpm lint
pnpm test -- --run
pnpm build
pnpm exec playwright test --project=chromium

Release notes are maintained in both Russian and English. See Release process.


License

MIT

About

Cascade media cleanup — deletes from Radarr, Sonarr, Jellyseerr & qBittorrent when you remove from Jellyfin

Topics

Resources

Code of conduct

Contributing

Security policy

Stars

15 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Repository files navigation

CleanArr

English · Русский

Automatic cascade cleanup for your self-hosted media stack.
CleanArr listens for Jellyfin ItemDeleted webhooks and cascades deletion to Radarr, Sonarr, Seerr, and supported torrent clients — automatically, safely, and without touching files it doesn't own.

Quick start · Linux packages · Screenshots · How it works · Configuration · Torrent clients · Compatibility · Safety · Troubleshooting · Operations · Roadmap · Contributing

Python 3.12React 19License MITDockerLinux packages


What is CleanArr?

When you delete something in Jellyfin, you usually have to manually clean up the same item in Radarr, Sonarr, Seerr, and your torrent clients. CleanArr automates this entire chain:

  1. Jellyfin fires an ItemDeleted webhook
  2. CleanArr resolves the item in Radarr/Sonarr using strict ID matching (TMDB → IMDB → path)
  3. Torrent hashes are routed to qBittorrent, Transmission, Deluge, and rTorrent — only when Arr history proves ownership
  4. The entry is removed from Radarr/Sonarr
  5. Matching requests, issues, and media records are cleaned up in Seerr

Pack torrents, shared files, and anything that can't be safely attributed are always skipped.


Screenshots

Sign in

Sign in screen

Create admin account

First-run — create admin account

Setup wizard

Guided setup wizard — Jellyfin step

Dashboard

Dashboard — all services healthy, Live mode

Activity log

Activity log with deletion history

Jellyfin service modal

Jellyfin service editor — webhook auto-configure

Settings

Settings — General configuration


Features

  • Cascade deletion — one webhook triggers a full cleanup chain: Jellyfin → Radarr/Sonarr → torrent clients → Seerr
  • Multi-instance routing — every enabled Radarr, Sonarr, and torrent-client profile participates without numeric ID collisions
  • Strict ID matching — resolves items by TMDB/TVDB/IMDB ID and path; no fuzzy guessing
  • Conservative guardrails — pack torrents and files shared between items are never deleted; CleanArr logs the reason and skips
  • Confirmed preflight — enabled before every manual deletion; shows exact media IDs, Arr instance, torrent client/hash/path, downstream mutations, and safety skips
  • Durable background cleanup — manual jobs, partial results, and retry state survive process restarts and report live step-by-step progress
  • Idempotent execution — completed Jellyfin deliveries are suppressed for seven days, partial failures remain retryable, and one safety lock serializes all destructive work in a CleanArr instance
  • Live health monitoring — probes all connected services every 30 s; status visible on the dashboard
  • Webhook auto-configure — one-click setup of the Jellyfin Webhook plugin directly from the UI
  • Activity log — every processed event is stored with full action breakdown; searchable by title, system, action, or status
  • Guided setup wizard — first-run wizard walks you through connecting each service step by step
  • Multi-profile downloaders — save qBittorrent, Transmission, Deluge, and rTorrent profiles together; enabled profiles participate while one preferred profile is retained for setup and display
  • Downloads and cleanup recommendations — inspect bounded, normalized torrent observations and Jellyfin-based cleanup candidates without turning unknown data into deletion permission
  • Local and SSO authentication — local password login plus strict OpenID Connect validation, PKCE, nonce, and explicit user/group/claim access policies
  • Dark / light mode — follows system preference

Quick start

Docker Compose

git clone https://github.com/mambastick/Cleanarr.git
cd Cleanarr
# Start (review environment variables in the compose file first)
docker compose -f deploy/docker-compose.yml up -d

Open http://localhost:8089 — the setup wizard walks you through the rest.

Before an image upgrade, create and export a verified SQLite backup:

docker compose -f deploy/docker-compose.yml exec -T cleanarr python3 -c 'import sqlite3; source=sqlite3.connect("/config/cleanarr.db"); backup=sqlite3.connect("/config/cleanarr.pre-upgrade.db"); source.backup(backup); print(backup.execute("PRAGMA integrity_check").fetchone()[0]); backup.close(); source.close()'
docker compose -f deploy/docker-compose.yml cp \
cleanarr:/config/cleanarr.pre-upgrade.db ./cleanarr.pre-upgrade.db

The check must print ok. To roll back, pin the previous image, stop the service, copy the verified backup back to /config/cleanarr.db, and start it again. Keep the failed database under a different name until the restore is verified.

Docker (manual)

docker run -d \
--name cleanarr \
-p 8089:8089 \
-e DRY_RUN=true \
-v cleanarr-config:/config \
ghcr.io/mambastick/cleanarr:latest

Native Linux packages

Every release provides .deb and .rpm packages for amd64 and arm64. They install CleanArr under /opt/cleanarr, create a dedicated system user, and provide a hardened systemd service.

# Debian / Ubuntu
sudo apt install ./cleanarr_<version>_amd64.deb
# Fedora / RHEL-compatible distributions
sudo dnf install ./cleanarr-<version>-1.x86_64.rpm
sudo systemctl enable --now cleanarr

The default configuration is stored in /etc/cleanarr/cleanarr.env; application data is stored in /var/lib/cleanarr. Packages require systemd and Python 3.12. See the complete native package guide, including upgrade, removal, backup, and checksum instructions.

Kubernetes

kubectl apply -f deploy/k8s/namespace.yaml
kubectl apply -f deploy/k8s/pvc.yaml
# Edit secret.example.yaml with your values first
kubectl apply -f deploy/k8s/secret.example.yaml
kubectl apply -f deploy/k8s/deployment.yaml
kubectl apply -f deploy/k8s/service.yaml
kubectl apply -f deploy/k8s/ingress.yaml

The deployment uses strategy: Recreate because the config PVC is ReadWriteOnce.


Configuration

All settings can be changed at runtime from the Settings tab. Environment variables provide defaults on first start.

VariableDefaultDescription
DRY_RUNtrueSet to false to enable real deletions
LOG_LEVELINFODEBUG, INFO, WARNING, ERROR
HTTP_TIMEOUT_SECONDS15Timeout for calls to downstream services
DB_PATH/config/cleanarr.dbSQLite database path — must be on a persistent volume
CONFIG_STATE_PATH/config/runtime-config.jsonLegacy runtime-config migration path
ADMIN_SHARED_TOKENOptional static token that bypasses session auth (useful for automation)
WEBHOOK_SHARED_TOKENauto-generatedShared secret verified on every inbound webhook. Auto-generated on first start; rotate from Settings → General
UI_LANGUAGEenInitial UI language: en or ru
JELLYFIN_LANGUAGEenPreferred metadata language for Jellyfin integration
SSO_MODEpassword_onlyAuthentication mode: password_only, both, or sso_only
SSO_ENABLEDfalseEnables the OpenID Connect integration
SSO_ISSUER_URLOpenID Connect issuer URL
SSO_CLIENT_IDOpenID Connect client ID
SSO_CLIENT_SECRETOpenID Connect client secret
SSO_REDIRECT_URICallback URL, usually https://cleanarr.example/api/auth/sso/callback
SSO_SCOPESopenid profile emailOpenID Connect scopes
SSO_ALLOWED_USERSComma-separated usernames/emails/subjects allowed to sign in
SSO_ALLOWED_GROUPSComma-separated group values allowed to sign in
SSO_GROUP_CLAIMgroupsID-token claim containing group values
SSO_REQUIRED_CLAIMOptional additional ID-token claim required for access
SSO_REQUIRED_VALUERequired value; configure together with SSO_REQUIRED_CLAIM
SESSION_COOKIE_SECUREautoForce Secure on/off; set true when TLS terminates at a reverse proxy not trusted for forwarded headers

Important:DB_PATH must point to a persistent volume. Without it, all service configurations and activity history are lost on restart.

Existing jellyseerr profiles are migrated in place to the canonical seerr configuration on startup. The legacy JELLYSEERR_URL / JELLYSEERR_API_KEY variables and /api/config/jellyseerr routes remain backward-compatible aliases.

SSO remains disabled until at least one explicit user/group allowlist or a required claim/value pair is configured. See the complete OIDC and reverse proxy guide before enabling both or sso_only mode.

The downloader step can save several qBittorrent, Transmission, Deluge, and rTorrent profiles. A profile may be saved disabled for later setup; enabled and preferred/default are separate states. Test the exact current draft before treating it as ready: changing its client kind, URL, or credentials invalidates the frontend's saved connection-test fingerprint.


Jellyfin webhook setup

The easiest way is to use Auto-configure in the Jellyfin service editor (click the pencil icon on the Jellyfin card in the Dashboard). It installs the correct config into the Jellyfin Webhook plugin automatically.

Manual setup: install the Webhook plugin in Jellyfin → Dashboard → Plugins → Catalog, then add a Generic destination:

  • URL:http://your-cleanarr-host:8089/webhook/jellyfin
  • Method:POST
  • Header:X-Webhook-Token: <your-token>
  • Notification type:Item Deleted only
  • Template:
{
"notification_type": "{{json_encodeNotificationType}}",
"item_type": "{{json_encodeItemType}}",
"item_id": "{{json_encodeItemId}}",
"name": "{{json_encodeName}}",
"path": null,
"tmdb_id": {{#if_existProvider_tmdb}}{{Provider_tmdb}}{{else}}null{{/if_exist}},
"tvdb_id": {{#if_existProvider_tvdb}}{{Provider_tvdb}}{{else}}null{{/if_exist}},
"imdb_id": {{#if_existProvider_imdb}}"{{json_encodeProvider_imdb}}"{{else}}null{{/if_exist}},
"series_name": {{#if_existSeriesName}}"{{json_encodeSeriesName}}"{{else}}null{{/if_exist}},
"series_id": {{#if_existSeriesId}}"{{json_encodeSeriesId}}"{{else}}null{{/if_exist}},
"season_number": {{#if_existSeasonNumber}}{{SeasonNumber}}{{else}}null{{/if_exist}},
"episode_number": {{#if_existEpisodeNumber}}{{EpisodeNumber}}{{else}}null{{/if_exist}},
"episode_end_number": {{#if_existEpisodeNumberEnd}}{{EpisodeNumberEnd}}{{else}}null{{/if_exist}},
"occurred_at": "{{json_encodeUtcTimestamp}}"
}

How it works

Movie deletion

  1. Resolve in Radarr by tmdb_id → imdb_id → path (strict, no fuzzy matching)
  2. Collect torrent hashes from Radarr download history
  3. Delete safe hashes in every owning torrent client, optionally together with local data
  4. Delete the Radarr entry
  5. Delete matching Seerr requests, issues, and media records

Series deletion

  1. Resolve in Sonarr by tvdb_id → tmdb_id → imdb_id → path
  2. Delete torrent hashes exclusively owned by the series
  3. Delete the Sonarr series entry
  4. Delete all Seerr requests, issues, and media for the series

Season deletion

  1. Resolve parent series in Sonarr
  2. Unmonitor all episodes in the target season
  3. Delete only episode files and hashes fully covered by the season scope
  4. Update or remove matching Seerr season requests

Episode deletion

  1. Resolve parent series in Sonarr
  2. Unmonitor the target episode range
  3. Delete episode file and hash only when fully isolated
  4. Delete matching Seerr episode issues; retain a season-scoped request unless the event provably covers the complete season, then update or remove it

Guardrails: pack torrents (multiple series/seasons in one archive) and shared files are never deleted — CleanArr logs the reason and skips destructive actions.


API reference

MethodPathAuthDescription
POST/webhook/jellyfinX-Webhook-Token headerMain ingestion endpoint
GET/api/dashboardsessionDashboard snapshot for the SPA
GET/api/configsessionRuntime configuration
PUT/api/config/generalsessionUpdate general settings
GET/api/config/exportsessionExport a credential-free configuration document
POST/api/config/importsessionMerge a redacted configuration in fail-safe mode
GET/api/support/bundlesessionRedacted operational support snapshot
GET/metricssession or admin tokenPrivacy-safe Prometheus metrics
POST/api/config/jellyfin/setup-webhooksessionAuto-configure the Jellyfin Webhook plugin
POST/api/actions/delete/previewsessionMutation-free single-item deletion preview
POST / GET/api/actions/delete/jobssessionQueue or list hash-bound single deletion jobs
GET / DELETE/api/actions/delete/jobs/{job_id}sessionInspect or dismiss a terminal deletion job
POST/api/actions/delete/batches/previewsessionMutation-free item-level batch preview
POST / GET/api/actions/delete/batchessessionSubmit or list bounded hash-bound batches
GET/api/actions/delete/batches/{batch_id}sessionInspect batch and child outcomes
GET / POST/api/downloads, /api/downloads/refreshsessionBounded cursor read model and refresh
GET/api/downloads/{client_id}/{info_hash}sessionOne normalized torrent observation
POST/api/downloads/actionssessionReversible pause/resume only; idempotency required
GET/api/downloads/cleanup-candidatessessionBounded Jellyfin-based cleanup recommendations
POST/api/auth/loginAdmin login
GET/api/auth/statusCurrent authentication capabilities and session state
GET/api/auth/sso/loginStart the OpenID Connect login flow
GET/health/livenoneLiveness probe
GET/health/readynoneReadiness probe

Repository layout

cleanarr/
├── backend/ # Python 3.12 / FastAPI
│ └── src/cleanarr/
│ ├── api/ # Routes, schemas, dashboard, auth
│ ├── application/ # Cascade deletion logic, configuration service
│ ├── domain/ # Models, config, errors
│ └── infrastructure/ # HTTP clients, SQLite stores, settings
├── frontend/ # React 19 + Vite + TypeScript + shadcn/ui
│ └── src/
├── deploy/
│ ├── Dockerfile # Multi-stage build (node:24 → python:3.12-slim)
│ ├── docker-compose.yml
│ └── k8s/ # Kubernetes manifests
├── packaging/ # DEB/RPM metadata, systemd unit, build scripts
└── docs/
├── LINUX_PACKAGES.md # Native Linux package guide
├── OPERATIONS.md # Metrics, support bundle, config transfer
├── RELEASING.md # Bilingual release policy
└── screenshots/

Stack

LayerTechnology
BackendPython 3.12, FastAPI, httpx, Pydantic v2, uvicorn
FrontendReact 19, Vite, TypeScript, shadcn/ui, Tailwind CSS v4, Sonner, Motion
StorageSQLite (config + activity log)
ContainerMulti-stage Docker build — node:24-bookworm-slim → python:3.12-slim

Development

# Backendcd backend
python -m venv .venv &&source .venv/bin/activate
pip install -e ".[dev]"# Frontend (build static assets served by the backend)cd frontend
pnpm install && pnpm build
# Run backend with hot reloadcd backend
uvicorn cleanarr.api.app:app --host 0.0.0.0 --port 8089 --reload

For full hot-reload on both sides, run the frontend dev server in parallel — it proxies /api, /health, and /webhook to port 8089:

# Terminal 2cd frontend
pnpm dev

Tests

cd backend
ruff format --check src tests
ruff check src tests
mypy src
pytest -q
cd ../frontend
pnpm lint
pnpm test -- --run
pnpm build
pnpm exec playwright test --project=chromium

Release notes are maintained in both Russian and English. See Release process.


License

MIT

About

Cascade media cleanup — deletes from Radarr, Sonarr, Jellyseerr & qBittorrent when you remove from Jellyfin

Topics

Resources

Code of conduct

Contributing

Security policy

Stars

15 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content

Repository files navigation

CleanArr

English · Русский

Automatic cascade cleanup for your self-hosted media stack.
CleanArr listens for Jellyfin ItemDeleted webhooks and cascades deletion to Radarr, Sonarr, Seerr, and supported torrent clients — automatically, safely, and without touching files it doesn't own.

Quick start · Linux packages · Screenshots · How it works · Configuration · Torrent clients · Compatibility · Safety · Troubleshooting · Operations · Roadmap · Contributing

Python 3.12React 19License MITDockerLinux packages


What is CleanArr?

When you delete something in Jellyfin, you usually have to manually clean up the same item in Radarr, Sonarr, Seerr, and your torrent clients. CleanArr automates this entire chain:

  1. Jellyfin fires an ItemDeleted webhook
  2. CleanArr resolves the item in Radarr/Sonarr using strict ID matching (TMDB → IMDB → path)
  3. Torrent hashes are routed to qBittorrent, Transmission, Deluge, and rTorrent — only when Arr history proves ownership
  4. The entry is removed from Radarr/Sonarr
  5. Matching requests, issues, and media records are cleaned up in Seerr

Pack torrents, shared files, and anything that can't be safely attributed are always skipped.


Screenshots

Sign in

Sign in screen

Create admin account

First-run — create admin account

Setup wizard

Guided setup wizard — Jellyfin step

Dashboard

Dashboard — all services healthy, Live mode

Activity log

Activity log with deletion history

Jellyfin service modal

Jellyfin service editor — webhook auto-configure

Settings

Settings — General configuration


Features

  • Cascade deletion — one webhook triggers a full cleanup chain: Jellyfin → Radarr/Sonarr → torrent clients → Seerr
  • Multi-instance routing — every enabled Radarr, Sonarr, and torrent-client profile participates without numeric ID collisions
  • Strict ID matching — resolves items by TMDB/TVDB/IMDB ID and path; no fuzzy guessing
  • Conservative guardrails — pack torrents and files shared between items are never deleted; CleanArr logs the reason and skips
  • Confirmed preflight — enabled before every manual deletion; shows exact media IDs, Arr instance, torrent client/hash/path, downstream mutations, and safety skips
  • Durable background cleanup — manual jobs, partial results, and retry state survive process restarts and report live step-by-step progress
  • Idempotent execution — completed Jellyfin deliveries are suppressed for seven days, partial failures remain retryable, and one safety lock serializes all destructive work in a CleanArr instance
  • Live health monitoring — probes all connected services every 30 s; status visible on the dashboard
  • Webhook auto-configure — one-click setup of the Jellyfin Webhook plugin directly from the UI
  • Activity log — every processed event is stored with full action breakdown; searchable by title, system, action, or status
  • Guided setup wizard — first-run wizard walks you through connecting each service step by step
  • Multi-profile downloaders — save qBittorrent, Transmission, Deluge, and rTorrent profiles together; enabled profiles participate while one preferred profile is retained for setup and display
  • Downloads and cleanup recommendations — inspect bounded, normalized torrent observations and Jellyfin-based cleanup candidates without turning unknown data into deletion permission
  • Local and SSO authentication — local password login plus strict OpenID Connect validation, PKCE, nonce, and explicit user/group/claim access policies
  • Dark / light mode — follows system preference

Quick start

Docker Compose

git clone https://github.com/mambastick/Cleanarr.git
cd Cleanarr
# Start (review environment variables in the compose file first)
docker compose -f deploy/docker-compose.yml up -d

Open http://localhost:8089 — the setup wizard walks you through the rest.

Before an image upgrade, create and export a verified SQLite backup:

docker compose -f deploy/docker-compose.yml exec -T cleanarr python3 -c 'import sqlite3; source=sqlite3.connect("/config/cleanarr.db"); backup=sqlite3.connect("/config/cleanarr.pre-upgrade.db"); source.backup(backup); print(backup.execute("PRAGMA integrity_check").fetchone()[0]); backup.close(); source.close()'
docker compose -f deploy/docker-compose.yml cp \
cleanarr:/config/cleanarr.pre-upgrade.db ./cleanarr.pre-upgrade.db

The check must print ok. To roll back, pin the previous image, stop the service, copy the verified backup back to /config/cleanarr.db, and start it again. Keep the failed database under a different name until the restore is verified.

Docker (manual)

docker run -d \
--name cleanarr \
-p 8089:8089 \
-e DRY_RUN=true \
-v cleanarr-config:/config \
ghcr.io/mambastick/cleanarr:latest

Native Linux packages

Every release provides .deb and .rpm packages for amd64 and arm64. They install CleanArr under /opt/cleanarr, create a dedicated system user, and provide a hardened systemd service.

# Debian / Ubuntu
sudo apt install ./cleanarr_<version>_amd64.deb
# Fedora / RHEL-compatible distributions
sudo dnf install ./cleanarr-<version>-1.x86_64.rpm
sudo systemctl enable --now cleanarr

The default configuration is stored in /etc/cleanarr/cleanarr.env; application data is stored in /var/lib/cleanarr. Packages require systemd and Python 3.12. See the complete native package guide, including upgrade, removal, backup, and checksum instructions.

Kubernetes

kubectl apply -f deploy/k8s/namespace.yaml
kubectl apply -f deploy/k8s/pvc.yaml
# Edit secret.example.yaml with your values first
kubectl apply -f deploy/k8s/secret.example.yaml
kubectl apply -f deploy/k8s/deployment.yaml
kubectl apply -f deploy/k8s/service.yaml
kubectl apply -f deploy/k8s/ingress.yaml

The deployment uses strategy: Recreate because the config PVC is ReadWriteOnce.


Configuration

All settings can be changed at runtime from the Settings tab. Environment variables provide defaults on first start.

VariableDefaultDescription
DRY_RUNtrueSet to false to enable real deletions
LOG_LEVELINFODEBUG, INFO, WARNING, ERROR
HTTP_TIMEOUT_SECONDS15Timeout for calls to downstream services
DB_PATH/config/cleanarr.dbSQLite database path — must be on a persistent volume
CONFIG_STATE_PATH/config/runtime-config.jsonLegacy runtime-config migration path
ADMIN_SHARED_TOKENOptional static token that bypasses session auth (useful for automation)
WEBHOOK_SHARED_TOKENauto-generatedShared secret verified on every inbound webhook. Auto-generated on first start; rotate from Settings → General
UI_LANGUAGEenInitial UI language: en or ru
JELLYFIN_LANGUAGEenPreferred metadata language for Jellyfin integration
SSO_MODEpassword_onlyAuthentication mode: password_only, both, or sso_only
SSO_ENABLEDfalseEnables the OpenID Connect integration
SSO_ISSUER_URLOpenID Connect issuer URL
SSO_CLIENT_IDOpenID Connect client ID
SSO_CLIENT_SECRETOpenID Connect client secret
SSO_REDIRECT_URICallback URL, usually https://cleanarr.example/api/auth/sso/callback
SSO_SCOPESopenid profile emailOpenID Connect scopes
SSO_ALLOWED_USERSComma-separated usernames/emails/subjects allowed to sign in
SSO_ALLOWED_GROUPSComma-separated group values allowed to sign in
SSO_GROUP_CLAIMgroupsID-token claim containing group values
SSO_REQUIRED_CLAIMOptional additional ID-token claim required for access
SSO_REQUIRED_VALUERequired value; configure together with SSO_REQUIRED_CLAIM
SESSION_COOKIE_SECUREautoForce Secure on/off; set true when TLS terminates at a reverse proxy not trusted for forwarded headers

Important:DB_PATH must point to a persistent volume. Without it, all service configurations and activity history are lost on restart.

Existing jellyseerr profiles are migrated in place to the canonical seerr configuration on startup. The legacy JELLYSEERR_URL / JELLYSEERR_API_KEY variables and /api/config/jellyseerr routes remain backward-compatible aliases.

SSO remains disabled until at least one explicit user/group allowlist or a required claim/value pair is configured. See the complete OIDC and reverse proxy guide before enabling both or sso_only mode.

The downloader step can save several qBittorrent, Transmission, Deluge, and rTorrent profiles. A profile may be saved disabled for later setup; enabled and preferred/default are separate states. Test the exact current draft before treating it as ready: changing its client kind, URL, or credentials invalidates the frontend's saved connection-test fingerprint.


Jellyfin webhook setup

The easiest way is to use Auto-configure in the Jellyfin service editor (click the pencil icon on the Jellyfin card in the Dashboard). It installs the correct config into the Jellyfin Webhook plugin automatically.

Manual setup: install the Webhook plugin in Jellyfin → Dashboard → Plugins → Catalog, then add a Generic destination:

  • URL:http://your-cleanarr-host:8089/webhook/jellyfin
  • Method:POST
  • Header:X-Webhook-Token: <your-token>
  • Notification type:Item Deleted only
  • Template:
{
"notification_type": "{{json_encodeNotificationType}}",
"item_type": "{{json_encodeItemType}}",
"item_id": "{{json_encodeItemId}}",
"name": "{{json_encodeName}}",
"path": null,
"tmdb_id": {{#if_existProvider_tmdb}}{{Provider_tmdb}}{{else}}null{{/if_exist}},
"tvdb_id": {{#if_existProvider_tvdb}}{{Provider_tvdb}}{{else}}null{{/if_exist}},
"imdb_id": {{#if_existProvider_imdb}}"{{json_encodeProvider_imdb}}"{{else}}null{{/if_exist}},
"series_name": {{#if_existSeriesName}}"{{json_encodeSeriesName}}"{{else}}null{{/if_exist}},
"series_id": {{#if_existSeriesId}}"{{json_encodeSeriesId}}"{{else}}null{{/if_exist}},
"season_number": {{#if_existSeasonNumber}}{{SeasonNumber}}{{else}}null{{/if_exist}},
"episode_number": {{#if_existEpisodeNumber}}{{EpisodeNumber}}{{else}}null{{/if_exist}},
"episode_end_number": {{#if_existEpisodeNumberEnd}}{{EpisodeNumberEnd}}{{else}}null{{/if_exist}},
"occurred_at": "{{json_encodeUtcTimestamp}}"
}

How it works

Movie deletion

  1. Resolve in Radarr by tmdb_id → imdb_id → path (strict, no fuzzy matching)
  2. Collect torrent hashes from Radarr download history
  3. Delete safe hashes in every owning torrent client, optionally together with local data
  4. Delete the Radarr entry
  5. Delete matching Seerr requests, issues, and media records

Series deletion

  1. Resolve in Sonarr by tvdb_id → tmdb_id → imdb_id → path
  2. Delete torrent hashes exclusively owned by the series
  3. Delete the Sonarr series entry
  4. Delete all Seerr requests, issues, and media for the series

Season deletion

  1. Resolve parent series in Sonarr
  2. Unmonitor all episodes in the target season
  3. Delete only episode files and hashes fully covered by the season scope
  4. Update or remove matching Seerr season requests

Episode deletion

  1. Resolve parent series in Sonarr
  2. Unmonitor the target episode range
  3. Delete episode file and hash only when fully isolated
  4. Delete matching Seerr episode issues; retain a season-scoped request unless the event provably covers the complete season, then update or remove it

Guardrails: pack torrents (multiple series/seasons in one archive) and shared files are never deleted — CleanArr logs the reason and skips destructive actions.


API reference

MethodPathAuthDescription
POST/webhook/jellyfinX-Webhook-Token headerMain ingestion endpoint
GET/api/dashboardsessionDashboard snapshot for the SPA
GET/api/configsessionRuntime configuration
PUT/api/config/generalsessionUpdate general settings
GET/api/config/exportsessionExport a credential-free configuration document
POST/api/config/importsessionMerge a redacted configuration in fail-safe mode
GET/api/support/bundlesessionRedacted operational support snapshot
GET/metricssession or admin tokenPrivacy-safe Prometheus metrics
POST/api/config/jellyfin/setup-webhooksessionAuto-configure the Jellyfin Webhook plugin
POST/api/actions/delete/previewsessionMutation-free single-item deletion preview
POST / GET/api/actions/delete/jobssessionQueue or list hash-bound single deletion jobs
GET / DELETE/api/actions/delete/jobs/{job_id}sessionInspect or dismiss a terminal deletion job
POST/api/actions/delete/batches/previewsessionMutation-free item-level batch preview
POST / GET/api/actions/delete/batchessessionSubmit or list bounded hash-bound batches
GET/api/actions/delete/batches/{batch_id}sessionInspect batch and child outcomes
GET / POST/api/downloads, /api/downloads/refreshsessionBounded cursor read model and refresh
GET/api/downloads/{client_id}/{info_hash}sessionOne normalized torrent observation
POST/api/downloads/actionssessionReversible pause/resume only; idempotency required
GET/api/downloads/cleanup-candidatessessionBounded Jellyfin-based cleanup recommendations
POST/api/auth/loginAdmin login
GET/api/auth/statusCurrent authentication capabilities and session state
GET/api/auth/sso/loginStart the OpenID Connect login flow
GET/health/livenoneLiveness probe
GET/health/readynoneReadiness probe

Repository layout

cleanarr/
├── backend/ # Python 3.12 / FastAPI
│ └── src/cleanarr/
│ ├── api/ # Routes, schemas, dashboard, auth
│ ├── application/ # Cascade deletion logic, configuration service
│ ├── domain/ # Models, config, errors
│ └── infrastructure/ # HTTP clients, SQLite stores, settings
├── frontend/ # React 19 + Vite + TypeScript + shadcn/ui
│ └── src/
├── deploy/
│ ├── Dockerfile # Multi-stage build (node:24 → python:3.12-slim)
│ ├── docker-compose.yml
│ └── k8s/ # Kubernetes manifests
├── packaging/ # DEB/RPM metadata, systemd unit, build scripts
└── docs/
├── LINUX_PACKAGES.md # Native Linux package guide
├── OPERATIONS.md # Metrics, support bundle, config transfer
├── RELEASING.md # Bilingual release policy
└── screenshots/

Stack

LayerTechnology
BackendPython 3.12, FastAPI, httpx, Pydantic v2, uvicorn
FrontendReact 19, Vite, TypeScript, shadcn/ui, Tailwind CSS v4, Sonner, Motion
StorageSQLite (config + activity log)
ContainerMulti-stage Docker build — node:24-bookworm-slim → python:3.12-slim

Development

# Backendcd backend
python -m venv .venv &&source .venv/bin/activate
pip install -e ".[dev]"# Frontend (build static assets served by the backend)cd frontend
pnpm install && pnpm build
# Run backend with hot reloadcd backend
uvicorn cleanarr.api.app:app --host 0.0.0.0 --port 8089 --reload

For full hot-reload on both sides, run the frontend dev server in parallel — it proxies /api, /health, and /webhook to port 8089:

# Terminal 2cd frontend
pnpm dev

Tests

cd backend
ruff format --check src tests
ruff check src tests
mypy src
pytest -q
cd ../frontend
pnpm lint
pnpm test -- --run
pnpm build
pnpm exec playwright test --project=chromium

Release notes are maintained in both Russian and English. See Release process.


License

MIT

About

Cascade media cleanup — deletes from Radarr, Sonarr, Jellyseerr & qBittorrent when you remove from Jellyfin

Topics

Resources

Code of conduct

Contributing

Security policy

Stars

15 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Repository files navigation

CleanArr

English · Русский

Automatic cascade cleanup for your self-hosted media stack.
CleanArr listens for Jellyfin ItemDeleted webhooks and cascades deletion to Radarr, Sonarr, Seerr, and supported torrent clients — automatically, safely, and without touching files it doesn't own.

Quick start · Linux packages · Screenshots · How it works · Configuration · Torrent clients · Compatibility · Safety · Troubleshooting · Operations · Roadmap · Contributing

Python 3.12React 19License MITDockerLinux packages


What is CleanArr?

When you delete something in Jellyfin, you usually have to manually clean up the same item in Radarr, Sonarr, Seerr, and your torrent clients. CleanArr automates this entire chain:

  1. Jellyfin fires an ItemDeleted webhook
  2. CleanArr resolves the item in Radarr/Sonarr using strict ID matching (TMDB → IMDB → path)
  3. Torrent hashes are routed to qBittorrent, Transmission, Deluge, and rTorrent — only when Arr history proves ownership
  4. The entry is removed from Radarr/Sonarr
  5. Matching requests, issues, and media records are cleaned up in Seerr

Pack torrents, shared files, and anything that can't be safely attributed are always skipped.


Screenshots

Sign in

Sign in screen

Create admin account

First-run — create admin account

Setup wizard

Guided setup wizard — Jellyfin step

Dashboard

Dashboard — all services healthy, Live mode

Activity log

Activity log with deletion history

Jellyfin service modal

Jellyfin service editor — webhook auto-configure

Settings

Settings — General configuration


Features

  • Cascade deletion — one webhook triggers a full cleanup chain: Jellyfin → Radarr/Sonarr → torrent clients → Seerr
  • Multi-instance routing — every enabled Radarr, Sonarr, and torrent-client profile participates without numeric ID collisions
  • Strict ID matching — resolves items by TMDB/TVDB/IMDB ID and path; no fuzzy guessing
  • Conservative guardrails — pack torrents and files shared between items are never deleted; CleanArr logs the reason and skips
  • Confirmed preflight — enabled before every manual deletion; shows exact media IDs, Arr instance, torrent client/hash/path, downstream mutations, and safety skips
  • Durable background cleanup — manual jobs, partial results, and retry state survive process restarts and report live step-by-step progress
  • Idempotent execution — completed Jellyfin deliveries are suppressed for seven days, partial failures remain retryable, and one safety lock serializes all destructive work in a CleanArr instance
  • Live health monitoring — probes all connected services every 30 s; status visible on the dashboard
  • Webhook auto-configure — one-click setup of the Jellyfin Webhook plugin directly from the UI
  • Activity log — every processed event is stored with full action breakdown; searchable by title, system, action, or status
  • Guided setup wizard — first-run wizard walks you through connecting each service step by step
  • Multi-profile downloaders — save qBittorrent, Transmission, Deluge, and rTorrent profiles together; enabled profiles participate while one preferred profile is retained for setup and display
  • Downloads and cleanup recommendations — inspect bounded, normalized torrent observations and Jellyfin-based cleanup candidates without turning unknown data into deletion permission
  • Local and SSO authentication — local password login plus strict OpenID Connect validation, PKCE, nonce, and explicit user/group/claim access policies
  • Dark / light mode — follows system preference

Quick start

Docker Compose

git clone https://github.com/mambastick/Cleanarr.git
cd Cleanarr
# Start (review environment variables in the compose file first)
docker compose -f deploy/docker-compose.yml up -d

Open http://localhost:8089 — the setup wizard walks you through the rest.

Before an image upgrade, create and export a verified SQLite backup:

docker compose -f deploy/docker-compose.yml exec -T cleanarr python3 -c 'import sqlite3; source=sqlite3.connect("/config/cleanarr.db"); backup=sqlite3.connect("/config/cleanarr.pre-upgrade.db"); source.backup(backup); print(backup.execute("PRAGMA integrity_check").fetchone()[0]); backup.close(); source.close()'
docker compose -f deploy/docker-compose.yml cp \
cleanarr:/config/cleanarr.pre-upgrade.db ./cleanarr.pre-upgrade.db

The check must print ok. To roll back, pin the previous image, stop the service, copy the verified backup back to /config/cleanarr.db, and start it again. Keep the failed database under a different name until the restore is verified.

Docker (manual)

docker run -d \
--name cleanarr \
-p 8089:8089 \
-e DRY_RUN=true \
-v cleanarr-config:/config \
ghcr.io/mambastick/cleanarr:latest

Native Linux packages

Every release provides .deb and .rpm packages for amd64 and arm64. They install CleanArr under /opt/cleanarr, create a dedicated system user, and provide a hardened systemd service.

# Debian / Ubuntu
sudo apt install ./cleanarr_<version>_amd64.deb
# Fedora / RHEL-compatible distributions
sudo dnf install ./cleanarr-<version>-1.x86_64.rpm
sudo systemctl enable --now cleanarr

The default configuration is stored in /etc/cleanarr/cleanarr.env; application data is stored in /var/lib/cleanarr. Packages require systemd and Python 3.12. See the complete native package guide, including upgrade, removal, backup, and checksum instructions.

Kubernetes

kubectl apply -f deploy/k8s/namespace.yaml
kubectl apply -f deploy/k8s/pvc.yaml
# Edit secret.example.yaml with your values first
kubectl apply -f deploy/k8s/secret.example.yaml
kubectl apply -f deploy/k8s/deployment.yaml
kubectl apply -f deploy/k8s/service.yaml
kubectl apply -f deploy/k8s/ingress.yaml

The deployment uses strategy: Recreate because the config PVC is ReadWriteOnce.


Configuration

All settings can be changed at runtime from the Settings tab. Environment variables provide defaults on first start.

VariableDefaultDescription
DRY_RUNtrueSet to false to enable real deletions
LOG_LEVELINFODEBUG, INFO, WARNING, ERROR
HTTP_TIMEOUT_SECONDS15Timeout for calls to downstream services
DB_PATH/config/cleanarr.dbSQLite database path — must be on a persistent volume
CONFIG_STATE_PATH/config/runtime-config.jsonLegacy runtime-config migration path
ADMIN_SHARED_TOKENOptional static token that bypasses session auth (useful for automation)
WEBHOOK_SHARED_TOKENauto-generatedShared secret verified on every inbound webhook. Auto-generated on first start; rotate from Settings → General
UI_LANGUAGEenInitial UI language: en or ru
JELLYFIN_LANGUAGEenPreferred metadata language for Jellyfin integration
SSO_MODEpassword_onlyAuthentication mode: password_only, both, or sso_only
SSO_ENABLEDfalseEnables the OpenID Connect integration
SSO_ISSUER_URLOpenID Connect issuer URL
SSO_CLIENT_IDOpenID Connect client ID
SSO_CLIENT_SECRETOpenID Connect client secret
SSO_REDIRECT_URICallback URL, usually https://cleanarr.example/api/auth/sso/callback
SSO_SCOPESopenid profile emailOpenID Connect scopes
SSO_ALLOWED_USERSComma-separated usernames/emails/subjects allowed to sign in
SSO_ALLOWED_GROUPSComma-separated group values allowed to sign in
SSO_GROUP_CLAIMgroupsID-token claim containing group values
SSO_REQUIRED_CLAIMOptional additional ID-token claim required for access
SSO_REQUIRED_VALUERequired value; configure together with SSO_REQUIRED_CLAIM
SESSION_COOKIE_SECUREautoForce Secure on/off; set true when TLS terminates at a reverse proxy not trusted for forwarded headers

Important:DB_PATH must point to a persistent volume. Without it, all service configurations and activity history are lost on restart.

Existing jellyseerr profiles are migrated in place to the canonical seerr configuration on startup. The legacy JELLYSEERR_URL / JELLYSEERR_API_KEY variables and /api/config/jellyseerr routes remain backward-compatible aliases.

SSO remains disabled until at least one explicit user/group allowlist or a required claim/value pair is configured. See the complete OIDC and reverse proxy guide before enabling both or sso_only mode.

The downloader step can save several qBittorrent, Transmission, Deluge, and rTorrent profiles. A profile may be saved disabled for later setup; enabled and preferred/default are separate states. Test the exact current draft before treating it as ready: changing its client kind, URL, or credentials invalidates the frontend's saved connection-test fingerprint.


Jellyfin webhook setup

The easiest way is to use Auto-configure in the Jellyfin service editor (click the pencil icon on the Jellyfin card in the Dashboard). It installs the correct config into the Jellyfin Webhook plugin automatically.

Manual setup: install the Webhook plugin in Jellyfin → Dashboard → Plugins → Catalog, then add a Generic destination:

  • URL:http://your-cleanarr-host:8089/webhook/jellyfin
  • Method:POST
  • Header:X-Webhook-Token: <your-token>
  • Notification type:Item Deleted only
  • Template:
{
"notification_type": "{{json_encodeNotificationType}}",
"item_type": "{{json_encodeItemType}}",
"item_id": "{{json_encodeItemId}}",
"name": "{{json_encodeName}}",
"path": null,
"tmdb_id": {{#if_existProvider_tmdb}}{{Provider_tmdb}}{{else}}null{{/if_exist}},
"tvdb_id": {{#if_existProvider_tvdb}}{{Provider_tvdb}}{{else}}null{{/if_exist}},
"imdb_id": {{#if_existProvider_imdb}}"{{json_encodeProvider_imdb}}"{{else}}null{{/if_exist}},
"series_name": {{#if_existSeriesName}}"{{json_encodeSeriesName}}"{{else}}null{{/if_exist}},
"series_id": {{#if_existSeriesId}}"{{json_encodeSeriesId}}"{{else}}null{{/if_exist}},
"season_number": {{#if_existSeasonNumber}}{{SeasonNumber}}{{else}}null{{/if_exist}},
"episode_number": {{#if_existEpisodeNumber}}{{EpisodeNumber}}{{else}}null{{/if_exist}},
"episode_end_number": {{#if_existEpisodeNumberEnd}}{{EpisodeNumberEnd}}{{else}}null{{/if_exist}},
"occurred_at": "{{json_encodeUtcTimestamp}}"
}

How it works

Movie deletion

  1. Resolve in Radarr by tmdb_id → imdb_id → path (strict, no fuzzy matching)
  2. Collect torrent hashes from Radarr download history
  3. Delete safe hashes in every owning torrent client, optionally together with local data
  4. Delete the Radarr entry
  5. Delete matching Seerr requests, issues, and media records

Series deletion

  1. Resolve in Sonarr by tvdb_id → tmdb_id → imdb_id → path
  2. Delete torrent hashes exclusively owned by the series
  3. Delete the Sonarr series entry
  4. Delete all Seerr requests, issues, and media for the series

Season deletion

  1. Resolve parent series in Sonarr
  2. Unmonitor all episodes in the target season
  3. Delete only episode files and hashes fully covered by the season scope
  4. Update or remove matching Seerr season requests

Episode deletion

  1. Resolve parent series in Sonarr
  2. Unmonitor the target episode range
  3. Delete episode file and hash only when fully isolated
  4. Delete matching Seerr episode issues; retain a season-scoped request unless the event provably covers the complete season, then update or remove it

Guardrails: pack torrents (multiple series/seasons in one archive) and shared files are never deleted — CleanArr logs the reason and skips destructive actions.


API reference

MethodPathAuthDescription
POST/webhook/jellyfinX-Webhook-Token headerMain ingestion endpoint
GET/api/dashboardsessionDashboard snapshot for the SPA
GET/api/configsessionRuntime configuration
PUT/api/config/generalsessionUpdate general settings
GET/api/config/exportsessionExport a credential-free configuration document
POST/api/config/importsessionMerge a redacted configuration in fail-safe mode
GET/api/support/bundlesessionRedacted operational support snapshot
GET/metricssession or admin tokenPrivacy-safe Prometheus metrics
POST/api/config/jellyfin/setup-webhooksessionAuto-configure the Jellyfin Webhook plugin
POST/api/actions/delete/previewsessionMutation-free single-item deletion preview
POST / GET/api/actions/delete/jobssessionQueue or list hash-bound single deletion jobs
GET / DELETE/api/actions/delete/jobs/{job_id}sessionInspect or dismiss a terminal deletion job
POST/api/actions/delete/batches/previewsessionMutation-free item-level batch preview
POST / GET/api/actions/delete/batchessessionSubmit or list bounded hash-bound batches
GET/api/actions/delete/batches/{batch_id}sessionInspect batch and child outcomes
GET / POST/api/downloads, /api/downloads/refreshsessionBounded cursor read model and refresh
GET/api/downloads/{client_id}/{info_hash}sessionOne normalized torrent observation
POST/api/downloads/actionssessionReversible pause/resume only; idempotency required
GET/api/downloads/cleanup-candidatessessionBounded Jellyfin-based cleanup recommendations
POST/api/auth/loginAdmin login
GET/api/auth/statusCurrent authentication capabilities and session state
GET/api/auth/sso/loginStart the OpenID Connect login flow
GET/health/livenoneLiveness probe
GET/health/readynoneReadiness probe

Repository layout

cleanarr/
├── backend/ # Python 3.12 / FastAPI
│ └── src/cleanarr/
│ ├── api/ # Routes, schemas, dashboard, auth
│ ├── application/ # Cascade deletion logic, configuration service
│ ├── domain/ # Models, config, errors
│ └── infrastructure/ # HTTP clients, SQLite stores, settings
├── frontend/ # React 19 + Vite + TypeScript + shadcn/ui
│ └── src/
├── deploy/
│ ├── Dockerfile # Multi-stage build (node:24 → python:3.12-slim)
│ ├── docker-compose.yml
│ └── k8s/ # Kubernetes manifests
├── packaging/ # DEB/RPM metadata, systemd unit, build scripts
└── docs/
├── LINUX_PACKAGES.md # Native Linux package guide
├── OPERATIONS.md # Metrics, support bundle, config transfer
├── RELEASING.md # Bilingual release policy
└── screenshots/

Stack

LayerTechnology
BackendPython 3.12, FastAPI, httpx, Pydantic v2, uvicorn
FrontendReact 19, Vite, TypeScript, shadcn/ui, Tailwind CSS v4, Sonner, Motion
StorageSQLite (config + activity log)
ContainerMulti-stage Docker build — node:24-bookworm-slim → python:3.12-slim

Development

# Backendcd backend
python -m venv .venv &&source .venv/bin/activate
pip install -e ".[dev]"# Frontend (build static assets served by the backend)cd frontend
pnpm install && pnpm build
# Run backend with hot reloadcd backend
uvicorn cleanarr.api.app:app --host 0.0.0.0 --port 8089 --reload

For full hot-reload on both sides, run the frontend dev server in parallel — it proxies /api, /health, and /webhook to port 8089:

# Terminal 2cd frontend
pnpm dev

Tests

cd backend
ruff format --check src tests
ruff check src tests
mypy src
pytest -q
cd ../frontend
pnpm lint
pnpm test -- --run
pnpm build
pnpm exec playwright test --project=chromium

Release notes are maintained in both Russian and English. See Release process.


License

MIT

About

Cascade media cleanup — deletes from Radarr, Sonarr, Jellyseerr & qBittorrent when you remove from Jellyfin

Topics

Resources

Code of conduct

Contributing

Security policy

Stars

15 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Repository files navigation

CleanArr

English · Русский

Automatic cascade cleanup for your self-hosted media stack.
CleanArr listens for Jellyfin ItemDeleted webhooks and cascades deletion to Radarr, Sonarr, Seerr, and supported torrent clients — automatically, safely, and without touching files it doesn't own.

Quick start · Linux packages · Screenshots · How it works · Configuration · Torrent clients · Compatibility · Safety · Troubleshooting · Operations · Roadmap · Contributing

Python 3.12React 19License MITDockerLinux packages


What is CleanArr?

When you delete something in Jellyfin, you usually have to manually clean up the same item in Radarr, Sonarr, Seerr, and your torrent clients. CleanArr automates this entire chain:

  1. Jellyfin fires an ItemDeleted webhook
  2. CleanArr resolves the item in Radarr/Sonarr using strict ID matching (TMDB → IMDB → path)
  3. Torrent hashes are routed to qBittorrent, Transmission, Deluge, and rTorrent — only when Arr history proves ownership
  4. The entry is removed from Radarr/Sonarr
  5. Matching requests, issues, and media records are cleaned up in Seerr

Pack torrents, shared files, and anything that can't be safely attributed are always skipped.


Screenshots

Sign in

Sign in screen

Create admin account

First-run — create admin account

Setup wizard

Guided setup wizard — Jellyfin step

Dashboard

Dashboard — all services healthy, Live mode

Activity log

Activity log with deletion history

Jellyfin service modal

Jellyfin service editor — webhook auto-configure

Settings

Settings — General configuration


Features

  • Cascade deletion — one webhook triggers a full cleanup chain: Jellyfin → Radarr/Sonarr → torrent clients → Seerr
  • Multi-instance routing — every enabled Radarr, Sonarr, and torrent-client profile participates without numeric ID collisions
  • Strict ID matching — resolves items by TMDB/TVDB/IMDB ID and path; no fuzzy guessing
  • Conservative guardrails — pack torrents and files shared between items are never deleted; CleanArr logs the reason and skips
  • Confirmed preflight — enabled before every manual deletion; shows exact media IDs, Arr instance, torrent client/hash/path, downstream mutations, and safety skips
  • Durable background cleanup — manual jobs, partial results, and retry state survive process restarts and report live step-by-step progress
  • Idempotent execution — completed Jellyfin deliveries are suppressed for seven days, partial failures remain retryable, and one safety lock serializes all destructive work in a CleanArr instance
  • Live health monitoring — probes all connected services every 30 s; status visible on the dashboard
  • Webhook auto-configure — one-click setup of the Jellyfin Webhook plugin directly from the UI
  • Activity log — every processed event is stored with full action breakdown; searchable by title, system, action, or status
  • Guided setup wizard — first-run wizard walks you through connecting each service step by step
  • Multi-profile downloaders — save qBittorrent, Transmission, Deluge, and rTorrent profiles together; enabled profiles participate while one preferred profile is retained for setup and display
  • Downloads and cleanup recommendations — inspect bounded, normalized torrent observations and Jellyfin-based cleanup candidates without turning unknown data into deletion permission
  • Local and SSO authentication — local password login plus strict OpenID Connect validation, PKCE, nonce, and explicit user/group/claim access policies
  • Dark / light mode — follows system preference

Quick start

Docker Compose

git clone https://github.com/mambastick/Cleanarr.git
cd Cleanarr
# Start (review environment variables in the compose file first)
docker compose -f deploy/docker-compose.yml up -d

Open http://localhost:8089 — the setup wizard walks you through the rest.

Before an image upgrade, create and export a verified SQLite backup:

docker compose -f deploy/docker-compose.yml exec -T cleanarr python3 -c 'import sqlite3; source=sqlite3.connect("/config/cleanarr.db"); backup=sqlite3.connect("/config/cleanarr.pre-upgrade.db"); source.backup(backup); print(backup.execute("PRAGMA integrity_check").fetchone()[0]); backup.close(); source.close()'
docker compose -f deploy/docker-compose.yml cp \
cleanarr:/config/cleanarr.pre-upgrade.db ./cleanarr.pre-upgrade.db

The check must print ok. To roll back, pin the previous image, stop the service, copy the verified backup back to /config/cleanarr.db, and start it again. Keep the failed database under a different name until the restore is verified.

Docker (manual)

docker run -d \
--name cleanarr \
-p 8089:8089 \
-e DRY_RUN=true \
-v cleanarr-config:/config \
ghcr.io/mambastick/cleanarr:latest

Native Linux packages

Every release provides .deb and .rpm packages for amd64 and arm64. They install CleanArr under /opt/cleanarr, create a dedicated system user, and provide a hardened systemd service.

# Debian / Ubuntu
sudo apt install ./cleanarr_<version>_amd64.deb
# Fedora / RHEL-compatible distributions
sudo dnf install ./cleanarr-<version>-1.x86_64.rpm
sudo systemctl enable --now cleanarr

The default configuration is stored in /etc/cleanarr/cleanarr.env; application data is stored in /var/lib/cleanarr. Packages require systemd and Python 3.12. See the complete native package guide, including upgrade, removal, backup, and checksum instructions.

Kubernetes

kubectl apply -f deploy/k8s/namespace.yaml
kubectl apply -f deploy/k8s/pvc.yaml
# Edit secret.example.yaml with your values first
kubectl apply -f deploy/k8s/secret.example.yaml
kubectl apply -f deploy/k8s/deployment.yaml
kubectl apply -f deploy/k8s/service.yaml
kubectl apply -f deploy/k8s/ingress.yaml

The deployment uses strategy: Recreate because the config PVC is ReadWriteOnce.


Configuration

All settings can be changed at runtime from the Settings tab. Environment variables provide defaults on first start.

VariableDefaultDescription
DRY_RUNtrueSet to false to enable real deletions
LOG_LEVELINFODEBUG, INFO, WARNING, ERROR
HTTP_TIMEOUT_SECONDS15Timeout for calls to downstream services
DB_PATH/config/cleanarr.dbSQLite database path — must be on a persistent volume
CONFIG_STATE_PATH/config/runtime-config.jsonLegacy runtime-config migration path
ADMIN_SHARED_TOKENOptional static token that bypasses session auth (useful for automation)
WEBHOOK_SHARED_TOKENauto-generatedShared secret verified on every inbound webhook. Auto-generated on first start; rotate from Settings → General
UI_LANGUAGEenInitial UI language: en or ru
JELLYFIN_LANGUAGEenPreferred metadata language for Jellyfin integration
SSO_MODEpassword_onlyAuthentication mode: password_only, both, or sso_only
SSO_ENABLEDfalseEnables the OpenID Connect integration
SSO_ISSUER_URLOpenID Connect issuer URL
SSO_CLIENT_IDOpenID Connect client ID
SSO_CLIENT_SECRETOpenID Connect client secret
SSO_REDIRECT_URICallback URL, usually https://cleanarr.example/api/auth/sso/callback
SSO_SCOPESopenid profile emailOpenID Connect scopes
SSO_ALLOWED_USERSComma-separated usernames/emails/subjects allowed to sign in
SSO_ALLOWED_GROUPSComma-separated group values allowed to sign in
SSO_GROUP_CLAIMgroupsID-token claim containing group values
SSO_REQUIRED_CLAIMOptional additional ID-token claim required for access
SSO_REQUIRED_VALUERequired value; configure together with SSO_REQUIRED_CLAIM
SESSION_COOKIE_SECUREautoForce Secure on/off; set true when TLS terminates at a reverse proxy not trusted for forwarded headers

Important:DB_PATH must point to a persistent volume. Without it, all service configurations and activity history are lost on restart.

Existing jellyseerr profiles are migrated in place to the canonical seerr configuration on startup. The legacy JELLYSEERR_URL / JELLYSEERR_API_KEY variables and /api/config/jellyseerr routes remain backward-compatible aliases.

SSO remains disabled until at least one explicit user/group allowlist or a required claim/value pair is configured. See the complete OIDC and reverse proxy guide before enabling both or sso_only mode.

The downloader step can save several qBittorrent, Transmission, Deluge, and rTorrent profiles. A profile may be saved disabled for later setup; enabled and preferred/default are separate states. Test the exact current draft before treating it as ready: changing its client kind, URL, or credentials invalidates the frontend's saved connection-test fingerprint.


Jellyfin webhook setup

The easiest way is to use Auto-configure in the Jellyfin service editor (click the pencil icon on the Jellyfin card in the Dashboard). It installs the correct config into the Jellyfin Webhook plugin automatically.

Manual setup: install the Webhook plugin in Jellyfin → Dashboard → Plugins → Catalog, then add a Generic destination:

  • URL:http://your-cleanarr-host:8089/webhook/jellyfin
  • Method:POST
  • Header:X-Webhook-Token: <your-token>
  • Notification type:Item Deleted only
  • Template:
{
"notification_type": "{{json_encodeNotificationType}}",
"item_type": "{{json_encodeItemType}}",
"item_id": "{{json_encodeItemId}}",
"name": "{{json_encodeName}}",
"path": null,
"tmdb_id": {{#if_existProvider_tmdb}}{{Provider_tmdb}}{{else}}null{{/if_exist}},
"tvdb_id": {{#if_existProvider_tvdb}}{{Provider_tvdb}}{{else}}null{{/if_exist}},
"imdb_id": {{#if_existProvider_imdb}}"{{json_encodeProvider_imdb}}"{{else}}null{{/if_exist}},
"series_name": {{#if_existSeriesName}}"{{json_encodeSeriesName}}"{{else}}null{{/if_exist}},
"series_id": {{#if_existSeriesId}}"{{json_encodeSeriesId}}"{{else}}null{{/if_exist}},
"season_number": {{#if_existSeasonNumber}}{{SeasonNumber}}{{else}}null{{/if_exist}},
"episode_number": {{#if_existEpisodeNumber}}{{EpisodeNumber}}{{else}}null{{/if_exist}},
"episode_end_number": {{#if_existEpisodeNumberEnd}}{{EpisodeNumberEnd}}{{else}}null{{/if_exist}},
"occurred_at": "{{json_encodeUtcTimestamp}}"
}

How it works

Movie deletion

  1. Resolve in Radarr by tmdb_id → imdb_id → path (strict, no fuzzy matching)
  2. Collect torrent hashes from Radarr download history
  3. Delete safe hashes in every owning torrent client, optionally together with local data
  4. Delete the Radarr entry
  5. Delete matching Seerr requests, issues, and media records

Series deletion

  1. Resolve in Sonarr by tvdb_id → tmdb_id → imdb_id → path
  2. Delete torrent hashes exclusively owned by the series
  3. Delete the Sonarr series entry
  4. Delete all Seerr requests, issues, and media for the series

Season deletion

  1. Resolve parent series in Sonarr
  2. Unmonitor all episodes in the target season
  3. Delete only episode files and hashes fully covered by the season scope
  4. Update or remove matching Seerr season requests

Episode deletion

  1. Resolve parent series in Sonarr
  2. Unmonitor the target episode range
  3. Delete episode file and hash only when fully isolated
  4. Delete matching Seerr episode issues; retain a season-scoped request unless the event provably covers the complete season, then update or remove it

Guardrails: pack torrents (multiple series/seasons in one archive) and shared files are never deleted — CleanArr logs the reason and skips destructive actions.


API reference

MethodPathAuthDescription
POST/webhook/jellyfinX-Webhook-Token headerMain ingestion endpoint
GET/api/dashboardsessionDashboard snapshot for the SPA
GET/api/configsessionRuntime configuration
PUT/api/config/generalsessionUpdate general settings
GET/api/config/exportsessionExport a credential-free configuration document
POST/api/config/importsessionMerge a redacted configuration in fail-safe mode
GET/api/support/bundlesessionRedacted operational support snapshot
GET/metricssession or admin tokenPrivacy-safe Prometheus metrics
POST/api/config/jellyfin/setup-webhooksessionAuto-configure the Jellyfin Webhook plugin
POST/api/actions/delete/previewsessionMutation-free single-item deletion preview
POST / GET/api/actions/delete/jobssessionQueue or list hash-bound single deletion jobs
GET / DELETE/api/actions/delete/jobs/{job_id}sessionInspect or dismiss a terminal deletion job
POST/api/actions/delete/batches/previewsessionMutation-free item-level batch preview
POST / GET/api/actions/delete/batchessessionSubmit or list bounded hash-bound batches
GET/api/actions/delete/batches/{batch_id}sessionInspect batch and child outcomes
GET / POST/api/downloads, /api/downloads/refreshsessionBounded cursor read model and refresh
GET/api/downloads/{client_id}/{info_hash}sessionOne normalized torrent observation
POST/api/downloads/actionssessionReversible pause/resume only; idempotency required
GET/api/downloads/cleanup-candidatessessionBounded Jellyfin-based cleanup recommendations
POST/api/auth/loginAdmin login
GET/api/auth/statusCurrent authentication capabilities and session state
GET/api/auth/sso/loginStart the OpenID Connect login flow
GET/health/livenoneLiveness probe
GET/health/readynoneReadiness probe

Repository layout

cleanarr/
├── backend/ # Python 3.12 / FastAPI
│ └── src/cleanarr/
│ ├── api/ # Routes, schemas, dashboard, auth
│ ├── application/ # Cascade deletion logic, configuration service
│ ├── domain/ # Models, config, errors
│ └── infrastructure/ # HTTP clients, SQLite stores, settings
├── frontend/ # React 19 + Vite + TypeScript + shadcn/ui
│ └── src/
├── deploy/
│ ├── Dockerfile # Multi-stage build (node:24 → python:3.12-slim)
│ ├── docker-compose.yml
│ └── k8s/ # Kubernetes manifests
├── packaging/ # DEB/RPM metadata, systemd unit, build scripts
└── docs/
├── LINUX_PACKAGES.md # Native Linux package guide
├── OPERATIONS.md # Metrics, support bundle, config transfer
├── RELEASING.md # Bilingual release policy
└── screenshots/

Stack

LayerTechnology
BackendPython 3.12, FastAPI, httpx, Pydantic v2, uvicorn
FrontendReact 19, Vite, TypeScript, shadcn/ui, Tailwind CSS v4, Sonner, Motion
StorageSQLite (config + activity log)
ContainerMulti-stage Docker build — node:24-bookworm-slim → python:3.12-slim

Development

# Backendcd backend
python -m venv .venv &&source .venv/bin/activate
pip install -e ".[dev]"# Frontend (build static assets served by the backend)cd frontend
pnpm install && pnpm build
# Run backend with hot reloadcd backend
uvicorn cleanarr.api.app:app --host 0.0.0.0 --port 8089 --reload

For full hot-reload on both sides, run the frontend dev server in parallel — it proxies /api, /health, and /webhook to port 8089:

# Terminal 2cd frontend
pnpm dev

Tests

cd backend
ruff format --check src tests
ruff check src tests
mypy src
pytest -q
cd ../frontend
pnpm lint
pnpm test -- --run
pnpm build
pnpm exec playwright test --project=chromium

Release notes are maintained in both Russian and English. See Release process.


License

MIT

About

Cascade media cleanup — deletes from Radarr, Sonarr, Jellyseerr & qBittorrent when you remove from Jellyfin

Topics

Resources

Code of conduct

Contributing

Security policy

Stars

15 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content

Repository files navigation

CleanArr

English · Русский

Automatic cascade cleanup for your self-hosted media stack.
CleanArr listens for Jellyfin ItemDeleted webhooks and cascades deletion to Radarr, Sonarr, Seerr, and supported torrent clients — automatically, safely, and without touching files it doesn't own.

Quick start · Linux packages · Screenshots · How it works · Configuration · Torrent clients · Compatibility · Safety · Troubleshooting · Operations · Roadmap · Contributing

Python 3.12React 19License MITDockerLinux packages


What is CleanArr?

When you delete something in Jellyfin, you usually have to manually clean up the same item in Radarr, Sonarr, Seerr, and your torrent clients. CleanArr automates this entire chain:

  1. Jellyfin fires an ItemDeleted webhook
  2. CleanArr resolves the item in Radarr/Sonarr using strict ID matching (TMDB → IMDB → path)
  3. Torrent hashes are routed to qBittorrent, Transmission, Deluge, and rTorrent — only when Arr history proves ownership
  4. The entry is removed from Radarr/Sonarr
  5. Matching requests, issues, and media records are cleaned up in Seerr

Pack torrents, shared files, and anything that can't be safely attributed are always skipped.


Screenshots

Sign in

Sign in screen

Create admin account

First-run — create admin account

Setup wizard

Guided setup wizard — Jellyfin step

Dashboard

Dashboard — all services healthy, Live mode

Activity log

Activity log with deletion history

Jellyfin service modal

Jellyfin service editor — webhook auto-configure

Settings

Settings — General configuration


Features

  • Cascade deletion — one webhook triggers a full cleanup chain: Jellyfin → Radarr/Sonarr → torrent clients → Seerr
  • Multi-instance routing — every enabled Radarr, Sonarr, and torrent-client profile participates without numeric ID collisions
  • Strict ID matching — resolves items by TMDB/TVDB/IMDB ID and path; no fuzzy guessing
  • Conservative guardrails — pack torrents and files shared between items are never deleted; CleanArr logs the reason and skips
  • Confirmed preflight — enabled before every manual deletion; shows exact media IDs, Arr instance, torrent client/hash/path, downstream mutations, and safety skips
  • Durable background cleanup — manual jobs, partial results, and retry state survive process restarts and report live step-by-step progress
  • Idempotent execution — completed Jellyfin deliveries are suppressed for seven days, partial failures remain retryable, and one safety lock serializes all destructive work in a CleanArr instance
  • Live health monitoring — probes all connected services every 30 s; status visible on the dashboard
  • Webhook auto-configure — one-click setup of the Jellyfin Webhook plugin directly from the UI
  • Activity log — every processed event is stored with full action breakdown; searchable by title, system, action, or status
  • Guided setup wizard — first-run wizard walks you through connecting each service step by step
  • Multi-profile downloaders — save qBittorrent, Transmission, Deluge, and rTorrent profiles together; enabled profiles participate while one preferred profile is retained for setup and display
  • Downloads and cleanup recommendations — inspect bounded, normalized torrent observations and Jellyfin-based cleanup candidates without turning unknown data into deletion permission
  • Local and SSO authentication — local password login plus strict OpenID Connect validation, PKCE, nonce, and explicit user/group/claim access policies
  • Dark / light mode — follows system preference

Quick start

Docker Compose

git clone https://github.com/mambastick/Cleanarr.git
cd Cleanarr
# Start (review environment variables in the compose file first)
docker compose -f deploy/docker-compose.yml up -d

Open http://localhost:8089 — the setup wizard walks you through the rest.

Before an image upgrade, create and export a verified SQLite backup:

docker compose -f deploy/docker-compose.yml exec -T cleanarr python3 -c 'import sqlite3; source=sqlite3.connect("/config/cleanarr.db"); backup=sqlite3.connect("/config/cleanarr.pre-upgrade.db"); source.backup(backup); print(backup.execute("PRAGMA integrity_check").fetchone()[0]); backup.close(); source.close()'
docker compose -f deploy/docker-compose.yml cp \
cleanarr:/config/cleanarr.pre-upgrade.db ./cleanarr.pre-upgrade.db

The check must print ok. To roll back, pin the previous image, stop the service, copy the verified backup back to /config/cleanarr.db, and start it again. Keep the failed database under a different name until the restore is verified.

Docker (manual)

docker run -d \
--name cleanarr \
-p 8089:8089 \
-e DRY_RUN=true \
-v cleanarr-config:/config \
ghcr.io/mambastick/cleanarr:latest

Native Linux packages

Every release provides .deb and .rpm packages for amd64 and arm64. They install CleanArr under /opt/cleanarr, create a dedicated system user, and provide a hardened systemd service.

# Debian / Ubuntu
sudo apt install ./cleanarr_<version>_amd64.deb
# Fedora / RHEL-compatible distributions
sudo dnf install ./cleanarr-<version>-1.x86_64.rpm
sudo systemctl enable --now cleanarr

The default configuration is stored in /etc/cleanarr/cleanarr.env; application data is stored in /var/lib/cleanarr. Packages require systemd and Python 3.12. See the complete native package guide, including upgrade, removal, backup, and checksum instructions.

Kubernetes

kubectl apply -f deploy/k8s/namespace.yaml
kubectl apply -f deploy/k8s/pvc.yaml
# Edit secret.example.yaml with your values first
kubectl apply -f deploy/k8s/secret.example.yaml
kubectl apply -f deploy/k8s/deployment.yaml
kubectl apply -f deploy/k8s/service.yaml
kubectl apply -f deploy/k8s/ingress.yaml

The deployment uses strategy: Recreate because the config PVC is ReadWriteOnce.


Configuration

All settings can be changed at runtime from the Settings tab. Environment variables provide defaults on first start.

VariableDefaultDescription
DRY_RUNtrueSet to false to enable real deletions
LOG_LEVELINFODEBUG, INFO, WARNING, ERROR
HTTP_TIMEOUT_SECONDS15Timeout for calls to downstream services
DB_PATH/config/cleanarr.dbSQLite database path — must be on a persistent volume
CONFIG_STATE_PATH/config/runtime-config.jsonLegacy runtime-config migration path
ADMIN_SHARED_TOKENOptional static token that bypasses session auth (useful for automation)
WEBHOOK_SHARED_TOKENauto-generatedShared secret verified on every inbound webhook. Auto-generated on first start; rotate from Settings → General
UI_LANGUAGEenInitial UI language: en or ru
JELLYFIN_LANGUAGEenPreferred metadata language for Jellyfin integration
SSO_MODEpassword_onlyAuthentication mode: password_only, both, or sso_only
SSO_ENABLEDfalseEnables the OpenID Connect integration
SSO_ISSUER_URLOpenID Connect issuer URL
SSO_CLIENT_IDOpenID Connect client ID
SSO_CLIENT_SECRETOpenID Connect client secret
SSO_REDIRECT_URICallback URL, usually https://cleanarr.example/api/auth/sso/callback
SSO_SCOPESopenid profile emailOpenID Connect scopes
SSO_ALLOWED_USERSComma-separated usernames/emails/subjects allowed to sign in
SSO_ALLOWED_GROUPSComma-separated group values allowed to sign in
SSO_GROUP_CLAIMgroupsID-token claim containing group values
SSO_REQUIRED_CLAIMOptional additional ID-token claim required for access
SSO_REQUIRED_VALUERequired value; configure together with SSO_REQUIRED_CLAIM
SESSION_COOKIE_SECUREautoForce Secure on/off; set true when TLS terminates at a reverse proxy not trusted for forwarded headers

Important:DB_PATH must point to a persistent volume. Without it, all service configurations and activity history are lost on restart.

Existing jellyseerr profiles are migrated in place to the canonical seerr configuration on startup. The legacy JELLYSEERR_URL / JELLYSEERR_API_KEY variables and /api/config/jellyseerr routes remain backward-compatible aliases.

SSO remains disabled until at least one explicit user/group allowlist or a required claim/value pair is configured. See the complete OIDC and reverse proxy guide before enabling both or sso_only mode.

The downloader step can save several qBittorrent, Transmission, Deluge, and rTorrent profiles. A profile may be saved disabled for later setup; enabled and preferred/default are separate states. Test the exact current draft before treating it as ready: changing its client kind, URL, or credentials invalidates the frontend's saved connection-test fingerprint.


Jellyfin webhook setup

The easiest way is to use Auto-configure in the Jellyfin service editor (click the pencil icon on the Jellyfin card in the Dashboard). It installs the correct config into the Jellyfin Webhook plugin automatically.

Manual setup: install the Webhook plugin in Jellyfin → Dashboard → Plugins → Catalog, then add a Generic destination:

  • URL:http://your-cleanarr-host:8089/webhook/jellyfin
  • Method:POST
  • Header:X-Webhook-Token: <your-token>
  • Notification type:Item Deleted only
  • Template:
{
"notification_type": "{{json_encodeNotificationType}}",
"item_type": "{{json_encodeItemType}}",
"item_id": "{{json_encodeItemId}}",
"name": "{{json_encodeName}}",
"path": null,
"tmdb_id": {{#if_existProvider_tmdb}}{{Provider_tmdb}}{{else}}null{{/if_exist}},
"tvdb_id": {{#if_existProvider_tvdb}}{{Provider_tvdb}}{{else}}null{{/if_exist}},
"imdb_id": {{#if_existProvider_imdb}}"{{json_encodeProvider_imdb}}"{{else}}null{{/if_exist}},
"series_name": {{#if_existSeriesName}}"{{json_encodeSeriesName}}"{{else}}null{{/if_exist}},
"series_id": {{#if_existSeriesId}}"{{json_encodeSeriesId}}"{{else}}null{{/if_exist}},
"season_number": {{#if_existSeasonNumber}}{{SeasonNumber}}{{else}}null{{/if_exist}},
"episode_number": {{#if_existEpisodeNumber}}{{EpisodeNumber}}{{else}}null{{/if_exist}},
"episode_end_number": {{#if_existEpisodeNumberEnd}}{{EpisodeNumberEnd}}{{else}}null{{/if_exist}},
"occurred_at": "{{json_encodeUtcTimestamp}}"
}

How it works

Movie deletion

  1. Resolve in Radarr by tmdb_id → imdb_id → path (strict, no fuzzy matching)
  2. Collect torrent hashes from Radarr download history
  3. Delete safe hashes in every owning torrent client, optionally together with local data
  4. Delete the Radarr entry
  5. Delete matching Seerr requests, issues, and media records

Series deletion

  1. Resolve in Sonarr by tvdb_id → tmdb_id → imdb_id → path
  2. Delete torrent hashes exclusively owned by the series
  3. Delete the Sonarr series entry
  4. Delete all Seerr requests, issues, and media for the series

Season deletion

  1. Resolve parent series in Sonarr
  2. Unmonitor all episodes in the target season
  3. Delete only episode files and hashes fully covered by the season scope
  4. Update or remove matching Seerr season requests

Episode deletion

  1. Resolve parent series in Sonarr
  2. Unmonitor the target episode range
  3. Delete episode file and hash only when fully isolated
  4. Delete matching Seerr episode issues; retain a season-scoped request unless the event provably covers the complete season, then update or remove it

Guardrails: pack torrents (multiple series/seasons in one archive) and shared files are never deleted — CleanArr logs the reason and skips destructive actions.


API reference

MethodPathAuthDescription
POST/webhook/jellyfinX-Webhook-Token headerMain ingestion endpoint
GET/api/dashboardsessionDashboard snapshot for the SPA
GET/api/configsessionRuntime configuration
PUT/api/config/generalsessionUpdate general settings
GET/api/config/exportsessionExport a credential-free configuration document
POST/api/config/importsessionMerge a redacted configuration in fail-safe mode
GET/api/support/bundlesessionRedacted operational support snapshot
GET/metricssession or admin tokenPrivacy-safe Prometheus metrics
POST/api/config/jellyfin/setup-webhooksessionAuto-configure the Jellyfin Webhook plugin
POST/api/actions/delete/previewsessionMutation-free single-item deletion preview
POST / GET/api/actions/delete/jobssessionQueue or list hash-bound single deletion jobs
GET / DELETE/api/actions/delete/jobs/{job_id}sessionInspect or dismiss a terminal deletion job
POST/api/actions/delete/batches/previewsessionMutation-free item-level batch preview
POST / GET/api/actions/delete/batchessessionSubmit or list bounded hash-bound batches
GET/api/actions/delete/batches/{batch_id}sessionInspect batch and child outcomes
GET / POST/api/downloads, /api/downloads/refreshsessionBounded cursor read model and refresh
GET/api/downloads/{client_id}/{info_hash}sessionOne normalized torrent observation
POST/api/downloads/actionssessionReversible pause/resume only; idempotency required
GET/api/downloads/cleanup-candidatessessionBounded Jellyfin-based cleanup recommendations
POST/api/auth/loginAdmin login
GET/api/auth/statusCurrent authentication capabilities and session state
GET/api/auth/sso/loginStart the OpenID Connect login flow
GET/health/livenoneLiveness probe
GET/health/readynoneReadiness probe

Repository layout

cleanarr/
├── backend/ # Python 3.12 / FastAPI
│ └── src/cleanarr/
│ ├── api/ # Routes, schemas, dashboard, auth
│ ├── application/ # Cascade deletion logic, configuration service
│ ├── domain/ # Models, config, errors
│ └── infrastructure/ # HTTP clients, SQLite stores, settings
├── frontend/ # React 19 + Vite + TypeScript + shadcn/ui
│ └── src/
├── deploy/
│ ├── Dockerfile # Multi-stage build (node:24 → python:3.12-slim)
│ ├── docker-compose.yml
│ └── k8s/ # Kubernetes manifests
├── packaging/ # DEB/RPM metadata, systemd unit, build scripts
└── docs/
├── LINUX_PACKAGES.md # Native Linux package guide
├── OPERATIONS.md # Metrics, support bundle, config transfer
├── RELEASING.md # Bilingual release policy
└── screenshots/

Stack

LayerTechnology
BackendPython 3.12, FastAPI, httpx, Pydantic v2, uvicorn
FrontendReact 19, Vite, TypeScript, shadcn/ui, Tailwind CSS v4, Sonner, Motion
StorageSQLite (config + activity log)
ContainerMulti-stage Docker build — node:24-bookworm-slim → python:3.12-slim

Development

# Backendcd backend
python -m venv .venv &&source .venv/bin/activate
pip install -e ".[dev]"# Frontend (build static assets served by the backend)cd frontend
pnpm install && pnpm build
# Run backend with hot reloadcd backend
uvicorn cleanarr.api.app:app --host 0.0.0.0 --port 8089 --reload

For full hot-reload on both sides, run the frontend dev server in parallel — it proxies /api, /health, and /webhook to port 8089:

# Terminal 2cd frontend
pnpm dev

Tests

cd backend
ruff format --check src tests
ruff check src tests
mypy src
pytest -q
cd ../frontend
pnpm lint
pnpm test -- --run
pnpm build
pnpm exec playwright test --project=chromium

Release notes are maintained in both Russian and English. See Release process.


License

MIT

About

Cascade media cleanup — deletes from Radarr, Sonarr, Jellyseerr & qBittorrent when you remove from Jellyfin

Topics

Resources

Code of conduct

Contributing

Security policy

Stars

15 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages