Sign inSign up

gfsolone/transmissioncleaner

By gfsolone

β€’Updated 7 days ago

Automated torrent cleanup for Transmission. Docker container with web dashboard, cron scheduler, and

Image
0

3.2K

gfsolone/transmissioncleaner repository overview

⁠🧹 Transmission Cleaner

Docker Build/Publish GitHub release Docker Pulls GitHub license

Automated cleanup for one or more Transmission⁠ instances. Removes torrents that have been seeding (or are stopped) for longer than a configurable number of days, optionally enforcing a minimum upload ratio before deletion. Ships as a Docker container with a built-in web dashboard, scheduled runs, and multi-channel notifications.


⁠Pre-built images

Images are built automatically for linux/amd64 and linux/arm64 on every push to main or the cleanup-history test branch (tagged dev) and on every release tag (tagged latest + version number). Current feature version: 1.1.0.

RegistryImage
GitHub Container Registryghcr.io/gioxx/transmissioncleaner:latest
Docker Hubgfsolone/transmissioncleaner:latest

For pre-release testing, use ghcr.io/gioxx/transmissioncleaner:dev or gfsolone/transmissioncleaner:dev.

# GHCR
docker pull ghcr.io/gioxx/transmissioncleaner:latest

# Docker Hub
docker pull gfsolone/transmissioncleaner:latest

⁠Features

  • Multi-server β€” monitor and clean multiple Transmission instances from a single container
  • Rule-based deletion β€” age threshold + optional minimum ratio + completeness guard (incomplete downloads are never touched)
  • Scheduled runs β€” any cron expression, default 50 7 * * 3 (every Wednesday at 07:50)
  • Web dashboard β€” live torrent table with delete/keep badges, countdown to next run, last-run log
  • Dry-run mode β€” preview what would be deleted without touching anything, both via env var and from the UI
  • Notifications β€” Telegram, Resend⁠ and SMTP, independently enabled, sent in parallel
  • REST API β€” /api/status and /api/torrents for scripting or external integrations
  • Health check β€” built-in Docker health check on /api/status
  • Persistent cleanup history β€” effective runs are stored in /config/cleanups.db and searchable from /history; dry runs are excluded
  • History importer β€” import legacy .eml notifications with python scripts/import_history.py --db /config/cleanups.db /path/to/emails

⁠Requirements

  • Docker 24+ (or Podman with Docker Compose support)
  • Network access from the container to each Transmission RPC endpoint (default port 9091)

No Python installation required on the host.


⁠Quick start

⁠1 β€” Clone or download
git clone https://github.com/gioxx/TransmissionCleaner.git
cd TransmissionCleaner
⁠2 β€” Create your config files
cp stack.env.example stack.env
cp config/servers.json.example config/servers.json
cp .env.example .env

Edit config/servers.json with your Transmission server(s) β€” the file supports // and /* */ comments:

[
  {
    "name": "Home",
    "host": "192.168.1.10",
    "port": 9091,
    "user": "admin",
    "password": "yourpassword"
  }
]

Then set your cleanup rules and notification settings in stack.env.

⁠3 β€” Start
docker compose up -d

The pre-built image is pulled automatically from GHCR. Open http://your-host:8080⁠ β€” done.

To build from source instead, uncomment build: . in docker-compose.yml and run docker compose up -d --build.


⁠Deployment options

The repository ships a ready-to-use docker-compose.yml that pulls the pre-built image from GHCR. Copy the examples, fill in your values and run:

cp stack.env.example stack.env
cp config/servers.json.example config/servers.json
cp .env.example .env          # only if you need a port other than 8080
# edit the three files, then:
docker compose up -d

The config/ directory is mounted as a volume, so servers.json persists across container restarts and updates. It also contains cleanups.db, the SQLite database with the cleanup history. Include this file in backups.

Full docker-compose.yml for reference:

services:
  transmission-cleaner:
    image: ghcr.io/gioxx/transmissioncleaner:latest
    # build: .   # uncomment to build from source instead
    container_name: transmission-cleaner
    ports:
      - "${HOST_PORT:-8080}:8080"  # set HOST_PORT in .env
    volumes:
      - ./config:/config   # servers.json lives here
    env_file:
      - stack.env
    restart: unless-stopped
    healthcheck:
      test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8080/api/status')"]
      interval: 30s
      timeout: 10s
      retries: 3
      start_period: 10s

⁠Option B β€” Docker Run

If you prefer a one-liner, build the image first and then run it:

# Run using the pre-built GHCR image
docker run -d \
  --name transmission-cleaner \
  --restart unless-stopped \
  -p 8080:8080 \
  -v "$(pwd)/config:/config" \
  --env-file stack.env \
  ghcr.io/gioxx/transmissioncleaner:latest

