Skip to content

Instantly share code, notes, and snippets.

@sock-lint
Created May 22, 2026 23:21
Show Gist options
  • Select an option

  • Save sock-lint/0331f1e5b458dc165e43758f37ecf940 to your computer and use it in GitHub Desktop.

Select an option

Save sock-lint/0331f1e5b458dc165e43758f37ecf940 to your computer and use it in GitHub Desktop.
Curating my media library with swamp instead of letting it grow forever

Curating my media library with swamp instead of letting it grow forever

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.

Library-as-a-collection, not a queue

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.

The pipeline

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.

Scoring

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/unknown tracks 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.

Four things I got wrong on the first cut

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.

Defense in depth

The cleaner has four independent refuse-conditions, any one of which is enough to stop a delete:

  1. keep-forever Radarr tagskipped_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.
  2. Metadata mismatch (above) → skipped_metadata_mismatch.
  3. Cooling period → a candidate must appear on the drop list for 6 consecutive days before the cleaner will act. New candidates get skipped_awaiting_cooling on first appearance, with firstSeenAt persisted to a candidate_history resource. This catches the case where I add a movie, the score temporarily dips, and a same-day delete would race the score recovering.
  4. 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.

What it actually does each week

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment