Plex doesn't automatically clean up its library trash when you're using symlinked debrid or usenet media. When a file gets replaced or removed, Plex marks it unavailable — but unless you have "empty trash automatically after every scan" turned on (which you probably don't, because that's risky), those entries just pile up.
emptyarr runs on a schedule, checks that your mounts are actually healthy, and then calls Plex's emptyTrash API. If anything looks wrong — mount missing, symlinks broken, file count dropped — it skips the empty and optionally pings you on Discord.
Before emptying trash on any library, emptyarr runs:
- Mount check — walks up the path tree to find the nearest mount point and verifies it's accessible
- Debrid mount check — for debrid/usenet paths, reads symlink targets via
os.readlink()(without resolving them), finds the underlying FUSE mount point, and verifies it is accessible and non-empty. This detects a dead mount even when symlinks point into trash and would otherwise appear broken - File threshold — compares the count of files on disk to your Plex library count. If the ratio drops below your configured threshold (default 90%), something's wrong and it bails
- Combined check — for mixed libraries (physical + debrid), sums all paths and checks the combined ratio
All checks pass → trash gets emptied. Any check fails → skip, log it, notify if configured.
mkdir -p /mnt/cache/appdata/emptyarr/data
cd /mnt/cache/appdata/emptyarr
git clone https://github.com/jjbobz/emptyarr.git .
docker build -t emptyarr:latest .Network: your arr network (e.g. arr_net)
Port: 8222
Path mappings:
| Host | Container | Mode |
|---|---|---|
/mnt/cache/appdata/emptyarr/data |
/app/data |
Read/Write |
/mnt/symlink_media |
/symlink_media |
Read Only - Slave |
/mnt/user/media |
/mnt/user/media |
Read Only |
The container path for symlink media needs to match what your symlinks actually point to. Check with
ls -la /mnt/symlink_media/symlinks/radarr/ | head -3and look at the symlink targets. If they start with/symlink_media/(no/mnt), use that as the container path.
Slave propagation required for FUSE mounts: The symlink media volume must use
slavepropagation (:ro,slave) so that FUSE mounts created by tools like Decypharr or zurg after the container starts are visible inside the container. Withoutslave, the container sees a stale snapshot of the host mount namespace and the FUSE filesystem will appear empty or missing.
Environment variables:
| Variable | Default | Description |
|---|---|---|
PUID |
— | User ID for file permissions. 99 on Unraid (nobody) |
PGID |
— | Group ID for file permissions. 100 on Unraid (users) |
TZ |
— | Timezone, e.g. America/New_York |
PLEX_TOKEN_<NAME> |
— | Plex token per instance. Name is the instance name uppercased with spaces/hyphens as underscores: PLEX_TOKEN_MY_PLEX, PLEX_TOKEN_MY_PLEX_UNLIMITED, etc. Can also be entered in the UI |
EMPTYARR_SECRET_KEY |
random | Stable session key — set this so users aren't logged out on every restart. Generate with openssl rand -hex 32 |
EMPTYARR_USERNAME |
— | Web UI login username. Leave unset to disable auth |
EMPTYARR_PASSWORD |
— | Web UI login password. Leave unset to disable auth |
RD_API_KEY |
— | Real-Debrid API key (alternative to setting it in the UI) |
AD_API_KEY |
— | AllDebrid API key |
TB_API_KEY |
— | Torbox API key |
DL_API_KEY |
— | DebridLink API key |
CONFIG_PATH |
data/config.yml |
Path to the config file |
LOG_DIR |
data/logs |
Directory where log files are written |
BROWSE_ROOTS |
/mnt,/media,/data,/home |
Comma-separated list of root paths the file browser is allowed to enter |
FLASK_HOST |
127.0.0.1 |
Network interface to bind to. Set to 0.0.0.0 if you need external access or are using a reverse proxy |
SESSION_COOKIE_SECURE |
false |
Set to true when serving over HTTPS — marks the session cookie as Secure so it's never sent over plain HTTP |
Open http://YOUR_IP:8222 and run through the setup wizard. Takes a few minutes.
Config lives at /app/data/config.yml (your host's data directory). The Settings page in the UI can edit everything — you shouldn't need to touch the file directly.
- physical — standard files on disk
- debrid — symlinked content (Real-Debrid, AllDebrid, etc.)
- usenet — usenet downloads with symlinks
- mixed — combination of physical and debrid in the same Plex library
For mixed libraries the file threshold check combines all paths before comparing to your Plex count, so individual paths don't need to hold the full library.
min_threshold is the percentage of your Plex library count that must exist on disk. Default is 90. If you have 1000 movies in Plex and only 850 files on disk, that's 85% — below 90%, so the empty gets skipped.
Per-library. Standard cron syntax. 0 * * * * runs every hour on the hour, */30 * * * * every 30 minutes.
discord_webhook: https://discord.com/api/webhooks/...
notify:
on_emptied: true
on_health_fail: true
on_error: true
on_clean: false
on_skip: false
plex_instances:
- name: My Plex
url: http://192.168.1.100:32400
token: ''
libraries:
- name: Movies
type: physical
cron: "0 * * * *"
paths:
- path: /mnt/user/media/movies
type: physical
min_threshold: 90
- name: TV Shows
type: physical
cron: "0 * * * *"
paths:
- path: /mnt/user/media/tv
type: physical
min_threshold: 90
- name: My Plex Unlimited
url: http://192.168.1.100:32410
token: ''
libraries:
- name: Movies
type: mixed
cron: "0 * * * *"
paths:
- path: /mnt/user/media/movies
type: physical
min_threshold: 90
- path: /symlink_media/symlinks/radarr
type: debrid
min_threshold: 90
- name: TV Shows
type: debrid
cron: "0 * * * *"
paths:
- path: /symlink_media/symlinks/sonarr
type: debrid
min_threshold: 90Settings → Security. Enter username and password, save. Takes effect immediately, no restart needed. Stored as a SHA-256 hash in config.yml — never plaintext.
You can also set EMPTYARR_USERNAME and EMPTYARR_PASSWORD env vars instead (these take priority).
Five separate Discord notification events you can toggle independently:
- Trash emptied — something was actually removed
- Health check failed — checks didn't pass, empty was skipped
- Error — the emptyTrash API call failed
- Already clean — ran fine, nothing to remove (off by default — gets noisy)
- Skipped — scheduling paused, config error, section not found (off by default)
cd /mnt/cache/appdata/emptyarr
git pull
docker build -t emptyarr:latest .
# restart in Unraid UIemptyarr only talks to: your Plex server, debrid provider APIs if you configure an API key, and your Discord webhook. That's it. No telemetry, no analytics, no external calls. See PRIVACY.md.
MIT