Skip to content

feat(rules): add watcher lists since media was added - #3532

Merged
enoch85 merged 5 commits into
Maintainerr:developmentfrom
whitestrake:feat/plex-watchers-since-added
Sep 5, 2026
Merged

feat(rules): add watcher lists since media was added#3532
enoch85 merged 5 commits into
Maintainerr:developmentfrom
whitestrake:feat/plex-watchers-since-added

Conversation

@whitestrake

@whitestrake whitestrake commented Aug 18, 2026

Copy link
Copy Markdown
Collaborator

Description & Design

This adds two text-list rule values for show and season targets on Plex, Jellyfin and Emby:

  • [list] Users that watched at least one episode since added
  • [list] Users that watched every episode since added

The properties use IDs 49 and 50, with the same IDs and names on all three providers.

Existing watcher lists consider all known watch history. When media is removed and later added again, a username from an earlier availability cycle can remain in those lists indefinitely. Unlike a date value, that username has no threshold through which it will eventually age out. These new values limit the results to the current availability cycle.

Each rule uses the addition date of the selected target as a strict cut-off:

  • A show target uses the show's addedAt.
  • A season target uses the season's addedAt.
  • A view qualifies only when viewedAt > addedAt.

Re-adding a season therefore starts a new cycle when that season is evaluated, but does not restart the enclosing show's cycle if the show itself remained present. The exact surviving-show workflow described in #2206 should use these properties at season level.

The "at least one" value returns the union of users with a qualifying view on any available episode: recognised users on Plex and Emby, while Jellyfin keeps an unresolved account id the way Users that watched at least one episode does. The "every episode" value intersects the qualifying users across every available episode. Targets with no available episodes return an empty list.

Missing or invalid target addition dates and malformed watch dates produce a transient unknown result instead of a false empty list. Missing Jellyfin or Emby DateCreated values are therefore treated as unreadable rather than as newly unwatched media.

Plex uses genuine playback-history rows, following its existing watcher rules. Manually marked-watched state is not included. Emby has no central dated history endpoint, so its implementation must read each episode across the available users; episodes are read in batches of five, the way the adapter already batches its per-user reads, and Users that watched every episode now uses the same walk.

The implementation adds no dependencies, database migration or UI-specific code. Matching property IDs and names allow the existing media-server migration machinery to carry the rules between supported providers.

The existing Plex Users that watched every episode behaviour for targets with no episodes is aligned with Jellyfin and Emby in a separate fix: commit, as requested.

Related issue

Related to #2206.

AI-Assisted Development

Codex assisted with repository analysis, implementation, focused tests, review passes and local verification.

I reviewed the resulting implementation and its design decisions, including provider parity, show and season scope, target-level viewing cycles, strict timestamp boundaries, malformed-data handling, user mapping and the separate zero-episode fix.

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 Plex, Jellyfin or Emby and select a show or season with known episode-watch activity.

  2. Confirm that Users that watched at least one episode since added includes users with at least one view strictly after the target's addition date.

  3. Confirm that Users that watched every episode since added includes only users with a qualifying view for every available episode.

  4. Re-add the selected show or season and confirm that older retained watch activity is excluded until fresh playback occurs.

  5. On Jellyfin or Emby, replace an episode after it has been watched and confirm that the enclosing show or season still uses its own addition date as the cycle boundary.

  6. Run the project checks:

    yarn format:check
    yarn check-types
    yarn turbo lint -- --no-fix
    yarn turbo test --concurrency=1
    yarn build

Additional context

The six focused provider suites pass all 433 tests. The complete project suite passes all 3,458 tests, and formatting, linting, typechecking and production builds pass.

I rebuilt the disposable Maintainerr instances from the final commit and exercised both properties at show and season level through Playwright against real Plex, Jellyfin and Emby servers. All twelve provider-and-target rule results contained the expected recognised-user lists. The Jellyfin and Emby runs also covered an upgraded episode whose new item date was later than its retained watch date, confirming that the target-level cut-off preserves the completed-season result.

There were no browser console errors, page errors or Maintainerr error-level log entries during that run.

@whitestrake

Copy link
Copy Markdown
Collaborator Author

Before I move this out of draft, there are two semantic points where I would appreciate maintainer input.

First, targets with no available episodes. The existing Plex Users that watched every episode rule returns every recognised user when there are zero episodes to check, while the equivalent Jellyfin and Emby rules return an empty list. This PR deliberately preserves the existing Plex behaviour for the new since-added rule. I can align it with Jellyfin and Emby instead if that is preferred.

Second, manually using Plex's Mark Watched action. These rules currently use actual watch-history rows only, following the existing Plex watcher rules, so manually marked episodes do not count.

I briefly considered supporting manual marks only for users who also have at least one genuine post-add view. That would let somebody watch part of the media after it was added and then mark the remainder watched, while still excluding stale watched state from users who have not participated in the new cycle. Unfortunately, determining that state requires retrieving each participating user's Plex access token and probing the metadata separately as that user.

I left that out for now because it adds substantial complexity to otherwise straightforward history rules. If manually marked episodes are considered important here, I can bring that approach back into the PR.

cc @enoch85

@SmolSoftBoi SmolSoftBoi added the enhancement New feature or request label Aug 19, 2026
@enoch85 enoch85 added the plex Concerns the Plex integration or Plex-specific behaviour label Aug 19, 2026
@enoch85

enoch85 commented Sep 2, 2026

Copy link
Copy Markdown
Collaborator
Review of #3532 at 09340a6 (expand)

Rebased this onto development in a scratch worktree and ran it against the real dev stack: Plex 1.43.3, a second Maintainerr instance on :6247, Playwright on the rule editor. Focused suite 83/83, rules module 901/901, eslint and prettier clean, UI needs no changes. Everything below is about the design, not the craft.

What the history says

I went back through the issues, the board and the commits before reviewing the code, because the premise here has been argued before.

Source What it says
#2206 (Dec 2025, closed by the stale bot) The exact bug. Reporter pairs sw_watchers with sw_allEpisodesSeenBy to delete a show once everybody has finished it. A season was deleted and re-added, sw_watchers kept listing the users from the first cycle, so the show never got deleted again.
benscobie's reply on #2206 "When an episode is deleted, the watch history also gets deleted." That closed the issue, and 06095411 later disproves it.
Fider #170 (1 vote) Same problem through Tautulli. Claims "the date added is reset" on re-add. That claim is the whole basis for this PR and nobody has measured it.
Everything else Nothing. No open, triaged, upvoted request for this. Compare #3531, which had #751 behind it.

Two things follow. First, #2206 was closed on a wrong premise, so it is fair to reopen the topic. Second, #2206 describes a show-level rule, and as written this PR does not fix that case.

The more useful precedent is on #3531 itself. In 870b1e93 I filtered Plex's retained history down to the seasons a show still has, then reverted it in 06095411 an hour later after measuring: a date property ages out on the rule's own threshold, so filtering only suppressed the recent case and killed the point of the property. That reasoning does not carry over here. These two are text lists, and a list has no threshold to age out on. That is the strongest argument in favour of this PR and it belongs in the description.

The same revert also established, by measurement, that /status/sessions/history/all is a server-side event log that outlives the media. Which brings us to the part nobody has checked.

Does the cutoff ever fire?

History rows outlive the media, but they stay bound to the ids they were written with. I unmatched a movie so its guid changed from plex://movie/5d7768... to tv.plex.agents.none://2693, and /status/sessions/history/all?metadataItemID=2693 still returned the row. That query is keyed on the metadata item id, not the guid.

So re-added media gets a new id, the old rows stay pointed at the dead one, and the cutoff has nothing to remove:

Scenario What the cutoff filters out
Whole show removed, re-added Nothing. Old rows point at the dead show id.
Season removed, re-added, show survives, show-level rule Nothing. show.addedAt never moved, so the old rows are newer than it.
Same, season-level rule Nothing. The new season id only ever had new rows.

The one case left is a trash restore, where the item keeps its id but addedAt moves forward. I could not produce it: devbox has no /media mount and Radarr lost its media mount in the podman migration (rootfolder reports accessible: false).

@whitestrake, can you measure that on a real server? Record ratingKey, addedAt and the history rows before deleting a season's files, then after putting them back, once with the trash emptied and once without. That result decides whether these properties do anything at all, and it is the gate on the rest of this.

The cutoff is read off the wrong item

getAddedAtCutoff takes the cutoff from the target, then applies it to views of that target's children. On a real library the parent is routinely newer than its children:

SHOW 2562 'NBA'                addedAt=1785198485
  EP 2569 addedAt=1212849867     EP 2572 addedAt=1227501607
  EP 2570 addedAt=1273700165     EP 2571 addedAt=1348669573
  EP 2567 addedAt=1779913275
SHOW 7 'Blender Open Movies'   addedAt=1782111555
  SEASON 8 addedAt=1782006781    (3 of its 5 episodes also 1782006781)

I made one genuine view (episode 13, viewedAt=1783962660) and moved show 7's addedAt to 1784000000 to reproduce that shape, then ran POST /api/rules/test:

Property Show 7 Season 8
sw_watchers (18) ['enoch85'] ['enoch85']
sw_watchersSinceAdded (50) [] ['enoch85']

Same view, same user, dropped at show level and kept at season level, purely because the parent record is younger than the child. Written the obvious way, sw_watchersSinceAdded NOT_EXISTS, that deletes media somebody watched. It is the same failure I flagged in the round two review of #3531: a false "never watched" costs a season its protection.

Fix is to compare each view against the addedAt of the item the view belongs to, or to scope the property to season and episode where the target is the thing that was re-added.

Smaller things

# Issue
1 A missing addedAt throws, the getter turns that into the transient, and the item is held forever with nothing shown to the user. implementation.instructions.md names addedAt as the example of a field the official Plex schema does not require, and addDate (id 0) returns null for it.
2 Plex only, with no API reason. Jellyfin and Emby already fetch DateCreated and LastPlayedDate and discard the dates. Both properties would work there at zero extra requests, since max(plays) > cutoff is the same question as "any play after the cutoff". Id 48 is the pattern: same id and name everywhere, divergence in a code comment.
3 No migrateTo on either property, so these rules are deleted when someone switches server.
4 Id 50 drops 'episode' from showType while id 18 has it. An episode has its own addedAt, so the concept is well defined there.
5 Needs a rebase. The seed-db.mjs line drops 48:D, and the fake-plex.mjs change overwrites the sh1 fixture #3531 added, including its retained-history row for a removed episode.

Your two questions

Zero episodes. Align with Jellyfin and Emby, return an empty list, but as its own fix: commit with its own test rather than folded in here. For what it is worth, the divergence is accidental, not a decision: the Plex loop skeleton is from 2022 and allViewers = plexUsers.slice() from 2023, while Jellyfin got its guard in #3345 and Emby in #2911, both incidentally. On Plex the case is only reachable on an emptied item, which is exactly the state this feature is about, and "everybody watched everything" is the worst answer we could give there.