For a quick single-server test without a config file, use the inline fallback:

docker run -d \
  --name transmission-cleaner \
  --restart unless-stopped \
  -p 8080:8080 \
  -e TRANSMISSION_SERVERS='[{"name":"Home","host":"192.168.1.10","port":9091,"user":"admin","password":"s3cr3t"}]' \
  -e DAYS_TO_WAIT=10 \
  -e CLEANUP_SCHEDULE="50 7 * * 3" \
  -e TZ="Europe/Rome" \
  transmission-cleaner:latest

⁠Option C β€” Portainer (Stacks)
  1. In Portainer, go to Stacks β†’ Add stack.
  2. Give the stack a name, e.g. transmission-cleaner.
  3. Choose Web editor and paste the docker-compose.yml content below.
  4. Scroll to Environment variables and click Add an environment variable for each setting, or switch to Advanced mode and paste the contents of stack.env.example directly.
  5. Click Deploy the stack.
services:
  transmission-cleaner:
    image: transmission-cleaner:latest
    build:
      context: https://github.com/gioxx/TransmissionCleaner.git
    container_name: transmission-cleaner
    ports:
      - "8080:8080"
    volumes:
      - /your/host/path/config:/config   # put servers.json here
    environment:
      SERVERS_FILE: "/config/servers.json"
      DAYS_TO_WAIT: "10"
      MIN_RATIO: "0"
      CLEANUP_SCHEDULE: "50 7 * * 3"
      TZ: "Europe/Rome"
      DRY_RUN: "false"
      NOTIFY_ALWAYS: "false"
      TELEGRAM_ENABLED: "false"
      TELEGRAM_BOT_TOKEN: ""
      TELEGRAM_CHAT_ID: ""
      RESEND_ENABLED: "false"
      RESEND_API_KEY: ""
      RESEND_FROM: "[email protected]"
      RESEND_TO: "[email protected]"
      SMTP_ENABLED: "false"
      SMTP_HOST: "localhost"
      SMTP_PORT: "25"
      SMTP_FROM: "[email protected]"
      SMTP_TO: "[email protected]"
      SMTP_TLS: "false"
    restart: unless-stopped

Portainer tip β€” use the Secrets feature or Portainer's environment variable editor to avoid putting passwords in plain text inside the stack definition. For servers.json, bind-mount a directory from the host (e.g. /opt/transmission-cleaner/config) so credentials stay on the host filesystem and survive stack redeployments.


⁠Configuration reference

All configuration is done via environment variables (in your local stack.env, passed with --env-file, or set individually with -e). Use stack.env.example as the starting template.

⁠Transmission servers

Servers are configured via a JSON file mounted into the container. This is the recommended approach as it supports comments and multi-line formatting.

VariableDefaultDescription
SERVERS_FILE/config/servers.jsonPath to the servers config file inside the container

