feat(rules): add Sonarr episodeFileRank with seriesTitle/seriesId scoping by jackemcpherson · Pull Request #3095 · Maintainerr/Maintainerr · GitHub
Skip to content

feat(rules): add Sonarr episodeFileRank with seriesTitle/seriesId scoping#3095

Open
jackemcpherson wants to merge 3 commits into
Maintainerr:developmentfrom
jackemcpherson:feat-episode-retention-rank
Open

feat(rules): add Sonarr episodeFileRank with seriesTitle/seriesId scoping#3095
jackemcpherson wants to merge 3 commits into
Maintainerr:developmentfrom
jackemcpherson:feat-episode-retention-rank

Conversation

@jackemcpherson

@jackemcpherson jackemcpherson commented Jun 13, 2026

Copy link
Copy Markdown

Updating this from the early draft after a couple of weeks dogfooding it on my own setup, and folding in @enoch85's review feedback. Still happy to rework scope, naming, or strategy.

Description & Design

What problem this solves

Sonarr getters can rank by season air date or count missing episodes, but there's no way to rank an episode within its show by air date and prune on a rolling window. A long-running daily series (thousands of episodes) can't be trimmed to "keep the most recent N."

What's in this PR

Three new Sonarr rule properties:

  • episodeFileRank (NUMBER, episode scope) — rank of an episode within its show by air date, newest = 1, computed only over episodes currently on disk (hasFile === true). episodeFileRank > 5 keeps the latest five downloaded. (id 32)
  • seriesTitle (TEXT) — the Sonarr series title, so a rank rule can be scoped to one show in the rule builder (seriesTitle EQUALS "...") instead of via out-of-band Sonarr tags or Plex collections. (id 33)
  • seriesId (NUMBER) — the Sonarr series id; a stable scope handle for destructive rules that survives a title rename or a regional/year suffix. (id 34)

seriesTitle/seriesId were added after the initial review specifically to give episodeFileRank a safe, discoverable way to scope to a single show; see the comment below for the reasoning, and I'm happy to split them out if you'd rather review the rank property on its own.

Why this approach

A Sonarr rule property is the smallest viable change: it reuses the existing arrLookupCache plumbing, slots into the rule UI with no frontend work (the constants entry drives the dropdown and NUMBER/TEXT render through the generic custom-value input), and composes with the existing comparators. A custom rule action or collection setting would be a bigger surface area.

How it works

  1. episodeFileRank switches on case 'episodeFileRank' in SonarrGetterService.get.
  2. buildRankMaps builds the rank over the show's episodes filtered to hasFile === true, season > 0, a valid airDateUtc, and airDateUtc <= now; sorted newest-first; rank = position + 1.
  3. The map build and the underlying getEpisodes(showId) call are memoized through the run-scoped arrLookupCache, keyed by (settingsId, showId), so ranking every episode of a long-running daily series pays the sort/fetch cost once per show per rule-run rather than per episode.
  4. A second map keyed by broadcast date (airDate) backs a fallback for daily-air Plex items, which carry a year as parentIndex and no episode number. It's matched against originallyAvailableAt's broadcast date so a primetime broadcast that crosses UTC midnight still lines up.
  5. Out-of-pool episodes (no file, special, unaired, null air date) and any transient Sonarr fetch failure return null/undefined, keeping the comparator fail-closed (no surprise deletions).

Trade-offs, edge cases, limitations

  • Rank is computed at evaluation time, not stored; a show that gains an episode mid-run reranks on the next run (same behaviour as the addDate-style rules).
  • Pool is on-disk episodes only. This is the post-review semantics (thanks @enoch85): an air-date rank over Sonarr's full catalog could push a user's owned subset outside the newest N and delete it. "Latest N downloaded" matches Maintainerr's job of managing what's on disk.
  • Specials (season 0), unaired episodes, and episodes with no / 0001-01-01 air date are excluded.
  • Episodes with the .NET null-date sentinel 0001-01-01T00:00:00Z are rejected explicitly (it parses to a finite year-1 epoch), symmetric on both the Sonarr and Plex sides.

Related issue

None. Opened exploratory rather than tied to a ticket; happy to file a discussion if that helps tracking.

AI-Assisted Development

I used Claude as a coding aid for the getter case and the spec scaffolding. I reviewed every line, caught the .NET null-date sentinel and the daily-series UTC-midnight boundary against my own Sonarr instance, chose the arrLookupCache route after profiling per-episode cost, and validated the change end-to-end on my production setup. I take full responsibility for the correctness, performance, and maintainability of the change.