Manual marks. Your reading is right and I confirmed it: /:/scrobble set viewCount=1 and lastViewedAt, and produced no row in /status/sessions/history/all at either episode or show level. Leave the per-user token probing out. That gap already has an owner in sw_markedWatchedEpisodes (id 45), and its real defect is that viewedLeafCount is owner-scoped (#3479, Fider 231). There is also more demand behind it than behind this feature: Fider 219 has 6 votes, Fider 231 has 4, against 1 for the re-add case.

If it comes back

Per-item cutoff, all three servers, migrateTo set, the id 12 alignment split out. It should also get smaller. The Number.isSafeInteger throw in filterWatchHistoryAfter cannot fire, since the Plex client sends Accept: application/json and the server returns viewedAt and accountID as numbers, and 4 of the 8 added test cases exist only to pin that dead branch. Keep the id 12 refactor though, the Set version is behaviour-preserving and better than the splice loop it replaces.

@whitestrake

Copy link
Copy Markdown
Collaborator Author

I've carried out more detailed lifecycle testing against a real Plex server. The results contradict several of the review's technical conclusions, while other points depend on how the intended scope of the rules is defined. The evidence and a point-by-point response are included below.

Detailed response to review items

Testing and evidence

  • The implementation under test was the exact PR head 09340a6692607bbe748ad8a0ef64a29f04b2b0a7, built into an isolated Maintainerr container and connected to a disposable real Plex 1.42.2 server containing six synthetic episodes across Seasons 1, 2 and 3.
  • Adding and removing an episode beneath an existing season did not change the surviving Plex show or season IDs or their addedAt values. Equivalent parent-stability probes also passed on Jellyfin and Emby.
  • Removing and re-importing a Plex season produced replacement season and episode IDs with later addedAt values. Plex relinked the retained historical views to those replacements, both with trash retained and after explicitly emptying trash.
  • Removing and restoring the whole show's media produced replacement show, season and episode IDs with a later show addedAt, while Plex again relinked the retained earlier views.
  • Playwright exercised the rules through Maintainerr's Test Media UI. Retained pre-re-add views produced empty lists; fresh partial viewing populated only the "at least one" list; completing the selected season populated both season lists; and completing all six episodes populated both show lists. There were no browser console errors or Maintainerr error-level log entries.
  • Jellyfin and Emby lifecycle probes found that re-importing Season 2 advanced its DateCreated while its older LastPlayedDate survived. This supports equivalent target-cutoff behaviour if support for those providers is requested.

Core claims

Review claim Assessment Response
Nobody had measured whether addedAt resets when media is re-added. Previously true, now resolved. Fresh season and realistic whole-show re-imports produced replacement IDs and later addedAt values.
Plex history outlives the media. Confirmed. The retained event history is the reason this feature is useful: unlike a date comparison that eventually ages out, a stale username in a list can remain indefinitely.
Retained history remains attached only to dead metadata IDs. Disproved. Plex relinked retained history to replacement episode, season and show IDs after real removal and re-import. The unmatched-movie GUID experiment established behaviour for a GUID change on an existing item, but did not establish removal and re-import behaviour.
A whole show can be removed and re-added without the cutoff filtering anything. Disproved. Retained history was visible against the replacement show, but the exact-head since-added properties excluded it because the replacement show's addedAt was newer. Fresh playback then made the properties populate again.
A season-level rule has nothing to filter after that season is removed and re-added. Disproved. Replacement season and episode IDs received the retained old history. The replacement season's newer addedAt filtered it until fresh views occurred.
Only a same-ID trash restore could make the cutoff useful. Disproved. The successful lifecycle tests received replacement IDs and still had their retained history relinked.
Re-adding one season beneath a surviving show does not restart the show-level result. Correct, but intentional under the current contract. The selected show was not re-added, so its viewing cycle did not restart. A season-level rule uses that season's new cycle. Making child churn restart a show-level cycle would define a different property.
This PR does not fix the exact show-target workflow reported in issue #2206. Narrowly correct. Issue #2206 targets a surviving show while one child season cycles. This PR fixes season re-adds at season scope and whole-show re-adds at show scope. If solving that exact show-target workflow is required, the desired child-relative semantics need to be agreed explicitly.
The cutoff is read from the wrong item. Disagree under the stated contract. The selected show or season deliberately defines the current viewing cycle. Comparing each view with its child episode's addedAt would change the rule to "watched since each individual episode was added".
The manipulated parent-newer example demonstrates a false "never watched" result. Not established. The test manually moved the show's addedAt after the genuine view. Under target-cycle semantics, that view belongs to the previous show cycle and should be excluded.
Parent records can be newer than their children. True, but not sufficient to demonstrate incorrect filtering. A qualifying view only needs to be newer than the selected target's current cycle. The review did not demonstrate a naturally occurring post-target view being excluded. Normal child addition and removal did not advance the surviving parent timestamps in our tests.
Using NOT_EXISTS could therefore delete media that somebody watched. This depends on the intended rule semantics and surrounding cleanup policy. An empty result means nobody watched during the selected target's current cycle. Our intended cleanup workflow also includes a post-addition age gate, so newly added media is not immediately eligible merely because the list begins empty.
The reasoning behind retaining removed Plex history for PR #3531 does not automatically carry over to these text lists. Agree. A date value naturally ages through the configured threshold. A stale username in a text list never ages out, which supports rather than undermines the need for these lifecycle-aware properties.
There is little prior demand compared with other features. Not a correctness objection. Existing vote counts are useful for maintainer prioritisation, but they do not determine whether a contributed implementation correctly serves its stated use case.

Smaller review items

Review claim Assessment Response
Missing addedAt becomes a transient result and may keep the item protected indefinitely. The mechanism is correct; describing it as an implementation defect is not. Without a trustworthy cutoff the watcher result is unknown. Returning undefined deliberately fails closed rather than converting malformed evidence into "nobody watched" and permitting deletion. The value is retried on subsequent evaluations and the getter logs the failure.
The existing addDate property returns null, so this property should follow that precedent. Disagree. null represents a known absence and can satisfy a DOES_NOT_EXIST comparison. Here the system cannot determine which views qualify, so transient unknown is safer and semantically different.
The properties should support Jellyfin and Emby as well as Plex. A product and scope decision, not a Plex correctness defect. Our new lifecycle tests support provider parity in principle. It remains additional implementation and testing work that should be explicitly requested.
Jellyfin and Emby can both implement this with zero additional requests. Overstated. Jellyfin can likely reuse its timestamp-bearing watch snapshot. Emby's efficient played-item query does not return LastPlayedDate, so a dated implementation requires additional direct reads or a different history source.
The properties need migrateTo or they will be deleted when switching media servers. The incompatibility observation is correct, but migrateTo is not the right remedy today. No semantically equivalent since-added property currently exists on the other providers. Mapping to an all-time watcher property would silently change the rule. The migration service already matches equivalent properties by exact ID and name, then by name, so genuine same-name provider implementations would migrate without migrateTo.
ID 50 should support episode targets because the existing all-time watcher property does. Reasonable optional extension. Episode scope is technically well defined, but the stated feature and cleanup workflow cover show and season cycles. It can be added if the maintainers want that extra scope.
The branch needs to be brought up to date with development. Correct. Agreed. I will bring the branch up to date once the intended scope is settled.
Zero available episodes should return an empty list rather than every recognised Plex user. Reasonable, but separate from this feature. The new helper currently preserves the existing Plex behaviour. Aligning it with Jellyfin and Emby changes the existing property too, so a separate fix: commit and focused regression test, as suggested in the review, is the clean approach.
Manual Plex Mark Watched actions do not create the history rows these properties consume. Confirmed and intentional. These rules use genuine playback history, following the existing watcher rules. Per-user token probing remains outside this PR, and manually marked state already has a separate rule property.
Number.isSafeInteger cannot fail because Plex returns JSON numbers. Incorrect. JSON does not guarantee that an external field is present, integral, non-negative or within JavaScript's safe integer range. The guard prevents malformed deletion evidence from becoming a false empty result.
The malformed timestamp tests cover dead branches. Disagree. They exercise the fail-closed trust-boundary contract through both new getter paths. Skipping or accepting malformed rows could under-report watchers and make media eligible for deletion.
The Set refactor of the existing all-episodes intersection should remain. Agree. It is behaviour-preserving for normal inputs and simpler than the previous reverse-copy and splice loop.

Use case

Consider a cleanup configuration that:

  • preserves a show or season while any participating viewer has not finished it;
  • queues it, with a grace period, once every participating viewer has finished;
  • protects newly added but unwatched media for six months; and
  • removes media after three years without activity as a final fallback.

A user partially watches a show and abandons it. Three years later, the inactivity rule removes it. Another user subsequently requests it again and watches it through to completion.

Without the proposed rules, Plex's retained history still places the original viewer in Users that watched at least one episode, but not Users that watched every episode. The completed-viewing path can therefore never match, even though the only viewer in the current availability cycle has finished. The show instead remains until the three-year fallback applies again.

The proposed rules limit both watcher lists to views recorded after the selected show or season was added. Previous-cycle viewers are ignored unless they watch again, allowing the normal completed-viewing cleanup and grace period to proceed.

Required decisions

  • Which media levels should these rules support? The current implementation supports shows and seasons, which covers the motivating cleanup workflow. Episode-level behaviour could also be added, but is not required for that use case. For an episode target, the "watched at least one" and "watched every episode" lists would necessarily contain the same users, but either value could still be useful independently in a rule. Should episode targets be included?

  • Which media servers should be supported? The current implementation is Plex-only. Our lifecycle testing found that Jellyfin and Emby retain the creation and playback dates needed for equivalent behaviour, although those rule paths have not yet been implemented. Should this PR add support for all three servers? I can expand it to include Jellyfin and Emby if so.

  • How should a re-added season affect a show-level result? The current design gives the selected show or season its own viewing cycle. Re-adding a season therefore starts a new cycle when that season is evaluated, but does not restart the enclosing show's cycle. The alternative is to give each episode its own boundary, allowing different episodes within one show to belong to different cycles. My recommendation is to retain the target-level boundary. For a show-level rule, I consider the cycle to begin when the show is requested and added, and to end when Maintainerr removes it. Replacing one child season while that show remains present should not restart the cycle for the whole show. Are you happy for this PR to retain that behaviour?

Once those are settled, I can get this PR updated accordingly and address the smaller valid issues the review identified at the same time.

@enoch85

enoch85 commented Sep 3, 2026

Copy link
Copy Markdown
Collaborator

On your three questions:

  1. Episode scope: yes, add it to 50 so it matches 18. Leave 49 at show/season like 12.
  2. Servers: all three, same ids and names. Jellyfin and Emby already have the dates, only Emby's "at least one" at show/season gets more expensive, and that is fine if a comment says so.
  3. Cycle boundary: per episode, not per target. Compare each view against the addedAt of the episode it belongs to. Same answer at show and season level, fixes Plex - [list] Users that saw all available episodes breaks when season is re added #2206 at show scope, and it survives a parent record being rebuilt, which I can show on our library without any re-add. Your whole-show case works the same either way.

Keep the Plex zero-episode alignment as its own fix: commit, rebase, and I'm happy to land it.

Claude Opus 4.8: detailed analysis (expand)

What I measured today (Plex 1.43.3)

Test Result
Delete a library section holding an episode with a history row Row survives with ratingKey, parentKey, grandparentKey nulled; grandparentTitle, parentIndex, index, viewedAt, accountID kept
Unmatch a show, refresh it, rematch it Show and season ratingKey and addedAt unchanged throughout
Refresh metadata on a show addedAt unchanged
Re-import after the section delete Could not complete: dev-plex has lost its media mount, no section will scan

The first row is the one that matters. Item linkage is resolved live from the row, not frozen into it, so re-linking to a replacement item is mechanically possible, and your measurement that Plex does it stands. My earlier unmatch experiment only showed that a guid change on a surviving item keeps the row; it said nothing about re-import.

Point by point

Your claim Verdict Evidence
Retained history re-links to replacement ids Accepted Your test; consistent with the nulled-link row above
Fail-closed on missing addedAt is correct Accepted, I was wrong undefined skips the rule, keeps the item in the collection and out of the deletion sweep (rule.comparator.service.ts:293-308, rule-executor.service.ts:1013-1027, collection-worker.service.ts:261-265). null satisfies NOT_EXISTS (executeRulesWithData.spec.ts:475-495), which is fail-open
Number.isSafeInteger guard is a trust boundary, not dead code Accepted Same shape as the #3082 throw in sw_lastViewedAtThroughSeason: stop rather than skip a row
migrateTo is not the remedy Accepted Migration matches (id, name) then name (rule-migration.service.ts:95-119); Plex-only still means the rule is deleted on switch (:270-274), so parity is the fix
Emby cannot do this at zero extra requests Half right getAllEpisodesSeenBy already does per-item /Users/{uid}/Items/{id} reads that carry LastPlayedDate and discards it (emby-adapter.service.ts:931-941, :1015), so 49 is free. getDescendantEpisodeWatchers is the dateless IsPlayed=true&Limit=1 bulk query (:706-728), so 50 at show/season goes from U+1 to about E*(U+1) reads. Jellyfin is free for both: the snapshot and the live sweep both carry the date (jellyfin-adapter.service.ts:1387-1395, :1820-1828)
Target addedAt is the right cycle boundary Disagree See below

Why per episode

Our library has a show at addedAt=1782111555 whose own season 1 is at 1782006781, with three episodes matching the season. Nobody re-added that show. I tried unmatch, rematch and refresh on another show and none of them move addedAt, so I cannot name the cause, but the shape exists in a real library.

Under the target boundary that produces two answers for one view: kept at season level, dropped at show level. I reproduced that with POST /api/rules/test on the PR head:

Property Show Season
sw_watchers ['enoch85'] ['enoch85']
sw_watchersSinceAdded [] ['enoch85']

The show-level drop is the fail-open direction: NOT_EXISTS matches and the media goes. The repo already treats under-reporting views as the one unacceptable outcome (#3082, #3402, ef098c1b).

Per-episode fixes that and also fixes #2206 at show scope, which the target boundary does not. Cost: 49 already enumerates the episodes and they carry addedAt, so it is free. 50 at show/season needs one children listing per season; Plex history rows carry the episode ratingKey to join on, and the children call is cached. Your whole-show re-add case works identically under both, since every episode is new.

Implementation notes

  • Jellyfin and Emby mappers fall back to new Date() when DateCreated is missing (jellyfin.mapper.ts:171, emby.mapper.ts:130). For these two properties that reads as "nobody watched". Treat a missing DateCreated as unreadable there.
  • Same ids 49/50 and names on the Jellyfin list; Emby clones it, and migration then needs nothing.
  • Document the Emby cost for 50 in a code comment, the way id 48 documents its Plex/Jellyfin divergence.
  • Rebase: the seed-db.mjs hunk drops 48:D, and the fake-plex.mjs hunk overwrites the sh1 fixture feat(rules): add episode view date through the target season #3531 added.
  • One question: a trash-retained restore normally keeps the same item ids, yet you report replacement ids in both cases. How was the re-import done (same filenames and paths)? It decides whether addedAt moves at all in the common case.

Infra note for the record

dev-plex cannot currently read any media file (/library/parts/.../file.mp4 returns 404 for a movie and an episode), and Radarr/Sonarr see /media empty. That blocked the re-import test here and is why the earlier probe section scanned nothing.

@enoch85
enoch85 force-pushed the feat/plex-watchers-since-added branch from 09340a6 to c134ff1 Compare September 3, 2026 17:06
@whitestrake

Copy link
Copy Markdown
Collaborator Author

Excellent. I can go ahead with most of that. I do have to push back a little on the proposed cycle boundary, though, for three reasons:

  1. Target-level boundaries are the natural reading of the rule names

    At season level, “users who watched every episode since added” naturally reads as “since this season was added”. At show level, it reads as “since this show was added”. Nothing in either name suggests that every descendant episode supplies its own independent cut-off.

    Most users will configure a rule according to that plain reading if it already appears coherent. They are unlikely to consult the documentation looking for a second, materially different interpretation. Documentation can clarify edge cases, but it should not be required to discover that “since added” actually means “since each individual episode was added”.

    If per-episode boundaries are required, the rule would arguably need to say so explicitly in its name. Otherwise, using the addition date of the selected show or season is the behaviour the existing names communicate.

  2. Episode upgrades can break valid season/show-level results

    Consider a user who requests a show and watches all 12 episodes of a season. Maintainerr then collects it, but during the grace period Sonarr upgrades one episode with a repack, proper or higher-quality release.

    With per-episode cut-offs, that replacement can receive a new addition date later than the user's existing watch. The user remains in watched any through the other episodes, but falls out of watched all, despite genuinely having completed the season and having no reason to watch the replacement again. That directly breaks the watched any = watched all cleanup workflow motivating these rules.

  3. The resulting behaviour differs between media servers

    I tested that lifecycle against live instances of Plex, Jellyfin and Emby. In this test, Plex retained the upgraded episode's identity and addedAt, so the existing watch continued to qualify.

    Jellyfin and Emby instead created a new episode item with a newer DateCreated while retaining the older watched state. Under a per-episode cut-off, that retained watch no longer qualified and the user dropped out of watched all.

    That means an identically configured rule would produce different results depending on the media server. A target-level boundary avoids that divergence and keeps the rule's meaning consistent.

For those reasons, my recommendation is that each rule use the addition date of the level being evaluated: the show's date for a show target, the season's date for a season target, and the episode's date if episode-level support is included.

@enoch85

enoch85 commented Sep 4, 2026

Copy link
Copy Markdown
Collaborator

Agreed, target level it is: show date for a show target, season date for a season target. The upgrade case settles it. A repack on Jellyfin or Emby creating a new episode item with a newer DateCreated would knock a viewer out of "watched every episode" for a season they finished, and that is worse than the case I was guarding against. The naming point is fair too.

Two small things:

  1. Drop the episode scope I asked for. By the same reasoning, an episode target on Jellyfin/Emby would reset its cycle on every upgrade, which is the exact divergence you are avoiding. Keep 49 and 50 at show/season.
  2. The Jellyfin/Emby mappers fall back to new Date() when DateCreated is missing (jellyfin.mapper.ts:171, emby.mapper.ts:130). For these two properties that reads as "nobody watched since added", so treat a missing DateCreated as unreadable there.

Please note in the PR description that a show-level rule does not restart on a season re-add (#2206's exact setup), so people know to write that rule at season level.

@whitestrake
whitestrake force-pushed the feat/plex-watchers-since-added branch from c134ff1 to 50a42a3 Compare September 4, 2026 14:09
@whitestrake whitestrake changed the title feat(rules): add Plex watcher lists since media was added feat(rules): add watcher lists since media was added Sep 4, 2026
@maintainerr-automation maintainerr-automation Bot added emby Concerns the Emby integration or Emby-specific behaviour jellyfin Concerns the Jellyfin integration or Jellyfin-specific behaviour labels Sep 4, 2026
@whitestrake

Copy link
Copy Markdown
Collaborator Author

I've now updated the branch following our discussion and force-pushed it as a clean two-commit history based on current development.

The main commit adds both since-added watcher lists to Plex, Jellyfin and Emby using the same property IDs and names:

  • Users that watched at least one episode since added
  • Users that watched every episode since added

Both remain limited to show and season targets. As agreed, each uses the selected target's own addition date as the viewing-cycle boundary, with a strict viewedAt > addedAt comparison. Re-adding a season starts a new season-level cycle but does not restart the enclosing show's cycle. Episode targets have been left out.

Missing or malformed addition and watch dates fail closed rather than producing a false empty list. Jellyfin and Emby no longer manufacture a current date when DateCreated is absent on these paths. The Emby implementation also keeps its per-episode, per-user history reads serial, with the cost documented in the code.

The existing Plex zero-episode behaviour for Users that watched every episode has been aligned with Jellyfin and Emby in the separate fix: commit you requested.

Everything has been retested from the final branch state, including Playwright tests of both rules, at both target levels, against all three servers.

@whitestrake

Copy link
Copy Markdown
Collaborator Author

Accompanying docs PR raised for your consideration at Maintainerr/Maintainerr_docs#169.

@enoch85

enoch85 commented Sep 4, 2026

Copy link
Copy Markdown
Collaborator

Thanks!

There are some small fixes I will implement. When committed, please test and check that everything is OK still.

…roperties

Emby now threads the cutoff through getAllEpisodesSeenBy and getSwWatchers
the way Plex and Jellyfin do, instead of carrying a second enumeration and
its own user mapping beside them. Both dated variants map users exactly
like their base property on each server.

A completed watch with no date is skipped rather than failing the read,
matching sw_lastViewedAtThroughSeason; a malformed date still propagates.
addDate and sw_lastEpisodeAddedAt on Jellyfin and Emby no longer pass an
invalid Date through when DateCreated is missing.

Specs trimmed to the cases that pin the contract.
@enoch85

enoch85 commented Sep 4, 2026

Copy link
Copy Markdown
Collaborator

Pushed 3bf1581e on top of your two commits with the follow-ups from the review:

Item Done
2 Fold Emby getAllEpisodesSeenBy and getSwWatchers take an optional watchedAfter like Plex and Jellyfin. The intersection reuses the existing per-item reads (dates were already fetched); the dated union reads per episode since the bulk IsPlayed listing has no dates, with the cost in one comment. The four parallel functions and their extra user-mapping path are gone. Side effect: Emby id 12 now enumerates children strictly (fail closed), matching Jellyfin.
3 Match 48 Shared isWatchedAfter() in rule-property.helper.ts: a dateless completed watch is skipped, a malformed date throws. Used by Jellyfin and Emby.
4 Invalid Date Shared isValidDate(); addDate returns null and sw_lastEpisodeAddedAt skips the item on Jellyfin/Emby when DateCreated is missing. Tracearr comment corrected (all three mappers leave it invalid; Plex always did).
5 Mapping Both since-added variants map users exactly like their base property on each server; the mapMatchingRuleUsersToNames branches are gone.
6 Tests Specs cut to the contract: union, intersection, missing/invalid cutoff, dateless skip vs malformed date, zero episodes. Emby -395, Jellyfin -248, Plex -44 lines.

@whitestrake

Copy link
Copy Markdown
Collaborator Author

Just had a look into this latest commit. The focused getter tests, complete server suite and repository-wide suite all pass, and the consolidation generally looks good. I noticed three things I think need a quick review:

  1. The Emby history reads are no longer serial. Both dated rules now use Promise.all() across every episode, while each episode history lookup fans out across all users. Our previous implementation deliberately processed episodes sequentially and had a focused test protecting that behaviour. That test has now been removed, and the PR description still says the reads remain serial to avoid nested request bursts. Was restoring the existing parallel behaviour intentional? If so, I will update the description; otherwise I think the serial safeguard should remain.

  2. The user-mapping contract has changed. Matching each provider’s base property means Jellyfin can now return a raw unresolved account ID, while Plex and Emby normally return only recognised users. That is a defensible consistency choice within each provider, but the PR description currently says the rules return recognised users. Should the implementation filter unresolved users consistently across providers, or should I update the description to reflect the provider-specific behaviour?

  3. The focused target-boundary test was removed. Most of the test reduction looks sensible, but I think one explicit test proving that the selected show or season’s addedAt is used rather than a descendant episode’s date is still valuable. That behaviour was a central design decision from our discussion, particularly because using episode dates breaks the upgrade case on Jellyfin and Emby.

The missing-versus-malformed timestamp distinction and the existing-property invalid-date fixes otherwise look sound to me.

…oundary

The Emby adapter now answers getDescendantEpisodeWatchHistory like the
Jellyfin adapter does: the season and episode walk plus the per-episode
reads, walked in batches of EMBY_BATCH_SIZE.EPISODE_WATCH_HISTORY beside
its per-user batching. The Emby getter consumes the record map the way
the Jellyfin getter does, for sw_allEpisodesSeenBy as well as the dated
variants, so the fan-out is bounded in one place. Measured against a real
Emby server through a counting proxy: an 8-episode show peaks at 5 reads
in flight for ids 12, 49 and 50.

Each getter spec pins that the selected show or season's addedAt is the
boundary, not a descendant episode's, which is what keeps an upgraded
episode on Jellyfin or Emby from resetting a finished season.
@enoch85
enoch85 force-pushed the feat/plex-watchers-since-added branch from 11f3a98 to a3c74f4 Compare September 4, 2026 21:25
@enoch85

enoch85 commented Sep 4, 2026

Copy link
Copy Markdown
Collaborator

Pushed a3c74f4f for the three points:

  1. Batching, adapter-side like the others. The Emby adapter now has getDescendantEpisodeWatchHistory(), the same method and record-map shape the Jellyfin adapter exposes: the season/episode walk plus the per-episode reads in batches of EMBY_BATCH_SIZE.EPISODE_WATCH_HISTORY (5, next to USER_WATCH_HISTORY), failing closed on any read error. The Emby getter mirrors the Jellyfin getter (descendantWatchHistory with the isWatchedAfter filter, intersection and union over the map), and sw_allEpisodesSeenBy rides the same walk, so the fan-out is bounded in one place for ids 12, 49 and 50.
  2. Description updated: parallel in batches, and user mapping follows each provider's base property.
  3. Target-boundary tests on Plex, Jellyfin and Emby: an episode dated after the retained view still counts because the target's addedAt is the boundary. The adapter spec covers the map shape and that a failed episode read propagates.

E2E on a real Emby through a counting proxy, 8-episode series at show level:

rule requests peak in flight
12 every episode 21 5
49 every since added 21 5
50 at least one since added 21 5

21 = 8 episode reads + the show read, 8+1 /Users/Query, 3 listings. Unbatched would peak at 8.

@enoch85 enoch85 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approve in current state.

@whitestrake Please confirm you agree on everything here, and test it yourself please. :)

@whitestrake

Copy link
Copy Markdown
Collaborator Author

Just had a fresh look at commit a3c74f4, with API and Playwright tests.

@enoch85 all pass on my end, upgraded-episode case still preserves completion, adapter and batching looked good, target boundary verified.

LGTM! Ready to merge.

@enoch85
enoch85 merged commit 294ab7e into Maintainerr:development Sep 5, 2026
17 checks passed
@whitestrake
whitestrake deleted the feat/plex-watchers-since-added branch September 5, 2026 08:55
@maintainerr-automation

Copy link
Copy Markdown
Contributor

🎉 This PR is included in version 3.27.0 🎉

The release is available on GitHub release

Your semantic-release bot 📦🚀

doonga pushed a commit to greyrock-labs/home-ops that referenced this pull request Sep 5, 2026
… ➔ 3.27.0) (#121)

This PR contains the following updates:

| Package | Update | Change |
|---|---|---|
| [ghcr.io/maintainerr/maintainerr](https://github.com/Maintainerr/Maintainerr) | minor | `3.26.0` → `3.27.0` |

---

### Release Notes

<details>
<summary>Maintainerr/Maintainerr (ghcr.io/maintainerr/maintainerr)</summary>

### [`v3.27.0`](https://github.com/Maintainerr/Maintainerr/blob/HEAD/CHANGELOG.md#3270-2026-09-05)

[Compare Source](Maintainerr/Maintainerr@v3.26.0...v3.27.0)

#### Highlights

- Resolved an issue where items were incorrectly marked as manual in collections, preventing rule-based removal ([#&#8203;3636](Maintainerr/Maintainerr#3636)).
- Added new watcher list rules for Plex, Jellyfin, and Emby to filter by users who watched media since it was added ([#&#8203;3532](Maintainerr/Maintainerr#3532)).

#### Fixes

- Read the Sonarr episode list fresh before unmonitoring or deleting files ([#&#8203;3676](Maintainerr/Maintainerr#3676)).
- Increase HTTP timeouts to prevent premature deletion failures during large media server operations ([#&#8203;3674](Maintainerr/Maintainerr#3674), [#&#8203;3673](Maintainerr/Maintainerr#3673)).
- Improve UI error reporting for bulk actions to provide specific failure reasons ([#&#8203;3643](Maintainerr/Maintainerr#3643)).
- Drop rule-removal markers when a collection stops syncing ([#&#8203;3644](Maintainerr/Maintainerr#3644)).
- Correctly identify custom collections as siblings when guarding adoption ([#&#8203;3641](Maintainerr/Maintainerr#3641)).
- Clear cached collection children after a Plex collection mutation ([#&#8203;3640](Maintainerr/Maintainerr#3640)).
- Treat an unset deletion window as never instead of immediately ([#&#8203;3639](Maintainerr/Maintainerr#3639)).
- Report additions that failed due to missing media server collections ([#&#8203;3638](Maintainerr/Maintainerr#3638)).
- Prevent stale-link resync from incorrectly marking manual collection members as manual ([#&#8203;3637](Maintainerr/Maintainerr#3637)).
- Prevent calendar month skipping when navigating dates ([#&#8203;3635](Maintainerr/Maintainerr#3635)).

#### Database migrations

- Added a `direction` column to the `collection_media_rule_removal` table to track pending removal actions.

#### Internal

- Refactor bulk outcome reporting to use the outcome object directly ([#&#8203;3654](Maintainerr/Maintainerr#3654)).
- Refactor rule capability checks to query server capabilities instead of server type ([#&#8203;3642](Maintainerr/Maintainerr#3642)).
- Transpile NestJS packages to support ESM under Jest ([#&#8203;3628](Maintainerr/Maintainerr#3628)).

#### Dependencies

- Updated 34 dependencies including NestJS, Jest, and various ESLint plugins.

</details>

---

### Configuration

📅 **Schedule**: (in timezone America/New_York)

- Branch creation
  - At any time (no schedule defined)
- Automerge
  - At any time (no schedule defined)

🚦 **Automerge**: Disabled by config. Please merge this manually once you are satisfied.

♻ **Rebasing**: Whenever PR becomes conflicted, or you tick the rebase/retry checkbox.

🔕 **Ignore**: Close this PR and you won't be reminded about these updates again.

---

 - [ ] <!-- rebase-check -->If you want to rebase/retry this PR, check this box

---

This PR has been generated by [Mend Renovate CLI](https://github.com/renovatebot/renovate).
<!--renovate-debug:eyJjcmVhdGVkSW5WZXIiOiI0NC41Mi4xIiwidXBkYXRlZEluVmVyIjoiNDQuNTIuMSIsInRhcmdldEJyYW5jaCI6Im1haW4iLCJsYWJlbHMiOlsicmVub3ZhdGUvY29udGFpbmVyIiwidHlwZS9taW5vciJdfQ==-->

Reviewed-on: https://git.greyrock.io/todd/home-ops/pulls/121
eleboucher pushed a commit to eleboucher/homelab that referenced this pull request Sep 8, 2026
… ➔ 3.27.0) (#2037)

This PR contains the following updates:

| Package | Update | Change |
|---|---|---|
| [ghcr.io/maintainerr/maintainerr](https://github.com/Maintainerr/Maintainerr) | minor | `3.26.0` → `3.27.0` |

---

> ⚠️ **Warning**
>
> Some dependencies could not be looked up. Check the [Dependency Dashboard](issues/567) for more information.

---

### Release Notes

<details>
<summary>Maintainerr/Maintainerr (ghcr.io/maintainerr/maintainerr)</summary>

### [`v3.27.0`](https://github.com/Maintainerr/Maintainerr/blob/HEAD/CHANGELOG.md#3270-2026-09-05)

[Compare Source](Maintainerr/Maintainerr@v3.26.0...v3.27.0)

#### Highlights

- Resolved an issue where items were incorrectly marked as manual in collections, preventing rule-based removal ([#&#8203;3636](Maintainerr/Maintainerr#3636)).
- Added new watcher list rules for Plex, Jellyfin, and Emby to filter by users who watched media since it was added ([#&#8203;3532](Maintainerr/Maintainerr#3532)).

#### Fixes

- Read the Sonarr episode list fresh before unmonitoring or deleting files ([#&#8203;3676](Maintainerr/Maintainerr#3676)).
- Increase HTTP timeouts to prevent premature deletion failures during large media server operations ([#&#8203;3674](Maintainerr/Maintainerr#3674), [#&#8203;3673](Maintainerr/Maintainerr#3673)).
- Improve UI error reporting for bulk actions to provide specific failure reasons ([#&#8203;3643](Maintainerr/Maintainerr#3643)).
- Drop rule-removal markers when a collection stops syncing ([#&#8203;3644](Maintainerr/Maintainerr#3644)).
- Correctly identify custom collections as siblings when guarding adoption ([#&#8203;3641](Maintainerr/Maintainerr#3641)).
- Clear cached collection children after a Plex collection mutation ([#&#8203;3640](Maintainerr/Maintainerr#3640)).
- Treat an unset deletion window as never instead of immediately ([#&#8203;3639](Maintainerr/Maintainerr#3639)).
- Report additions that failed due to missing media server collections ([#&#8203;3638](Maintainerr/Maintainerr#3638)).
- Prevent stale-link resync from incorrectly marking manual collection members as manual ([#&#8203;3637](Maintainerr/Maintainerr#3637)).
- Prevent calendar month skipping when navigating dates ([#&#8203;3635](Maintainerr/Maintainerr#3635)).

#### Database migrations

- Added a `direction` column to the `collection_media_rule_removal` table to track pending removal actions.

#### Internal

- Refactor bulk outcome reporting to use the outcome object directly ([#&#8203;3654](Maintainerr/Maintainerr#3654)).
- Refactor rule capability checks to query server capabilities instead of server type ([#&#8203;3642](Maintainerr/Maintainerr#3642)).
- Transpile NestJS packages to support ESM under Jest ([#&#8203;3628](Maintainerr/Maintainerr#3628)).

#### Dependencies

- Updated 34 dependencies including NestJS, Jest, and various ESLint plugins.

</details>

---

### Configuration

📅 **Schedule**: (in timezone Europe/Paris)

- Branch creation
  - At any time (no schedule defined)
- Automerge
  - At any time (no schedule defined)

🚦 **Automerge**: Disabled by config. Please merge this manually once you are satisfied.

♻ **Rebasing**: Whenever PR becomes conflicted, or you tick the rebase/retry checkbox.

🔕 **Ignore**: Close this PR and you won't be reminded about these updates again.

---

 - [ ] <!-- rebase-check -->If you want to rebase/retry this PR, check this box

---

This PR has been generated by [Mend Renovate CLI](https://github.com/renovatebot/renovate).
<!--renovate-debug:eyJjcmVhdGVkSW5WZXIiOiI0NC41Mi4xIiwidXBkYXRlZEluVmVyIjoiNDQuNTIuMSIsInRhcmdldEJyYW5jaCI6Im1haW4iLCJsYWJlbHMiOlsicmVub3ZhdGUvY29udGFpbmVyIiwidHlwZS9taW5vciJdfQ==-->

Reviewed-on: https://git.erwanleboucher.dev/eleboucher/homelab/pulls/2037
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

emby Concerns the Emby integration or Emby-specific behaviour enhancement New feature or request jellyfin Concerns the Jellyfin integration or Jellyfin-specific behaviour plex Concerns the Plex integration or Plex-specific behaviour released

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants