Automated torrent management for qBittorrent with Sonarr/Radarr compatibility.
This tool automates torrent cleanup in qBittorrent based on ratio and seeding time. It's designed to work alongside Sonarr/Radarr without breaking their imports, and it can handle private and public trackers with different rules.
The main benefit is that you can maintain good ratios on private trackers while automatically cleaning up public torrents more aggressively. Everything runs in Docker and persists state using SQLite, so tracking continues even after restarts.
- Smart Cleanup - Removes torrents when they hit ratio or time limits without interfering with Sonarr/Radarr imports
- Private/Public Differentiation - Different rules for private vs public trackers to maintain good standing
- High Performance - Uses SQLite with indexed queries for instant operations even with thousands of torrents
- Orphaned File Cleanup - Identifies and removes files on disk that aren't tracked by any active torrent
- FileFlows Protection - Won't delete torrents while files are being post-processed
- Force Delete - Can remove stuck torrents that meet criteria but won't auto-pause
- Stalled Detection - Cleans up downloads that are stuck without progress
- Persistent State - Tracks torrent history across restarts using SQLite database
- Manual Trigger - Run cleanup on-demand via Docker signals
docker run -d \
--name qbt-cleanup \
--restart unless-stopped \
-v /path/to/config:/config \
-e QB_HOST=192.168.1.100 \
-e QB_PORT=8080 \
-e QB_USERNAME=admin \
-e QB_PASSWORD=adminadmin \
-e PRIVATE_RATIO=2.0 \
-e PRIVATE_DAYS=14 \
-e PUBLIC_RATIO=1.0 \
-e PUBLIC_DAYS=3 \
ghcr.io/regix1/qbittorrent-cleanup:latestNote: For orphaned file cleanup, also mount your download directories at the same path as qBittorrent:
# Mount at the SAME path that qBittorrent uses!
# If qBittorrent has: -v /path/to/downloads:/downloads
# Then use: -v /path/to/downloads:/downloads (NOT /data/downloads)
-v /path/to/downloads:/downloads \
-e CLEANUP_ORPHANED_FILES=true \
-e ORPHANED_SCAN_DIRS=/downloads| Variable | Description | Default |
|---|---|---|
QB_HOST |
qBittorrent WebUI host | localhost |
QB_PORT |
qBittorrent WebUI port | 8080 |
QB_USERNAME |
qBittorrent username | admin |
QB_PASSWORD |
qBittorrent password | adminadmin |
QB_VERIFY_SSL |
Verify SSL certificate | false |
| Variable | Description | Default |
|---|---|---|
FALLBACK_RATIO |
Default ratio if not set in qBittorrent | 1.0 |
FALLBACK_DAYS |
Default days if not set in qBittorrent | 7 |
PRIVATE_RATIO |
Ratio requirement for private torrents | FALLBACK_RATIO |
PRIVATE_DAYS |
Seeding days for private torrents | FALLBACK_DAYS |
PUBLIC_RATIO |
Ratio requirement for public torrents | FALLBACK_RATIO |
PUBLIC_DAYS |
Seeding days for public torrents | FALLBACK_DAYS |
| Variable | Description | Default |
|---|---|---|
DELETE_FILES |
Delete files when removing torrents | true |
DRY_RUN |
Test mode without actual deletions | false |
SCHEDULE_HOURS |
Hours between cleanup runs | 24 |
RUN_ONCE |
Run once and exit | false |
| Variable | Description | Default |
|---|---|---|
CHECK_PRIVATE_PAUSED_ONLY |
Only check paused private torrents for deletion | false |
CHECK_PUBLIC_PAUSED_ONLY |
Only check paused public torrents for deletion | false |
FORCE_DELETE_PRIVATE_AFTER_HOURS |
Force delete private torrents that meet criteria but won't pause (hours) | 0 (disabled) |
FORCE_DELETE_PUBLIC_AFTER_HOURS |
Force delete public torrents that meet criteria but won't pause (hours) | 0 (disabled) |
CLEANUP_STALE_DOWNLOADS |
Enable stalled download cleanup | false |
MAX_STALLED_PRIVATE_DAYS |
Maximum days private torrents can be stalled | 3 |
MAX_STALLED_PUBLIC_DAYS |
Maximum days public torrents can be stalled | 3 |
Understanding Force Delete:
Force delete handles "stuck" torrents that meet your ratio/time criteria but qBittorrent refuses to auto-pause. This can happen when:
- qBittorrent's share limits are disabled or set higher than yours
- The torrent is configured to ignore share limits
- There's a mismatch between qBittorrent settings and your cleanup rules
How it works:
- Tool checks if torrent meets your cleanup criteria (ratio AND/OR seeding time)
- If
CHECK_*_PAUSED_ONLY=true, it waits for qBittorrent to auto-pause the torrent first - If the torrent is still active after X hours despite meeting criteria, force delete kicks in
- The torrent gets deleted even if not paused
Example scenario:
# Your settings
- CHECK_PRIVATE_PAUSED_ONLY=true
- FORCE_DELETE_PRIVATE_AFTER_HOURS=48
# What happens:
# Day 1: Torrent reaches 2.0 ratio → Meets criteria, tool waits for qBittorrent to pause it
# Day 2: Still seeding (qBittorrent won't pause) → Still waiting
# Day 3 (48h later): Still seeding → Force delete removes itSet to 0 to disable force delete and only remove torrents that qBittorrent has already paused.
| Variable | Description | Default |
|---|---|---|
IGNORE_QBT_RATIO_PRIVATE |
Ignore qBittorrent's ratio for private | false |
IGNORE_QBT_RATIO_PUBLIC |
Ignore qBittorrent's ratio for public | false |
IGNORE_QBT_TIME_PRIVATE |
Ignore qBittorrent's time for private | false |
IGNORE_QBT_TIME_PUBLIC |
Ignore qBittorrent's time for public | false |
| Variable | Description | Default |
|---|---|---|
FILEFLOWS_ENABLED |
Enable FileFlows protection | false |
FILEFLOWS_HOST |
FileFlows server host | localhost |
FILEFLOWS_PORT |
FileFlows server port | 19200 |
FILEFLOWS_TIMEOUT |
API timeout in seconds | 10 |
FILEFLOWS_RECENT_MINUTES |
Minutes to protect recently processed files | 10 |
| Variable | Description | Default |
|---|---|---|
CLEANUP_ORPHANED_FILES |
Enable orphaned file detection and cleanup | false |
ORPHANED_SCAN_DIRS |
Comma-separated list of directories to scan (container paths) | (empty) |
ORPHANED_MIN_AGE_HOURS |
Minimum age in hours before a file is considered orphaned | 1.0 |
ORPHANED_SCHEDULE_DAYS |
Run orphaned cleanup every X days (independent of main cleanup schedule) | 7 |
Important: This feature recursively scans specified directories for files/folders that exist on disk but aren't being tracked by any active torrent in qBittorrent (whether downloading, seeding, or paused). Files are only removed if they meet BOTH criteria:
- Not tracked by any active torrent in qBittorrent
- Not modified/accessed for the configured minimum age (default 1 hour)
This dual-check safety mechanism is useful for cleaning up leftover data from torrents that were removed incorrectly or files that were manually modified, while protecting recently active files.
Separate Schedule: Orphaned file cleanup runs on its own schedule (default: every 7 days), independent of the main torrent cleanup schedule. This prevents the potentially time-consuming filesystem scan from running too frequently. The schedule is tracked in the database, so it persists across container restarts.
Recursive Scanning: The scanner will walk through ALL subdirectories under the specified path. For example, if you specify /data/incomplete, it will scan:
/data/incomplete/anime/torrent1//data/incomplete/movies/torrent2//data/incomplete/tvshows/season1/episode.mkv- And all other files and folders at any depth
Volume Mounting Required:
You must mount your download directories into the container for this feature to work. The paths in ORPHANED_SCAN_DIRS should match the paths INSIDE the Docker container, not your host paths.
The download directories MUST be mounted at the SAME PATH in both qBittorrent and qbt-cleanup containers. If the paths don't match, the scanner will incorrectly mark all files as orphaned!
Incorrect (will delete everything!):
qbittorrent:
volumes:
- /host/downloads:/downloads # Path in qBittorrent: /downloads
qbt-cleanup:
volumes:
- /host/downloads:/data/downloads # Path in qbt-cleanup: /data/downloads ❌ DIFFERENT!
environment:
- ORPHANED_SCAN_DIRS=/data/downloadsCorrect:
qbittorrent:
volumes:
- /host/downloads:/downloads # Path in qBittorrent: /downloads
qbt-cleanup:
volumes:
- /host/downloads:/downloads # Path in qbt-cleanup: /downloads ✅ SAME!
environment:
- ORPHANED_SCAN_DIRS=/downloadsThe tool will abort the orphaned scan if it detects a path mismatch, preventing accidental deletion of all files.
Example:
volumes:
# Mount your actual download directories
- /path/on/host/downloads:/data/downloads
environment:
- CLEANUP_ORPHANED_FILES=true
# Use the container paths (after the colon in volumes)
- ORPHANED_SCAN_DIRS=/data/downloadsMultiple Directories:
volumes:
- /host/downloads/complete:/data/complete
- /host/downloads/movies:/data/movies
- /host/downloads/tv:/data/tv
environment:
- CLEANUP_ORPHANED_FILES=true
- ORPHANED_SCAN_DIRS=/data/complete,/data/movies,/data/tvMaintain good ratios on private trackers while cleaning up public torrents more aggressively:
environment:
- PRIVATE_RATIO=2.0
- PRIVATE_DAYS=30
- PUBLIC_RATIO=1.0
- PUBLIC_DAYS=3
- CHECK_PRIVATE_PAUSED_ONLY=true # Wait for qBittorrent to pause
- CHECK_PUBLIC_PAUSED_ONLY=false # Clean immediatelyProtect files during post-processing, but force delete if qBittorrent won't pause them:
environment:
# FileFlows protection
- FILEFLOWS_ENABLED=true
- FILEFLOWS_HOST=192.168.1.200
- FILEFLOWS_PORT=19200
# Cleanup settings
- CHECK_PRIVATE_PAUSED_ONLY=true # Wait for qBittorrent to pause
- PRIVATE_RATIO=2.0
- PRIVATE_DAYS=14
# Force delete after 48 hours if qBittorrent won't pause
# Useful when qBittorrent share limits don't match your cleanup rules
- FORCE_DELETE_PRIVATE_AFTER_HOURS=48
- FORCE_DELETE_PUBLIC_AFTER_HOURS=24Why force delete is useful here:
- qBittorrent may have share limits disabled or set differently
- Some torrents might be configured to ignore share limits
- Force delete ensures cleanup happens even if qBittorrent won't cooperate
- FileFlows protection still prevents deletion during active processing
Remove completed torrents quickly to save disk space:
environment:
- PRIVATE_RATIO=1.0
- PRIVATE_DAYS=7
- PUBLIC_RATIO=0.5
- PUBLIC_DAYS=1
- CLEANUP_STALE_DOWNLOADS=true
- MAX_STALLED_PUBLIC_DAYS=2Clean up leftover files that aren't tracked by any active torrent (runs weekly by default):
volumes:
# Mount your download directories so the container can access them
- /path/to/downloads:/downloads
- /path/to/completed:/downloads/completed
environment:
- CLEANUP_ORPHANED_FILES=true
# Use container paths (the paths after the : in volumes above)
- ORPHANED_SCAN_DIRS=/downloads
- ORPHANED_MIN_AGE_HOURS=1.0 # Only remove files untouched for 1+ hours
- ORPHANED_SCHEDULE_DAYS=7 # Run every 7 days (weekly)
- DRY_RUN=true # IMPORTANT: Test first to see what would be removed!How it works:
- Mounts your actual download directories into the container
- Scans the container paths for files/folders
- Checks each file against TWO criteria:
- Not tracked by any active torrent in qBittorrent
- Not modified/accessed for the minimum age (default 1 hour)
- Removes only files that meet BOTH criteria
Important: Always test with DRY_RUN=true first to verify what will be deleted!
Orphaned File Logs:
The orphaned file scanner creates one timestamped log file per scan in /config/:
-
orphaned_dryrun_YYYY-MM-DD_HH-MM-SS.log- Dry run results- Shows exactly what would be deleted
- Includes file/directory sizes in GB
- Example:
orphaned_dryrun_2025-11-06_04-16-33.log
-
orphaned_cleanup_YYYY-MM-DD_HH-MM-SS.log- Actual deletion results- Records what was deleted
- Includes file/directory sizes in GB
- Example:
orphaned_cleanup_2025-11-06_14-30-45.log
Each scan creates a separate log file, making it easy to track history and review results.
Example workflow:
# 1. Run dry run to see what would be deleted
docker-compose up -d # with DRY_RUN=true
# 2. Review the output (check the most recent file)
ls -lt /home/torrent/qbt-cleanup/config/orphaned_dryrun_*.log | head -1
cat /home/torrent/qbt-cleanup/config/orphaned_dryrun_2025-11-06_04-16-33.log
# 3. If everything looks good, disable dry run
# Edit docker-compose.yml: DRY_RUN=false
docker-compose up -dversion: '3'
services:
qbittorrent:
image: hotio/qbittorrent:latest
container_name: qbittorrent
environment:
- PUID=1000
- PGID=1000
- TZ=America/New_York
volumes:
- ./config:/config
- ./downloads:/downloads # ← Note this path
ports:
- 8080:8080
qbt-cleanup:
image: ghcr.io/regix1/qbittorrent-cleanup:latest
container_name: qbt-cleanup
restart: unless-stopped
depends_on:
- qbittorrent
volumes:
- ./qbt-cleanup/config:/config
# ⚠️ CRITICAL: Must use SAME path as qBittorrent container!
# qBittorrent uses /downloads, so we use /downloads (NOT /data/downloads)
- ./downloads:/downloads
environment:
# Connection
- QB_HOST=qbittorrent
- QB_PORT=8080
- QB_USERNAME=admin
- QB_PASSWORD=adminadmin
# Cleanup rules
- PRIVATE_RATIO=2.0
- PRIVATE_DAYS=14
- PUBLIC_RATIO=1.0
- PUBLIC_DAYS=3
# Behavior
- DELETE_FILES=true
- CHECK_PRIVATE_PAUSED_ONLY=true
- CHECK_PUBLIC_PAUSED_ONLY=false
- SCHEDULE_HOURS=6
# Advanced features (optional)
- FORCE_DELETE_PRIVATE_AFTER_HOURS=48
- FORCE_DELETE_PUBLIC_AFTER_HOURS=12
- CLEANUP_STALE_DOWNLOADS=true
- MAX_STALLED_DAYS=3
# Orphaned file cleanup (optional)
# - CLEANUP_ORPHANED_FILES=true
# - ORPHANED_SCAN_DIRS=/downloads # Must match the volume mount path!
# - ORPHANED_SCHEDULE_DAYS=7 # Run every 7 days (weekly)Trigger an immediate cleanup without waiting for the schedule:
docker kill --signal=SIGUSR1 qbt-cleanupView real-time logs:
docker logs -f qbt-cleanupReview orphaned file cleanup operations:
# List all orphaned scan logs
ls -lth ./qbt-cleanup/config/orphaned_*.log
# View the most recent dry run log
cat $(ls -t ./qbt-cleanup/config/orphaned_dryrun_*.log | head -n1)
# View the most recent cleanup log
cat $(ls -t ./qbt-cleanup/config/orphaned_cleanup_*.log | head -n1)
# Search for specific files in logs
grep "Black.Phone" ./qbt-cleanup/config/orphaned_*.logThese logs are stored in your mounted /config directory and persist across container restarts. Each scan creates a new timestamped log file.
Protect specific torrents from automatic deletion using the built-in control utility.
Use the interactive selector to see all torrents and toggle blacklist status by number:
docker exec -it qbt-cleanup qbt-cleanup-ctl selectThis displays a numbered list like:
# Status Name Hash
==========================================================================================
1 [ ] Ubuntu 22.04 LTS a1b2c3d4e5f6...
2 [B] Important Movie (2023) b2c3d4e5f6a1...
3 [ ] My Favorite Show S01E01 c3d4e5f6a1b2...
[B] = Already blacklisted
Enter torrent numbers to toggle blacklist (space-separated, e.g., '1 3 5')
Or enter 'q' to quit without changes
Select torrents: 1 3
This will toggle the blacklist status - adding if not blacklisted, removing if already blacklisted.
For scripting or specific hash-based operations:
# Add a torrent to the blacklist (prevents deletion)
docker exec qbt-cleanup qbt-cleanup-ctl blacklist add <TORRENT_HASH>
# Add with name and reason (optional)
docker exec qbt-cleanup qbt-cleanup-ctl blacklist add <TORRENT_HASH> --name "Important Movie" --reason "Keep forever"
# List all blacklisted torrents
docker exec qbt-cleanup qbt-cleanup-ctl blacklist list
# Remove a torrent from the blacklist
docker exec qbt-cleanup qbt-cleanup-ctl blacklist remove <TORRENT_HASH>
# Clear entire blacklist
docker exec qbt-cleanup qbt-cleanup-ctl blacklist clear -y
# Check status and statistics
docker exec qbt-cleanup qbt-cleanup-ctl status
# List all tracked torrents
docker exec qbt-cleanup qbt-cleanup-ctl list --limit 10Note: The interactive select command requires the -it flags for Docker to enable interactive input.
The tool uses SQLite for state management with optimized algorithms for fast operation even with large libraries:
| Torrents | Load Time | Save Time | Memory Usage |
|---|---|---|---|
| 500 | ~5ms | ~2ms per update | Minimal |
| 5,000 | ~10ms | ~2ms per update | Minimal |
| 50,000 | ~50ms | ~2ms per update | Minimal |
- Transaction Batching: State updates are batched into single transactions for efficiency
- O(1) Path Matching: Orphaned file scanner uses optimized index for near-constant time lookups
- FileFlows Cache Safety: Falls back to cached data on API failure to maintain protection
- Indexed Queries: SQLite database uses indexes for instant lookups
- Database: SQLite with indexed queries and WAL mode
- Location:
/config/qbt_cleanup_state.db - Migration: Automatic from JSON/MessagePack formats
- Cleanup: Automatically removes torrents that no longer exist
- Blacklist: Permanently stored in database, persists across restarts
The tool uses a modular Python package structure:
- src/qbt_cleanup/ - Main package directory
- state.py - SQLite database for persistent state management
- client.py - qBittorrent API wrapper with retry logic
- classifier.py - Torrent categorization logic
- fileflows.py - FileFlows API integration
- orphaned_scanner.py - Orphaned file detection and cleanup
- cleanup.py - Main orchestration
- config.py - Environment variable parsing
- main.py - Entry point and scheduler
- ctl.py - Control utility for runtime management
- Connect to qBittorrent and FileFlows (if enabled)
- Fetch all torrents and their metadata
- Update SQLite database with current torrent states
- Remove database entries for non-existent torrents
- Check if torrents are blacklisted
- Classify torrents based on configured rules
- Check FileFlows protection status
- Delete torrents that meet criteria
- Run orphaned file cleanup (if enabled):
- Collect all active torrent file paths from qBittorrent
- Recursively scan configured directories and all subdirectories
- For each file/folder found, check if it's tracked by any active torrent
- Check file modification time (must be older than minimum age)
- Remove orphaned files/folders that meet both criteria
- Commit database changes
A torrent is deleted when it meets ANY of these conditions:
-
Standard Deletion: Ratio OR seeding time exceeded AND either:
CHECK_*_PAUSED_ONLY=false(delete immediately when criteria met)CHECK_*_PAUSED_ONLY=trueAND torrent is paused (wait for qBittorrent to pause it first)
-
Force Delete: When
CHECK_*_PAUSED_ONLY=truebut qBittorrent won't pause the torrent:- Torrent meets ratio/time criteria
- Has been meeting criteria for longer than
FORCE_DELETE_*_AFTER_HOURS - Gets deleted even if still actively seeding
- Use this to handle torrents that are "stuck" seeding
-
Stalled Cleanup: Download stalled for too long (if enabled)
- Torrent is incomplete (downloading state)
- No download progress for X days
- Cleans up stuck/dead downloads
Deletion Flow Example:
Torrent reaches 2.0 ratio at 10:00 AM
├─ CHECK_PRIVATE_PAUSED_ONLY=false → Delete immediately
├─ CHECK_PRIVATE_PAUSED_ONLY=true
│ ├─ qBittorrent pauses it → Delete immediately
│ └─ qBittorrent doesn't pause it
│ ├─ FORCE_DELETE_PRIVATE_AFTER_HOURS=0 → Never delete (wait forever)
│ └─ FORCE_DELETE_PRIVATE_AFTER_HOURS=48 → Delete after 48 hours (at 10:00 AM in 2 days)
Protection from deletion:
- Files are being processed by FileFlows
- They don't meet any deletion criteria
- They're actively downloading (except stalled torrents)
- They are on the blacklist (manually protected)
No, this is not possible. The BitTorrent protocol requires exact file names and structures to match the torrent's metadata hash. If you rename files:
- The torrent client cannot verify pieces against the metadata
- The torrent will show as incomplete and stop seeding
- You would need to create a new torrent with the renamed files
This is a fundamental limitation of how BitTorrent works.
Private trackers typically have strict ratio requirements and track your account's performance. Public trackers generally don't have these requirements. This tool allows you to:
- Maintain higher ratios on private trackers to keep good standing
- Clean up public torrents more aggressively to save space
- Use different strategies based on tracker type
SQLite provides:
- Indexed queries for instant lookups
- Atomic updates without rewriting the entire file
- Automatic cleanup of non-existent torrents
- Crash recovery and data integrity
- Efficient storage even with thousands of torrents
All state is preserved in the SQLite database at /config/qbt_cleanup_state.db. The tool will:
- Continue tracking stalled durations
- Remember previous torrent states
- Automatically migrate from JSON if upgrading
- qBittorrent 4.3.0+ (5.0.0+ for enhanced private tracker detection)
- Sonarr/Radarr - Doesn't interfere with their import process
- FileFlows - Optional integration for processing protection
- Docker/Docker Compose - Primary deployment method
- Kubernetes - Configure via environment variables
If you see permission errors, ensure the config directory is writable:
# Fix permissions (adjust UID:GID to match your setup)
sudo chown -R 1000:1000 ./qbt-cleanup/config
sudo chmod 755 ./qbt-cleanup/configFor self-signed certificates:
environment:
- QB_VERIFY_SSL=falseCheck database status:
# View database size
ls -lh ./qbt-cleanup/config/qbt_cleanup_state.db
# Check if database is accessible
docker exec qbt-cleanup ls -la /config/The tool uses two methods to detect private torrents:
- qBittorrent 5.0.0+
isPrivatefield (preferred) - Tracker message analysis (fallback)
If detection isn't working correctly, check your qBittorrent version and tracker configuration.
Contributions are welcome. The modular architecture makes it straightforward to:
- Add new features or integrations
- Improve classification logic
- Enhance error handling
- Add support for other torrent clients
MIT License - See LICENSE file for details
Built for the self-hosting community to provide better torrent management that works seamlessly with the arr ecosystem.