My Plex library has 2,408 movies in it. 2,346 of them have never been played. The TV side is worse: 614 series, 563 never watched, of which 456 have already ended — they will literally never be the "I want to start something tonight" choice. None of this is a problem, exactly. Disks are cheap, the *arr stack runs itself, and I genuinely enjoy having a stocked library. But every six months I'd notice the disks were full again, panic-delete a few things at random, and resolve to "do something smarter eventually."
This is the writeup of "eventually." It runs on swamp, takes about a minute end-to-end, and the only thing it has ever deleted that I regretted is a copy of Wild Things — and even that, the system caught and refused.
The first thing I had to decide wasn't technical. It was: what is this library for?
If a media library is a queue — a list of things I intend to watch — then "never watched" is the strongest possible deletion signal. Nothing else really matters. Watched it once? Probably delete. Sitting unwatched for two years? Definitely delete.
If a library is a collection — a curated thing that has worth independent of whether I watched it this month — then "never watched" is almost meaningless on its own. I have never watched my copy of The Conformist. I am keeping my copy of The Conformist.
I went with collection. That single choice falls out into a lot of the weights below: tenure is positive (long time on disk is a vote of confidence, not abandonment); critical reviews matter more than play counts; franchise completeness gets a big bonus so we don't end up with the second and fourth Mission: Impossible and nothing else.
Five models, wired together by name with CEL:
radarr-default ─┐
radarr-4k ─┤
seerr ─┼──► media-curator.score ──► drop_candidates ──► media-cleaner.applyDrops
tautulli ─┘ └──► protected_drops ───► (visibility-only)
└──► summary ──► discord-notifier
Each of the four input models has one job — pull its own inventory and stop. The curator never talks to Radarr or Plex directly; it consumes their resources via data.latest(...) and produces a per-movie score with a full breakdown. The cleaner consumes the curator's drop_candidates and is the only thing in the pipeline allowed to issue destructive HTTP calls.
That separation is the entire reason this works. The curator can be re-run, re-tuned, and dry-inspected without ever risking a delete. The cleaner can be invoked with apply: false to print what it would do. The two halves never see each other's internals — just the resource shape.
Every movie that has a file on disk gets a score. The default threshold for "drop candidate" is -5 — i.e., a movie has to be actively bad to get on the list, not just unloved.
The signals that bump scores up:
- Provenance. Manual Seerr request → +50. Auto-request → +10. If a human asked for it, that's the strongest non-watch signal there is.
- Watch history. +30 per play, capped at +60. Plex play counts come from Tautulli.
- Reviews. Best of IMDb / TMDb / Trakt / Rotten Tomatoes / Metacritic. +20 if ≥80, -15 if ≤50. (More on the trap here in a second.)
- Collection completeness. +30 if part of a 5+-entry collection on disk, +20 for 3-4, +10 for a pair. This is what keeps the Mission: Impossible problem from happening.
- Tenure. +5 if added in the last 30 days (give it time), +5 if it's been on disk over a year, +10 over two years. Library-as-collection.
And the one signal that pulls down:
- Audio language. -20 if the file has known audio tracks and none of them are in my preferred language.
und/unknowntracks are ignored — no signal, no penalty.
A "naturally bad" score looks like: never requested in Seerr, never played in Plex, rated 45/100 by reviewers, not part of any collection, added 8 months ago, English audio fine. That's 0 + 0 + (-15) + 0 + 0 + 0 = -15 — below the -5 threshold, lands on the drop list.
Worth writing down because every one of them shipped to production and got caught by the next week's review.
1. The originalLanguage != "en" penalty was a disaster. I started with a -10 for non-English originals as a quick "this is probably a junk dub" signal. It immediately flagged ~50 things — every piece of anime in the library, every Studio Ghibli release, Parasite, The Lives of Others. Originally-foreign-language is not a quality signal in any sensible curation system. Deleted the rule entirely. The right hammer for actual junk is the review threshold plus a per-title keep-forever tag for the false positives the reviews still get wrong.
2. bestReview = max(sources) is fooled by tiny vote counts. I had a 7-vote TMDb outlier with a 9.0 rating making garbage look great. Fixed by requiring minReviewVotesPerSource: 50 for any user-vote source (IMDb / TMDb / Trakt). Rotten Tomatoes and Metacritic don't expose vote counts in Radarr; treated as already-aggregated.
3. I thought monitored=false meant something. In a fresh library, sure. In mine, 86% of movies are unmonitored — Radarr auto-unmonitors after grab by default. It's a configuration artifact, not a curation signal. Weight pinned to 0 in the breakdown for transparency.
4. Radarr metadata can disagree with the file on disk. This is the one that actually saved me. In one early dry-run, 3 of 16 drop candidates had a Radarr imdbId that didn't match the imdb-tt####### tag in the file's folder name. The cleaner now compares imdbId against fileImdbId and refuses on mismatch with skipped_metadata_mismatch. The standout case: Radarr's database had The Thing (1982) pointing at a file that was actually Wild Things (1998). I would have deleted a film I love because I thought I was deleting one I don't.
The cleaner has four independent refuse-conditions, any one of which is enough to stop a delete:
keep-foreverRadarr tag →skipped_protected_by_tag. The curator also adds a +200 bonus for the same tag, so protected items don't even reach the drop list under natural scoring. The cleaner's hard refuse exists for the case where someone bypasses the curator entirely.- Metadata mismatch (above) →
skipped_metadata_mismatch. - Cooling period → a candidate must appear on the drop list for 6 consecutive days before the cleaner will act. New candidates get
skipped_awaiting_coolingon first appearance, withfirstSeenAtpersisted to acandidate_historyresource. This catches the case where I add a movie, the score temporarily dips, and a same-day delete would race the score recovering. - Per-run cap of 100 deletes. Belt-and-braces — if everything else fails and the curator decides 2,000 movies are bad, the worst case is losing 100, not 2,000.
The protected-drop list is also surfaced in the weekly Discord embed under "what your keep-forever tags shielded this week" — currently 3 items, 61 GB. The point is to make protection visible so it doesn't become invisible rot.
Mondays 09:00 UTC, swamp serve fires weekly-curator-refresh. The workflow re-pulls Radarr / Seerr / Tautulli inventories, re-scores everything, applies eligible drops (cooling-period-aware), refreshes the Plex library on the relevant section, and posts a Discord embed with:
- the top-15 drop list as an embed (title, year, score, reasoning)
- both full drop list and protected-drop list as CSV attachments
- disk free-space delta from the Synology disk monitor
- a summary line: scored / dropped / reclaimed / shielded
This morning's numbers, for what it's worth: 2,408 movies scored, 16 drop candidates worth 173 GB, 3 protected, 143 in the "high" bucket (score ≥50), 1 in "very low" (≤-20). The TV side: 614 series, 5 drop candidates worth 76 GB, 0 protected.
That's it. The library shrinks slowly, by 100 GB or so a month, when it shrinks at all. Most weeks it doesn't. That's the design.
If you got this far, the media-curator extension is on swamp.club (@lint/media-curator) and the cleaner ships alongside it. The CEL wiring, default thresholds, and Radarr-tag protection scheme are all in the README. Tune away.