config/servers.json format (supports // and /* */ comments):

[
  {
    // Display name shown in the web UI and notifications
    "name": "S1",

    // Hostname or IP address of the Transmission instance
    "host": "192.168.1.10",

    // RPC port β€” optional, default 9091
    "port": 9091,

    // Leave empty if authentication is disabled
    "user": "admin",
    "password": "secret"
  },
  {
    "name": "S2",
    "host": "nas.local",
    "port": 9091,
    "user": "admin",
    "password": "secret"
  }
]

Copy the provided example to get started:

cp config/servers.json.example config/servers.json

Fallback β€” inline env var

If SERVERS_FILE does not exist, the app falls back to the TRANSMISSION_SERVERS environment variable. Useful for quick tests or single-server setups that don't need a mounted file:

TRANSMISSION_SERVERS=[{"name":"Home","host":"192.168.1.10","port":9091,"user":"admin","password":"secret"}]

⁠Cleanup rules
VariableDefaultDescription
DAYS_TO_WAIT10Delete torrents that have been seeding/stopped for at least this many days
MIN_RATIO0Minimum upload ratio required before deletion. 0 disables this check
CLEANUP_SCHEDULE50 7 * * 3Cron expression for the automatic run
TZUTCIANA timezone (e.g. Europe/Rome) the cron schedule and displayed times are evaluated in. Without this, the container defaults to UTC and CLEANUP_SCHEDULE/"Next run" will silently be off from your local time
DRY_RUNfalseIf true, log what would be deleted without actually deleting anything

Deletion criteria (all must be true):

  1. Torrent status is seeding or stopped
  2. Download is 100% complete (incomplete downloads are never touched)
  3. Torrent age β‰₯ DAYS_TO_WAIT
  4. If MIN_RATIO > 0: upload ratio β‰₯ MIN_RATIO

Cron schedule examples:

ExpressionMeaning
50 7 * * 3Every Wednesday at 07:50 (default)
0 3 * * *Every day at 03:00
0 6 * * 1Every Monday at 06:00
0 */6 * * *Every 6 hours
30 22 * * *Every day at 22:30

⁠Notifications

All three channels can be active simultaneously. By default, notifications are sent only when at least one torrent is deleted or an error occurs. Set NOTIFY_ALWAYS=true to receive a report on every run.

VariableDefaultDescription
NOTIFY_ALWAYSfalseSend notifications even when nothing was deleted
⁠Telegram
VariableDefaultDescription
TELEGRAM_ENABLEDfalseEnable Telegram notifications
TELEGRAM_BOT_TOKENβ€”Bot token from @BotFather⁠
TELEGRAM_CHAT_IDβ€”Target chat/channel ID

How to get your credentials:

  1. Open Telegram and search for @BotFather.
  2. Send /newbot and follow the prompts. Copy the token it gives you β†’ TELEGRAM_BOT_TOKEN.
  3. To get your personal chat ID, message @userinfobot β†’ TELEGRAM_CHAT_ID.
  4. For a group or channel, add the bot as admin, then check https://api.telegram.org/bot<TOKEN>/getUpdates after sending a message β€” look for "chat":{"id": ...}.
TELEGRAM_ENABLED=true
TELEGRAM_BOT_TOKEN=123456789:AABBCCDDeeffGGHHiijjKKLLmmNNoopp
TELEGRAM_CHAT_ID=-100123456789
⁠Resend

Resend⁠ is a developer-friendly transactional email API with a generous free tier (3 000 emails/month).

VariableDefaultDescription
RESEND_ENABLEDfalseEnable Resend notifications
RESEND_API_KEYβ€”API key from the Resend dashboard
RESEND_FROMβ€”Sender address (must be a verified domain in Resend)
RESEND_TOβ€”Recipient address
RESEND_ENABLED=true
RESEND_API_KEY=re_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
[email protected]
[email protected]
⁠SMTP

Use your own mail server, a relay, or a provider like Gmail, Fastmail, Mailgun, etc.

VariableDefaultDescription
SMTP_ENABLEDfalseEnable SMTP notifications
SMTP_HOSTlocalhostSMTP server hostname
SMTP_PORT25SMTP port
SMTP_USERβ€”Username (leave empty if not required)
SMTP_PASSWORDβ€”Password
SMTP_FROMβ€”Sender address
SMTP_TOβ€”Recipient address
SMTP_TLSfalsetrue = implicit TLS / SMTPS (port 465). false = plain or STARTTLS

Gmail example (App Password required β€” enable 2FA first):

SMTP_ENABLED=true
SMTP_HOST=smtp.gmail.com
SMTP_PORT=465
[email protected]
SMTP_PASSWORD=xxxx-xxxx-xxxx-xxxx
[email protected]
[email protected]
SMTP_TLS=true

Local relay / Postfix (no auth):

SMTP_ENABLED=true
SMTP_HOST=192.168.1.1
SMTP_PORT=25
[email protected]
[email protected]
SMTP_TLS=false

⁠Web UI port

The host port is configured via .env (not stack.env), because Docker Compose reads .env automatically for variable substitution in the compose file β€” env_file: only passes variables into the container and cannot affect port mapping.

cp .env.example .env
# then edit .env:
HOST_PORT=8081
FileVariableDefaultDescription
.envHOST_PORT8080Port exposed on the host

⁠Web dashboard

The dashboard is served at http://your-host:8080 and auto-refreshes every 60 seconds.

β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ 🧹 Transmission Cleaner        Next run: 05h 42m 18s   β”‚
β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€
β”‚ [β–Ά Run cleanup now]  [πŸ‘ Dry run]  [β†Ί Refresh]         β”‚
β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€
β”‚ Servers  β”‚ Torrents β”‚ To removeβ”‚  Keeping β”‚            β”‚
β”‚    2     β”‚    47    β”‚    8     β”‚    39    β”‚            β”‚
β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€
β”‚ βš™οΈ Settings                                              β”‚
β”‚  Retain: 10 days  Β·  Min ratio: disabled               β”‚
β”‚  Schedule: 0 6 * * *  Β·  Notifications: Telegram, SMTP β”‚
β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€
β”‚ πŸ“‘ S1 (192.168.1.10)             Connected  47 torrents β”‚
β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”β”‚
β”‚ β”‚ Name               β”‚ Status β”‚ Size β”‚ Ageβ”‚Ratioβ”‚     β”‚β”‚
β”‚ β”‚ Ubuntu 24.04 LTS   β”‚ seedingβ”‚ 4 GB β”‚ 15dβ”‚1.52 β”‚πŸ—‘ Delβ”‚β”‚
β”‚ β”‚ Fedora 41 Workstat β”‚ seedingβ”‚ 2 GB β”‚  3dβ”‚0.21 β”‚βœ“Keepβ”‚β”‚
β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”˜β”‚
β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€
β”‚ πŸ“‹ Last run log                                         β”‚
β”‚  Transmission Cleaner β€” 2025-06-15 06:00:01            β”‚
β”‚  === S1 (192.168.1.10) ===                             β”‚
β”‚  Removed: Ubuntu 24.04 LTS | age: 15d | ratio: 1.52   β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

Rows highlighted in red will be deleted on the next automatic run (or immediately if you click Run cleanup now).

⁠Manual controls
ButtonAction
Run cleanup nowExecutes the full cleanup immediately and sends notifications
Dry runFetches torrents and marks what would be deleted β€” no actual deletions, no notifications
RefreshReloads the page and queries all servers again
⁠Import legacy Gmail notifications

The history importer reads plain-text Gmail .eml exports matching the old Torrent deletion log subject. It imports the email date, complete message body, server/torrent details, and deletion count into the same persistent SQLite history. Imported entries are marked imported and are deduplicated by the email Message-ID (or a file hash when that header is unavailable).

The importer is a command-line utility and does not send notifications. It can read one file or every .eml file in a directory:

# From a source checkout, using a local database
python scripts/import_history.py \
  --db /config/cleanups.db \
  /path/to/exported-emails

# Preview the files without writing history
python scripts/import_history.py \
  --dry-run \
  --db /config/cleanups.db \
  "/path/to/Torrent deletion log.eml"

For a running Docker Compose deployment, copy the exports into the mounted config directory and run the importer inside the container:

mkdir -p config/import
cp /path/to/*.eml config/import/
docker compose exec transmission-cleaner \
  python scripts/import_history.py \
  --db /config/cleanups.db \
  /config/import

The importer creates the database if necessary. Existing history is retained; running the same import again reports duplicates and does not create new rows. Dry runs and imported legacy records are separate concepts: dry runs are never stored, while legacy emails are stored with the imported trigger.


⁠REST API

EndpointMethodDescription
/GETWeb dashboard (HTML)
/runPOSTTrigger cleanup (?dry=1 for dry run)
/api/statusGETScheduler and last-run metadata (JSON)
/api/torrentsGETFull torrent list per server (JSON)
/historyGETSearchable cleanup history (HTML)
/api/historyGETPaginated cleanup history search (JSON)

GET /api/status example:

{
  "next_run": "2025-06-16T06:00:00+02:00",
  "last_run": "2025-06-15T06:00:01.234567",
  "last_deleted": 3,
  "last_errors": 0,
  "schedule": "0 6 * * *",
  "dry_run": false
}

GET /api/torrents example:

[
  {
    "server": "S1",
    "connected": true,
    "error": "",
    "torrents": [
      {
        "name": "Ubuntu 24.04 LTS",
        "status": "seeding",
        "size": "4.00 GB",
        "age_days": 15,
        "ratio": 1.52,
        "progress": 100.0,
        "will_delete": true
      }
    ]
  }
]

Trigger a cleanup from the command line:

curl -X POST http://your-host:8080/run
# Dry run:
curl -X POST "http://your-host:8080/run?dry=1"

⁠Updating

git pull
docker compose up -d --build

Docker Compose will rebuild the image and recreate the container with zero downtime for the scheduler (the new container starts before the old one stops).


⁠Troubleshooting

⁠Cannot connect to Transmission
  • Verify the host in TRANSMISSION_SERVERS is reachable from inside the container.
    docker exec transmission-cleaner python -c \
      "import socket; socket.create_connection(('192.168.1.10', 9091), 3)"
    
  • If Transmission is on the same Docker host, use the host's LAN IP rather than localhost or 127.0.0.1 (which resolves to the container itself).
  • If both containers are on the same Docker network, use the service name as hostname.
⁠Telegram notifications not arriving
  • Confirm the bot has been started by sending /start to it.
  • For a group/channel, make sure the bot is an admin.
  • Test directly:
    curl "https://api.telegram.org/bot<TOKEN>/sendMessage" \
      -d chat_id=<CHAT_ID> \
      -d text="test"
    
⁠TRANSMISSION_SERVERS parsing error

The value must be valid JSON. Verify it with:

echo '$TRANSMISSION_SERVERS' | python3 -m json.tool

Common mistakes: single quotes inside the JSON string, trailing commas, missing brackets.

⁠View container logs
docker logs -f transmission-cleaner

⁠License

MIT⁠

Tag summary

Content type

Image

Digest

sha256:108947b38…

Size

58.6 MB

Last updated

3 months ago

docker pull gfsolone/transmissioncleaner