github.com/mjblack/arr_janitor

ArrJanitor — continuously watches Sonarr/Radarr download queues, removes and blocklists downloads that contain bad file extensions, and re-triggers a search when the episode/movie is released.

0 stars
0 dependents
License: MIT

Nothing has been indexed for 0.2.0 yet. The tag is recorded, its shard.yml has not been read, so the manifest and dependency list below are empty because they are unknown rather than because they are absent.

Installation

# Add this to your shard.yml
dependencies:
  arr_janitor:
    github: mjblack/arr_janitor
    version: ~> 0.2.0

Then run:

shards install

shard.yml

No shard.yml has been indexed for 0.2.0. You can read it on the repository.

Dependencies

Unknown: the shard.yml for this version has not been read yet.

README

This README is the one indexed from the repository at its latest ref, not from the tag for this version.

ArrJanitor

ArrJanitor is a long-running Crystal service that watches the download queues of your Sonarr and Radarr instances and cleans up junk downloads automatically.

For every queued/downloading/downloaded item it inspects the files inside the torrent (via the download client) and, when it finds a bad file extension (e.g. a .scr/.exe/.lnk decoy), it tells the *arr to:

  1. delete + blocklist the download (remove it from the client and blocklist the release so it isn't grabbed again), and
  2. if the episode has already aired/been released, re-trigger a search so the *arr grabs a legitimate replacement.

Every action is recorded to a local SQLite database for auditing.

How it works

By default ArrJanitor makes a single scan pass over every backend and exits; with -d/--daemon it runs continuously (see Run modes). Either way, one worker fiber runs per configured backend, and when a backend is due it runs a scan:

queue → for each item:
  resolve its download client (host/type from the *arr, creds from config)
  list the torrent's files by hash
  match each file against extensions_filter
    → any match:  delete_and_blocklist  → (if released) search  → record to SQLite
    → no match:   leave it alone

Workers never write logs directly; they emit log events onto a channel that the main fiber drains, so console output stays ordered across threads.

Configuration

ArrJanitor is configured from a single file in YAML or JSON. Copy one of the samples in this repo to get started:

# database: arr_janitor.db       # optional, SQLite persistence path (default ./arr_janitor.db; overridden by --database/-D)
# retention: 30d                 # optional, audit-log retention <int>[m|h|d] (default 30d)
# log_level: info                # optional, trace|debug|info|notice|warn|error|fatal|none (default info)

backends:
  - name: My Sonarr
    type: sonarr                 # sonarr | radarr
    url: http://localhost:8080
    api_key: "12355677757"
    interval: 30m                # optional, <int>[m|h|d], default 20m
    extensions_filter:           # bare ext (scr -> *.scr) OR glob ("*.lnk")
      - scr
      - exe
      - bat
      - "*.lnk"
    download_clients:
      - name: My qbittorrent     # matches the client name in Sonarr/Radarr
        username: admin          # api_key XOR (username + password)
        password: password
        # api_key: "..."         # alternative to username/password

Schema

Top level:

KeyRequiredDefaultNotes
backendsyesArray of *arr instances to watch (at least one).
databasenoarr_janitor.dbSQLite persistence path. Overridden by --database/-D.
retentionno30dAudit-log retention window, <int>[m|h|d].
log_levelnoinfoLog verbosity. One of trace, debug, info, notice, warn, error, fatal, none (case-insensitive). Overridden by --log-level/-l and ARR_JANITOR_LOG_LEVEL.

Each entry in backends:

KeyRequiredDefaultNotes
nameyesHuman-readable label (used in logs).
typeyessonarr or radarr.
urlyesBase URL of the *arr instance.
api_keyyesThe *arr API key.
intervalno20mPoll interval, <int>[m|h|d].
extensions_filteryesNon-empty list of bad-extension rules (see below).
download_clientsyesAt least one; supplies credentials (see below).

Each entry in download_clients:

KeyRequiredNotes
nameyesMust match the download client's name in the *arr.
api_key*Provide either api_key or both username and password.
username*qBittorrent authenticates with username/password (no API key).
password*Paired with username.

The download client's host, port and type are read from the *arr's own download-client configuration (matched by name); only the credentials live in this config. Only qBittorrent clients are supported initially, and it requires username + password.

The config is validated at startup and the process exits non-zero with a list of all problems if anything is wrong (missing required fields, unknown type, a malformed interval, an empty extensions_filter, no download clients, or a download client missing credentials).

Extension matching

Each extensions_filter entry is matched (case-insensitively) against a file's basename:

  • An entry containing a glob metacharacter (*, ?, [) is matched as a glob against the basename, e.g. *.lnk or payload.?? .
  • Otherwise it is treated as a bare extension, matching files that end in .<entry>. A leading dot is tolerated, so scr and .scr behave the same.

File format precedence

.yml/.yaml files parse as YAML and .json files as JSON. For any other extension ArrJanitor tries YAML first, then JSON (YAML wins).

Install

The easiest way to run ArrJanitor on a Linux host is a prebuilt native package. Every GitHub release (v0.2.5 and later) attaches two amd64/x86_64 packages to the Releases page:

  • arr_janitor_<version>_amd64.deb — Debian/Ubuntu
  • arr_janitor-<version>-1.x86_64.rpm — Fedora/RHEL

Download them from a browser or with gh release download <tag>, then install with your package manager so it resolves the runtime dependencies:

# Debian/Ubuntu (tested on Ubuntu 24.04)
sudo apt-get install ./arr_janitor_<version>_amd64.deb

# Fedora/RHEL (tested on Fedora)
sudo dnf install ./arr_janitor-<version>-1.x86_64.rpm

Prefer a different route? See Build & run to build from source or Docker to run the published container image.

What the package installs

  • the arr_janitor binary at /usr/bin/arr_janitor
  • a systemd service /lib/systemd/system/arr_janitor.service that runs, as a dedicated unprivileged system user arr_janitor: arr_janitor --daemon --config /etc/arr_janitor/config.yaml --database /var/lib/arr_janitor/arr_janitor.db
  • the config dir /etc/arr_janitor/, containing config.yaml.example
  • the state dir /var/lib/arr_janitor/ (owned by the arr_janitor user; the SQLite database lives here)

First-run setup

The service ships disabled — you must supply a config before starting it. Copy the example, edit in your backends and download-client credentials (the full schema is documented under Configuration), then enable and start it:

sudo cp /etc/arr_janitor/config.yaml.example /etc/arr_janitor/config.yaml
sudo $EDITOR /etc/arr_janitor/config.yaml      # add your Sonarr/Radarr backends + download clients
sudo systemctl enable --now arr_janitor        # start it and enable at boot

Useful follow-ups:

systemctl status arr_janitor                   # is it running?
journalctl -u arr_janitor -f                   # follow logs (service logs to stderr → journald)

Upgrading — installing a newer package over an old one — never clobbers your /etc/arr_janitor/config.yaml (the package only ships config.yaml.example) nor the database under /var/lib/arr_janitor/.

The packages are amd64/x86_64 only. On other architectures use the Docker image or build from source.

Build & run

Requires Crystal >= 1.20.2 and libsqlite3 (the crystal-sqlite3 shard needs the SQLite dev headers to build — e.g. apt-get install libsqlite3-dev).

shards install
shards build -Dpreview_mt           # produces bin/arr_janitor
bin/arr_janitor                     # defaults to ./config.yml
bin/arr_janitor config.yml          # bare path...
bin/arr_janitor --config config.yml # ...or --config / -c

CLI flags

FlagDefaultNotes
-c, --config <path>./config.ymlConfig file to load. Also accepted as a bare positional argument.
-D, --database <path>config database:, else ./arr_janitor.dbSQLite database path. Overrides the config's database: value.
-l, --log-level <level>env ARR_JANITOR_LOG_LEVEL, else config log_level:, else infoLog verbosity: trace, debug, info, notice, warn, error, fatal, none (case-insensitive). See Logging.
-d, --daemonoffRun continuously (see Run modes) instead of a single pass.
-n, --dry-runoffLog intended actions without mutating anything or writing the store.
-h, --helpPrint usage and exit 0.
-v, --versionPrint arr_janitor <version> and exit 0.

Both paths default to the current working directory (./config.yml and ./arr_janitor.db). The database path resolves with the precedence --database/-D > config database: > ./arr_janitor.db.

Note the case: -d is --daemon (a flag, no value) while -D is --database (takes a path value). They are distinct.

Run modes

By default ArrJanitor runs once: it makes a single scan pass over every configured backend and then exits 0 — no interval loop, no waiting. This suits cron jobs, systemd timers, or one-off invocations.

Pass -d / --daemon to run continuously instead: one worker fiber per backend loops on each backend's poll interval, a retention-sweep fiber ages out old audit rows, and the process runs until it receives SIGINT or SIGTERM, then shuts down gracefully (workers stop, the log channel drains, the process exits).

bin/arr_janitor config.yml          # run once, then exit
bin/arr_janitor config.yml -d       # run continuously (daemon)

The -Dpreview_mt flag enables Crystal's multi-threaded runtime so backend fibers can run across threads. Tune the number of worker threads with the CRYSTAL_WORKERS environment variable:

CRYSTAL_WORKERS=4 bin/arr_janitor config.yml -d

Docker

A multi-stage Dockerfile builds a slim debian:12-slim runtime image with a release binary (compiled -Dpreview_mt) that runs as a non-root user. The container runs in daemon mode by default.

Build locally

Both sonarr/qbittorrent.cr dependencies are public GitHub repos, so the build fetches them over HTTPS with no token or secret:

docker build -t arr_janitor .

Run

Mount your config read-only and a writable data volume:

docker run --rm \
  -v ./config.yml:/config/config.yml:ro \
  -v ./data:/data \
  -e PUID=$(id -u) -e PGID=$(id -g) \
  -e CRYSTAL_WORKERS=4 \
  arr_janitor

Volume permissions (PUID/PGID). The container's entrypoint runs as root only to chown the mounted /data to PUID:PGID (default 1000:1000), then drops to that user via gosu. This fixes the common case where docker compose up / docker run creates a root-owned ./data that the non-root app can't write (which otherwise shows up as a "cannot open the database" error). Set PUID/PGID to your host user (id -u / id -g) so the mount stays writable and the DB files are owned by you. /config is read-only and left untouched.

The default CMD runs arr_janitor --config /config/config.yml --database /data/arr_janitor.db --daemon (continuous). Because the paths are passed explicitly, the SQLite database lands on the mounted /data volume and survives container restarts with no config changes needed. For a one-shot run (single scan pass, then exit), override the command and drop --daemon:

docker run --rm \
  -v ./config.yml:/config/config.yml:ro \
  -v ./data:/data \
  arr_janitor --config /config/config.yml --database /data/arr_janitor.db

docker-compose

A ready-to-use docker-compose.yml runs the published image:

services:
  arr-janitor:
    image: ghcr.io/mjblack/arr_janitor:latest
    restart: unless-stopped
    volumes:
      - ./config.yml:/config/config.yml:ro
      - ./data:/data
    environment:
      - CRYSTAL_WORKERS=4

Persistence

State is kept in a SQLite database at the resolved database path (--database/-D override, else the config's database:, else ./arr_janitor.db), opened in WAL mode with a busy timeout so the backend fibers can write concurrently. It holds two tables:

  • processed_downloads — an audit log of every download ArrJanitor has acted on (backend, download id, title, action, matched extensions, timestamp). Rows older than retention are swept away.
  • download_states — per-download bookkeeping (when a download was first seen stalled); groundwork for stalled-download handling.

Logging

Logging uses Crystal's standard Log and writes to stdout. File logging is planned. All worker log events are funnelled through a single channel drained on the main fiber, so output stays ordered under -Dpreview_mt.

The log level is configurable from three sources, resolved with this precedence (highest first):

  1. -l / --log-level <level> — the CLI flag.
  2. ARR_JANITOR_LOG_LEVEL — an environment variable.
  3. log_level: — the config file.
  4. Default: info.

Valid levels (case-insensitive) are trace, debug, info, notice, warn, error, fatal, and none. An invalid value from any source fails fast with a clear error listing the valid levels rather than silently falling back.

bin/arr_janitor -l debug config.yml          # via the flag
ARR_JANITOR_LOG_LEVEL=warn bin/arr_janitor   # via the environment

Development

crystal spec              # run the test suite
crystal tool format       # format (add --check to verify in CI)
bin/ameba src/ spec/      # lint

Continuous integration lives in .github/workflows/ci.yml (GitHub Actions): it installs Crystal and the public sonarr/qbittorrent dependencies, checks formatting, lints, builds, and runs the spec suite on every pull request and every push to master.

Contributing

Issues and pull requests are tracked on GitHub at github.com/mjblack/arr_janitor. Please run the format, lint and spec commands above before opening a PR.

License

MIT © Matthew J. Black