Checklist

  • I have read the CONTRIBUTING.md document.
  • I understand the code I am submitting and can explain how it works
  • I have performed a self-review of my code
  • I have linted and formatted my code
  • My changes generate no new warnings
  • New and existing unit tests pass locally with my changes

How to test

  1. Configure a Sonarr server with a show that has more than a handful of aired, on-disk episodes (a daily show or long-running soap works best).
  2. New collection, rule: Sonarr / Rank among on-disk episodes per show by air date (newest = 1) is bigger than 5. Optionally scope with Sonarr / Series title EQUALS your show (or Series ID for a rename-proof handle).
  3. Run the rule; confirm the latest five downloaded episodes are excluded and the older tail matches.
  4. Repeat with smaller than 6 to confirm only the latest five match.
  5. Optional: against a show whose pilot has a 0001-01-01T00:00:00Z air date, confirm it's excluded rather than ranking near the top.
  6. Run yarn test from the workspace root; coverage lives in apps/server/src/modules/rules/getter/sonarr-getter.service.spec.ts.

@enoch85

enoch85 commented Jun 13, 2026

Copy link
Copy Markdown
Collaborator

@jackemcpherson

Copy link
Copy Markdown
Author

Thank you for the review - you make a good point about the Ranking Pool.

I will give it some thought and go over the logic again.

Other points can also be fixed in the next commit.

@enoch85

enoch85 commented Jun 13, 2026

Copy link
Copy Markdown
Collaborator

Also, have a look here: https://github.com/Maintainerr/Maintainerr/tree/development/.claude

(Since you use Claude)

jackemcpherson added a commit to jackemcpherson/Maintainerr that referenced this pull request Jun 14, 2026
…t pool to hasFile

The maintainer's review on PR Maintainerr#3095 flagged the ranking pool as a footgun:
ranking over the full aired Sonarr catalog (including episodes the user
has never downloaded) means a partial library can have every owned
episode fall outside the newest N and get matched for deletion.

Restrict the pool to `e.hasFile === true` so the rule reads as "keep the
latest N downloaded" and lines up with what Maintainerr can actually act
on. Drop the `sw_` prefix (only used by watch/library properties on
Plex/Tautulli/Jellyfin/Emby) and rename to `episodeFileRank` with a
human label that calls out the downloaded-only semantic.

Spec helpers and bench helper now pass `hasFile: true` explicitly so the
deterministic assertions don't trip on the createSonarrEpisode random
default.
… scoping

- episodeFileRank: rank on-disk episodes per show by air date (newest = 1).
  Pool requires hasFile, excludes specials (S00), unaired, and null-airDate
  episodes; out-of-pool ranks null so the comparator stays fail-closed.
  Rank maps are memoised through ArrLookupCache so long-running daily
  series fetch the full episode list once per show per rule-run.
- Daily-series airDate fallback for Plex items carrying parentIndex (year)
  but no episode index; keyed on Sonarr's broadcast-date string (not a UTC
  day-bucket) so US primetime broadcasts straddling UTC midnight still match.
- seriesTitle (TEXT) and seriesId (NUMBER) as scoping handles. seriesTitle
  is the discoverable entry point; seriesId is the stable handle for
  production rank-and-delete rules where a rename or case mismatch would
  silently lose the scope guard.
- Opt-in bench spec (MAINTAINERR_BENCH=1) compares cached vs uncached
  rank-map paths at N=100/1000/9000 episodes.
@jackemcpherson jackemcpherson force-pushed the feat-episode-retention-rank branch from e396dd7 to 2a59d19 Compare June 14, 2026 05:09
@jackemcpherson

jackemcpherson commented Jun 14, 2026

Copy link
Copy Markdown
Author

Put some more work in to this today.

Major:

  • Pool semantics: went with Option A from @enoch85's review, rank over hasFile === true only. The rule now means "keep the latest N downloaded" instead of "rank within Sonarr's catalog". Renamed sw_episodeRank to episodeFileRank to match. Felt this was closer to Maintainerr's goal of managing media on disk. Also removes the foot-gun where a partial library could have existing files deleted because none ranked in the catalog's newest N.

  • Scope, Series Title (Sonarr.seriesTitle, TEXT). episodeFileRank deletes, and there was no first-class way to scope to one show. The only options were Sonarr tags or Plex collections, both out-of-band and invisible in the rule builder. Sonarr.seriesTitle EQUALS "<title>" is the discoverable scope predicate now. Pairs with episodeFileRank > N for "keep the latest N for this show".

  • Scope, Series ID (Sonarr.seriesId, NUMBER). seriesTitle's strict-equals match is brittle: a user renaming the show in Sonarr, the title gaining a regional or year suffix (e.g. "(US)" or "(2019)") that wasn't there before, or trailing whitespace can silently zero the scope predicate and let the rank rule run library-wide. seriesId is the stable handle for production rank-and-delete rules. Both stay; recommendation in maintenance notes is seriesId for destructive rules.

