"But wait, there's more!" - A lightweight media cleanup automation tool for the *arr stack with Jellyfin integration.
OxiCleanarr removes media clutter from your Jellyfin server with the power and effectiveness you'd expect from a product endorsed by Billy Mays himself! Just like OxiClean tackles tough stains, OxiCleanarr tackles your unwatched media backlog.
Built with AI: This project was created ~90% with AI assistance using OpenCode and Claude 4.5 Sonnet.
- Automated Media Cleanup: Intelligently removes unwatched media based on configurable retention rules
- Advanced Rules Engine: Tag-based, user-based, and watched-based cleanup rules for fine-grained control
- "Leaving Soon" Library: Creates symlink libraries in Jellyfin to preview content scheduled for deletion
- Multi-Service Integration: Supports Jellyfin, Radarr, Sonarr, Jellyseerr, and Jellystat
- Safe Operations: Dry-run mode enabled by default, manual exclusions, and job history tracking
- Hot Configuration Reload: Update settings without restarting the application
- RESTful API: Complete HTTP API with JWT authentication
- Structured Logging: JSON-formatted logs for easy parsing and monitoring
Click to view screenshots
Overview of your media cleanup status with key metrics and recent activity
Visual timeline showing when media items are scheduled for deletion
Browse and manage your entire media library with filtering and sorting
Review items scheduled for deletion and manage exclusions
Track all sync operations and cleanup jobs with detailed history
Detailed view of job execution with statistics and timing information
Configure general application settings and sync intervals
Connect and configure external services (Jellyfin, Radarr, Sonarr, etc.)
Create tag-based, user-based, and watched-based cleanup rules
Configure "Leaving Soon" library symlink settings
Administrative controls including application restart and maintenance
- Docker (recommended) or Go 1.21+ (for building from source)
- Active *arr stack services (Radarr and/or Sonarr)
- Jellyfin instance
- OxiCleanarr Bridge Plugin installed in Jellyfin
⚠️ IMPORTANT: The OxiCleanarr Bridge Plugin is required for Jellyfin integration. It provides file system operations (symlink management, directory operations) that Jellyfin's native API doesn't support. Without this plugin, OxiCleanarr cannot create "leaving soon" libraries or manage media lifecycle.
Pull the latest image:
docker pull ghcr.io/ramonskie/oxicleanarr:latestRun with Docker:
docker run -d \
--name oxicleanarr \
--restart unless-stopped \
-p 8080:8080 \
-v /path/to/config:/app/config \
-v /path/to/data:/app/data \
-v /path/to/media:/data/media:ro \
-e PUID=1000 \
-e PGID=1000 \
-e TZ=UTC \
ghcr.io/ramonskie/oxicleanarr:latestOr use Docker Compose (see NAS_DEPLOYMENT.md for detailed examples)
Available Tags:
ghcr.io/ramonskie/oxicleanarr:latest- Latest stable releaseghcr.io/ramonskie/oxicleanarr:v1.0.0- Specific versionghcr.io/ramonskie/oxicleanarr:1.0- Major.minor versionghcr.io/ramonskie/oxicleanarr:1- Major version
- Open Jellyfin → Dashboard → Plugins → Repositories
- Click "+" to add a repository
- Enter:
- Repository Name:
OxiCleanarr Plugin Repository - Repository URL:
https://cdn.jsdelivr.net/gh/ramonskie/jellyfin-plugin-oxicleanarr@main/manifest.json
- Repository Name:
- Click Save
- Go to Dashboard → Plugins → Catalog
- Find "OxiCleanarr Bridge" and click Install
- Restart Jellyfin when prompted
- Verify the plugin is loaded: Dashboard → Plugins → OxiCleanarr Bridge
Manual Installation: For manual installation from source or releases, see the plugin repository
- Clone the repository:
git clone https://github.com/ramonskie/oxicleanarr.git
cd oxicleanarr- Build the application:
go build -o oxicleanarr cmd/oxicleanarr/main.go- Create configuration file:
mkdir -p config
cp config/config.yaml.example config/config.yaml-
Configure "Leaving Soon" library paths in Jellyfin:
- Create directories for symlink libraries (on the Jellyfin server):
mkdir -p /path/to/media/leaving-soon/movies mkdir -p /path/to/media/leaving-soon/tv
- Add these directories as new libraries in Jellyfin
- Name them appropriately (e.g., "Leaving Soon - Movies", "Leaving Soon - TV")
- Create directories for symlink libraries (on the Jellyfin server):
-
Edit
config/config.yamlwith your service URLs and API keys:
admin:
username: admin
password: changeme # ⚠️ Change this! Stored in plain text
integrations:
jellyfin:
enabled: true
url: http://jellyfin:8096
api_key: your-jellyfin-api-key-here
symlink_library:
enabled: true
base_path: /path/to/media/leaving-soon # Path on Jellyfin server
movies_library_name: "Leaving Soon - Movies"
tv_library_name: "Leaving Soon - TV"
radarr:
enabled: true
url: http://radarr:7878
api_key: your-radarr-api-key-here
sonarr:
enabled: true
url: http://sonarr:8989
api_key: your-sonarr-api-key-here
rules:
movie_retention: 90d # Keep movies for 90 days
tv_retention: 120d # Keep TV shows for 120 days
app:
dry_run: true # Start in safe mode - no actual deletions
leaving_soon_days: 14 # Show items in "leaving soon" 14 days before deletionchmod 600 config/config.yaml) and use a strong password.
- Run OxiCleanarr:
./oxicleanarrThe application will start on http://0.0.0.0:8080 by default.
OxiCleanarr uses a YAML configuration file located at ./config/config.yaml. The file supports hot-reloading - changes are automatically applied without restarting the application.
admin:
username: admin
password: changeme
integrations:
jellyfin:
enabled: true
url: http://jellyfin:8096
api_key: your-api-key-here
radarr:
enabled: true
url: http://radarr:7878
api_key: your-api-key-here
sonarr:
enabled: true
url: http://sonarr:8989
api_key: your-api-key-hereadmin:
username: admin
password: changeme
app:
dry_run: true # Safe mode - no actual deletions
leaving_soon_days: 14 # Days before retention expires
sync:
full_interval: 3600 # Full sync every hour (seconds)
incremental_interval: 900 # Incremental sync every 15 min
auto_start: true # Start syncing on startup
rules:
movie_retention: 90d # Keep movies for 90 days
tv_retention: 120d # Keep TV shows for 120 days
server:
host: 0.0.0.0
port: 8080
read_timeout: 30s
write_timeout: 30s
idle_timeout: 60s
shutdown_timeout: 30s
integrations:
jellyfin:
enabled: true
url: http://jellyfin:8096
api_key: your-api-key-here
timeout: 30s
radarr:
enabled: true
url: http://radarr:7878
api_key: your-api-key-here
timeout: 30s
sonarr:
enabled: true
url: http://sonarr:8989
api_key: your-api-key-here
timeout: 30s
jellyseerr:
enabled: false
url: http://jellyseerr:5055
api_key: ""
jellystat:
enabled: false
url: http://jellystat:3000
api_key: ""Configuration can be overridden using environment variables with the OXICLEANARR_ prefix:
export OXICLEANARR_ADMIN_USERNAME=myadmin
export OXICLEANARR_ADMIN_PASSWORD=mypassword
export OXICLEANARR_SERVER_PORT=9090
export OXICLEANARR_APP_DRY_RUN=false
export OXICLEANARR_INTEGRATIONS_JELLYFIN_URL=http://jellyfin:8096
export OXICLEANARR_INTEGRATIONS_JELLYFIN_API_KEY=your-keyOxiCleanarr provides a powerful rules engine that allows fine-grained control over media cleanup behavior. Rules are evaluated in priority order: tag-based → user-based → watched-based → default retention.
Target media by Radarr/Sonarr tags for custom retention periods:
advanced_rules:
- name: Kids Content
type: tag
enabled: true
tag: kids
retention: 60d # Keep kids content for 60 days
- name: Premium Content
type: tag
enabled: true
tag: premium
retention: 180d # Keep premium content for 6 monthsApply different retention policies based on who requested the content. Match users by any of: user_id, username, or email.
advanced_rules:
- name: Trial Users
type: user
enabled: true
users:
- user_id: 42 # Match by Jellyseerr user ID
retention: 30d
- email: guest@example.com # Match by email address
retention: 7d
require_watched: true # Only delete after watched
- username: trial_user # Match by username
retention: 14dIntegration Requirements: User-based rules require Jellyseerr integration enabled to match requesters.
Automatically clean up content based on watch history. Requires Jellystat integration.
advanced_rules:
- name: Auto Clean Watched Content
type: watched
enabled: true
retention: 30d # Delete 30 days after last watch
require_watched: true # Only delete media that has been watched
# Protects unwatched content from deletionHow it works: When require_watched: true, media must have at least one watch event. The retention period starts from the last watch date. Unwatched content is never deleted by this rule.
Integration Requirements: Watched-based rules require Jellystat integration enabled to track watch history.
Rules are evaluated in this order:
- Tag-based rules (highest priority) - If media has a matching tag
- User-based rules - If media was requested by a matching user
- Watched-based rules - If media meets watch criteria
- Default retention (lowest priority) -
movie_retentionortv_retention
The first matching rule determines the retention policy.
rules:
movie_retention: 90d # Default for movies
tv_retention: 120d # Default for TV shows
advanced_rules:
# Highest priority: Preserve important content by tag
- name: Keep Forever
type: tag
enabled: true
tag: keep
retention: never # Never delete
# Medium priority: Guest users get shorter retention
- name: Guest Users
type: user
enabled: true
users:
- username: guest
retention: 7d
require_watched: true
# Lower priority: Auto-cleanup watched content
- name: Watched Cleanup
type: watched
enabled: true
retention: 30d
require_watched: true
# Fallback: Default retention applies if no rules matchAll API endpoints (except /health and /api/auth/login) require JWT authentication.
POST /api/auth/login
Request:
{
"username": "admin",
"password": "changeme"
}Response:
{
"token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."
}Use the token in subsequent requests:
curl -H "Authorization: Bearer YOUR_TOKEN" http://localhost:8080/api/endpointGET /health
Response:
{
"status": "ok",
"uptime": "5.803388477s",
"version": "1.0.0-dev"
}GET /api/media/movies
Query parameters:
sort_by: Sort field (title,added_at,delete_after)order: Sort order (asc,desc)status: Filter by status (all,leaving_soon,excluded)
Response:
{
"movies": [
{
"id": "radarr-123",
"type": "movie",
"title": "Example Movie",
"year": 2023,
"added_at": "2024-01-01T00:00:00Z",
"last_watched": "2024-01-15T00:00:00Z",
"watch_count": 2,
"file_size": 4294967296,
"quality_tag": "Bluray-1080p",
"is_excluded": false,
"is_requested": false,
"delete_after": "2024-04-01T00:00:00Z",
"days_until_due": 30
}
],
"total": 1
}GET /api/media/shows
Query parameters: Same as movies
Response: Similar to movies but with type: "tv_show"
GET /api/media/leaving-soon
Returns media items that will be deleted soon (within leaving_soon_days threshold).
Response:
{
"media": [...],
"total": 5
}GET /api/media/{id}
Returns details for a specific media item.
POST /api/media/{id}/exclude
Excludes a media item from automated deletion.
Request:
{
"reason": "Personal favorite"
}Response:
{
"success": true,
"message": "Exclusion added"
}DELETE /api/media/{id}/exclude
Removes an exclusion, allowing the item to be deleted again.
Response:
{
"success": true,
"message": "Exclusion removed"
}DELETE /api/media/{id}
Deletes a media item from all services (Radarr/Sonarr, Jellyfin).
Query parameters:
dry_run=true: Preview deletion without actually deleting
Response:
{
"success": true,
"message": "Media deleted successfully",
"dry_run": false
}POST /api/sync/full
Triggers a complete synchronization of all media from all services.
Response:
{
"success": true,
"message": "Full sync started"
}POST /api/sync/incremental
Triggers a quick sync of watch history data only.
Response:
{
"success": true,
"message": "Incremental sync started"
}GET /api/sync/status
Returns the current sync engine status.
Response:
{
"running": true,
"media_count": 1523,
"last_full_sync": "2024-11-02T10:30:00Z",
"last_incr_sync": "2024-11-02T11:45:00Z",
"full_interval_seconds": 3600,
"incr_interval_seconds": 900,
"movies_count": 842,
"tv_shows_count": 681,
"excluded_count": 15
}GET /api/jobs
Returns all job execution history.
Response:
{
"jobs": [
{
"id": "uuid",
"type": "full_sync",
"status": "completed",
"started_at": "2024-11-02T10:30:00Z",
"completed_at": "2024-11-02T10:35:23Z",
"duration_ms": 323000,
"summary": {
"movies": 842,
"tv_shows": 681,
"total_media": 1523
}
}
],
"total": 1
}GET /api/jobs/{id}
Returns details for a specific job.
GET /api/jobs/latest
Returns the most recent job execution.
oxicleanarr/
├── cmd/
│ └── oxicleanarr/
│ └── main.go # Application entry point
├── internal/
│ ├── api/
│ │ ├── handlers/ # HTTP request handlers
│ │ ├── middleware/ # Authentication, logging, recovery
│ │ └── router.go # Route definitions
│ ├── cache/ # In-memory caching
│ ├── config/ # Configuration management
│ ├── services/ # Business logic
│ ├── storage/ # File-based persistence
│ └── utils/ # Utilities (logging, JWT)
├── config/
│ └── config.yaml # Configuration file
├── data/
│ ├── exclusions.json # Media exclusions
│ └── jobs.json # Job history
└── README.md
go build -o oxicleanarr cmd/oxicleanarr/main.gogo run cmd/oxicleanarr/main.go# Test health endpoint
curl http://localhost:8080/health
# Test login
curl -X POST http://localhost:8080/api/auth/login \
-H "Content-Type: application/json" \
-d '{"username":"admin","password":"changeme"}'
# Test with authentication
TOKEN=$(curl -s -X POST http://localhost:8080/api/auth/login \
-H "Content-Type: application/json" \
-d '{"username":"admin","password":"changeme"}' | jq -r .token)
curl -H "Authorization: Bearer $TOKEN" http://localhost:8080/api/endpoint- Phase 1: Foundation - Configuration, authentication, API framework
- Phase 2: Media Operations - Syncing, analysis, cleanup automation
- Phase 3: Management UI - Web interface for monitoring and control
- JWT tokens for API authentication
- Configurable token expiration (default: 24 hours)
- CORS support for web UI integration
⚠️ Important: Passwords are stored in plain text inconfig/config.yaml- protect this file with appropriate permissions
MIT License
Contributions are welcome! Please feel free to submit a Pull Request.