Minor (review feedback):

  • Dropped the sw_ prefix. The Sonarr block has no other sw_ props and the prefix reads as "watched/seen", which isn't what the rule does.

  • Scrubbed the show-name reference in the getShowEpisodes comment (now "a long-running daily series"). Same scrub on the PR description.

  • Dropped the two e2e matrix entries. That harness mocks the getter and the scenarios just handed 7 and 3 to the comparator, which is generic NUMBER-prop coverage. Real assertions live in sonarr-getter.service.spec.ts.

  • Daily-series airtime crossing UTC midnight. The fallback was keying the rank map on the UTC day of Sonarr's airDateUtc and looking up by the UTC day of Plex's originallyAvailableAt. For a US primetime broadcast (e.g. 8pm Eastern) those land on different UTC days and the lookup misses. Switched the key to Sonarr's airDate string (broadcast-date in the show's local calendar) matched against originallyAvailableAt.toISOString().slice(0, 10). Regression spec covers the boundary.

  • Opt-in bench spec (runs only with MAINTAINERR_BENCH=1) compares cached vs uncached rank-map at N=100/1000/9000. Doesn't run in CI. Will be removed in review candidate.

Other:

  • Bug found while running dogfood: the Sonarr unmonitor API call can time out partway through, leaving the episode unmonitored but the file still on disk and the action reported as failed. Out of scope here; separate issue and PR.

Running end-to-end on my prod setup. Going to leave it for a bit.

Will then take another pass over the whole thing. Try and trim some of the code and see if I can find anything performance wise before submitting for review.

@jackemcpherson jackemcpherson changed the title feat(rules): add Sonarr sw_episodeRank for air-date episode retention feat(rules): add Sonarr episodeFileRank with seriesTitle/seriesId scoping Jun 30, 2026
@jackemcpherson

Copy link
Copy Markdown
Author

A note on scope before this goes further.

The original draft added one property (episodeFileRank). Since your review I've added two more, seriesTitle (TEXT) and seriesId (NUMBER), and I'd rather flag that explicitly than slip them past a review that covered one property.

Why they're here: episodeFileRank drives deletions, and there was no first-class way to scope it to a single show. The only options were Sonarr tags or Plex collections, both out-of-band and invisible in the rule builder, so an episodeFileRank > N rule effectively ran library-wide. seriesTitle EQUALS "..." gives a discoverable scope predicate; seriesId is the rename-proof version of the same thing, since a title gaining a "(US)" / "(2019)" suffix or trailing whitespace silently zeroes a strict-equals match and lets the rank rule run library-wide again. My recommendation for destructive rules is seriesId.

One caveat on seriesId: Maintainerr's UI doesn't surface Sonarr's internal series id anywhere, so a user has to read it off the Sonarr series URL / API. It's an advanced handle, not a discoverable one.

That's three permanent rule properties now instead of one, so I'd rather you make the call than assume. episodeFileRank does need some first-class way to scope to a single show, or the rule runs library-wide, so the two realistic options are:

  • (a) keep seriesTitle and seriesId in this PR, or
  • (b) split them into a separate PR so this one stays focused on the rank property.

Either works for me; let me know which you'd rather review.

Separately, I've removed the opt-in benchmark spec I mentioned earlier: it was dev-only scaffolding (gated behind MAINTAINERR_BENCH=1, skipped in CI) and didn't belong in the feature diff.

@jackemcpherson jackemcpherson marked this pull request as ready for review June 30, 2026 05:18
@jackemcpherson jackemcpherson requested a review from enoch85 as a code owner June 30, 2026 05:18
@timelordx

Copy link
Copy Markdown

I’m reading and commenting on an iPhone, so I apologize if this has already been discussed and I missed it.

If this could also be applied to seasons, it would be one of the best features. A lot of people have been asking for a way to keep only the last X seasons.

@jmcpherson-mb

Copy link
Copy Markdown

@SmolSoftBoi SmolSoftBoi added the enhancement New feature or request label Jul 1, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants