Skip to content

fix(server): Use shared watch state for isWatched and Plex viewCount#2453

Closed
wortmanb wants to merge 10 commits into
Maintainerr:developmentfrom
wortmanb:fix-plex-viewcount-use-native-field
Closed

fix(server): Use shared watch state for isWatched and Plex viewCount#2453
wortmanb wants to merge 10 commits into
Maintainerr:developmentfrom
wortmanb:fix-plex-viewcount-use-native-field

Conversation

@wortmanb

@wortmanb wortmanb commented Mar 3, 2026

Copy link
Copy Markdown
Contributor

Summary

Addresses reviewer feedback from the original PR. The previous approach replaced the watch history API entirely with libItem.viewCount, which is per-user (admin token only) and not suitable for server-wide view counts.

viewCount (revised approach)

  • Primary: watch history API (/status/sessions/history/all) — server-wide, counts all users
  • Fallback: libItem.viewCount when history returns 0 or the API fails — per-user (admin), but better than returning 0 when Plex has purged watch history
  • Debug logging when fallback is used, noting the per-user limitation
Scenario Before (original code) After (this PR)
History available Uses history count Uses history count (same)
History purged Returns 0 (wrong) Falls back to admin viewCount with warning
API failure Uncaught error Falls back to admin viewCount with warning

isWatched (new property)

New boolean rule option: Plex - Is Watched (id: 43, movie type).

Returns true if the item has been watched by anyone:

  1. Checks watch history first (server-wide)
  2. Falls back to libItem.viewCount > 0 if history is empty/fails

This provides a simple "has this been watched?" check that is resilient to Plex history purging.

Re: Jellyfin parity (enoch85)

Noted that Jellyfin viewCount is derived from aggregated watch history, not a native counter. Aligning Plex and Jellyfin semantics is worthwhile but orthogonal to this fix — suggest addressing in a follow-up.

Re: remove/re-add behavior (ydkmlt84)

When an item is removed from Plex and re-added, the watch history is lost and viewCount resets to 0. Neither the history API nor the native counter survives this. The isWatched boolean has the same limitation. This is a Plex-level constraint.

Test plan

  • 10 unit tests covering viewCount (5 cases) and isWatched (5 cases): history available, history empty with fallback, history empty without fallback, API failure with fallback, API failure without fallback
  • All 510 existing tests pass (0 regressions)
  • Formatted with Prettier

@wortmanb wortmanb requested a review from ydkmlt84 as a code owner March 3, 2026 14:49
@wortmanb wortmanb force-pushed the fix-plex-viewcount-use-native-field branch from b3179b5 to 498bf67 Compare March 4, 2026 09:59
@enoch85

enoch85 commented Mar 8, 2026

Copy link
Copy Markdown
Collaborator

Hmm, wonder if the same can be done for Jellyfin...

enoch85 commented Mar 8, 2026

Copy link
Copy Markdown
Collaborator

@wortmanb Nice direction. It may also be worth checking Jellyfin for parity: Jellyfin viewCount is currently derived from aggregated watch history, not a native item counter, so Plex and Jellyfin may still diverge semantically. Might be worth aligning them by intent if possible.

enoch85 commented Mar 8, 2026

Copy link
Copy Markdown
Collaborator

More concretely, if we want Plex and Jellyfin to behave closer:

  • seenBy / sw_watchers: use completed-watch semantics per user. Jellyfin now does this via its watched threshold; Plex may still need history for this.
  • viewCount: use total completed views. Plex native viewCount looks right; Jellyfin should be checked separately because its current value is derived differently.
  • lastViewedAt: prefer native item timestamps where available instead of rebuilding from history.
  • show/season watched counts: prefer native watched-child counters where available, otherwise aggregate.

@ydkmlt84

ydkmlt84 commented Mar 9, 2026

Copy link
Copy Markdown
Collaborator

How would this effect an item that had a previous view count, was removed and then added back at a later date. Does it start back at 0 or do the view counts start from where they were before? I don't think the current watch history method addresses this either, but it has come up in previous conversations elsewhere and made me curious.

@ydkmlt84

ydkmlt84 commented Mar 9, 2026

Copy link
Copy Markdown
Collaborator

The data that is being used in this change is cached for 5 minutes. Would not be/could not be a problem unless you are trying to run rules or test media shortly after marking something as watched. Lowering that cache is not the fix there.

The fix is to use the PlexMetadata pulled at the top of the getter, which is already called and has per item cache updates. It currently does not have viewCount as a returned item. Adding it isn't a solution either as that call, and its results, are per the user token who pulled the metadata (whoever is authenticated in Maintainerr and usually admin).

So viewCount for the admin user might be 1 and another user looking at viewCount would see 2 (only there two watches). Maintainerr only sees 1 instead of 3. So that isn't a viable option either when you have multiple users and want viewcount for the entire server, across all users.

Unfortunately, I cannot merge this the way it is. 😞

@wortmanb

wortmanb commented Mar 9, 2026

Copy link
Copy Markdown
Contributor Author

I'll look into addressing these issues today. I appreciate the detailed comments.

viewCount: restore watch history API as primary source (server-wide
across all users) with fallback to libItem.viewCount when history
is empty or API fails. The fallback is per-user (admin token) but
is better than returning 0 when Plex has purged watch history.

isWatched: new boolean property (Plex - Is Watched) that returns
true if the item has been watched by anyone. Checks watch history
first, falls back to libItem.viewCount > 0.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@wortmanb wortmanb force-pushed the fix-plex-viewcount-use-native-field branch from 498bf67 to db4697d Compare March 9, 2026 10:50
@wortmanb wortmanb changed the title fix: use Plex native viewCount instead of watch history API fix: improve Plex viewCount reliability and add isWatched boolean Mar 9, 2026
@wortmanb

wortmanb commented Mar 9, 2026

Copy link
Copy Markdown
Contributor Author

Updated the PR based on the review feedback. Here's what changed:

@ydkmlt84 — You were right that the original approach (replacing watch history with libItem.viewCount) doesn't work for multi-user servers since it's per-admin-token. The revised approach:

  • Restores watch history API as the primary data source (server-wide)
  • Adds libItem.viewCount as a fallback only when history returns 0 or the API fails, with debug logging that notes the per-user limitation
  • This is strictly better than the original code which had no fallback at all

On the remove/re-add question: both watch history and native viewCount reset when an item is removed and re-added to Plex. That's a Plex-level constraint neither approach can work around.

@enoch85 — Agreed on Jellyfin parity. The Jellyfin viewCount derives from aggregated watch history rather than a native counter, so the semantics already differ. Aligning them by intent would be a good follow-up but felt out of scope for this fix.

New addition: Added a Plex - Is Watched boolean property (id: 43) that returns true if watch history has entries OR libItem.viewCount > 0. Provides a simple "has this been watched by anyone?" check that's resilient to history purging.

All 510 tests pass, 10 new tests covering both viewCount and isWatched.

@wortmanb

wortmanb commented Mar 9, 2026

Copy link
Copy Markdown
Contributor Author

The boolean moves this update to a new rule type, so as not to interferre with any existing rules users might have. I left a fallback there, to check libItem.viewCount if the history returns 0 as a double-check, but otherwise, everything has been moved to "Plex - Is Watched".

enoch85 added a commit that referenced this pull request Mar 9, 2026
…, #2442, #2406, #2386, #2370

PR #2466 - fix: honor Jellyfin played threshold
- Respect configured played percentage threshold for Jellyfin watch status

PR #2461 - feat(rules): add ARR disk target path selection for disk space rules
- Allow selecting specific disk target paths for Radarr/Sonarr disk space rules

PR #2458 - feat: clean up empty ended shows in Sonarr after season actions
- Automatically remove ended shows from Sonarr when all seasons are processed

PR #2453 - fix: improve Plex viewCount reliability and add isWatched boolean
- Use native Plex viewCount field with watch history fallback
- Add new isWatched boolean rule property

PR #2452 - build(deps): bump actions/download-artifact from 7 to 8

PR #2451 - build(deps): bump actions/upload-artifact from 6 to 7

PR #2442 - fix(server): reject null/undefined in numeric rule comparisons
- Add getComparisonResult wrapper that fails closed on null/undefined operands
- Strict type checking for BIGGER/SMALLER comparisons

PR #2406 - Metadata provider abstraction layer with TVDB support
- Add MetadataService as central metadata resolution layer
- TVDB support as alternative metadata provider
- Dynamic provider preference with fallback
- Replace TmdbIdService with unified MetadataService

PR #2386 - feat: missing_episode rules
- Add missing episode count as a rule property for Sonarr

PR #2370 - build(deps-dev): bump the eslint group with 2 updates

enoch85 commented Mar 9, 2026

Copy link
Copy Markdown
Collaborator

This is now included in the jellyfin-dev docker container - among some other fixes. To upgrade, change tag in docker from latest (or whatever you have) to jellyfin-dev. Then do:

docker compose pull jellyfin-dev && docker compose up -d

You can check the latest commits here: https://github.com/Maintainerr/Maintainerr/commits/jellyfin-dev

  1. Consider the jellyfin-dev branch to be early development, and make a backup before switching to that branch!
  2. Please test that your issue is fixed
  3. Report back here, and tag me and @ydkmlt84

Thank you very much! 🚀

enoch85 commented Mar 10, 2026

Copy link
Copy Markdown
Collaborator

Added a small follow-up on top of this PR to keep the new watched rule aligned with the media-server abstraction.

Why this change:

  • isWatched is a cross-server concept, not a Plex-only one.
  • The original fallback behavior for Plex viewCount/isWatched was duplicated in the getter.
  • Rule migration matches properties by name, so defining the same isWatched property for Jellyfin makes the rule portable instead of incompatible.
  • I cross-checked the watch-state behavior against both integrations/APIs before widening scope: Jellyfin exposes Played, PlayedPercentage, PlayCount, and LastPlayedDate in item user data, and the Plex history path used here is item-scoped, so the same watched check works for episodes as well as movies.

Why this is better:

  • Watch-state logic now lives in the media-server adapters, which keeps Plex-specific fallback handling out of the getter layer.
  • Plex and Jellyfin both expose the same rule name with the same item-level semantics, so migration and maintenance are simpler.
  • The rule was widened to movies + episodes only; show/season semantics stay separate because watched is ambiguous there and we already have dedicated show-level rules for that.

I also ran yarn format and the full yarn test suite after the change set.

@enoch85

enoch85 commented Mar 10, 2026

Copy link
Copy Markdown
Collaborator

@ydkmlt84 This is now in a state were I can accept the changes. Please have a look.

@Maintainerr Maintainerr deleted a comment from github-actions Bot Mar 10, 2026
enoch85 added a commit that referenced this pull request Mar 10, 2026
PR #2453 - fix: improve Plex viewCount reliability and add isWatched boolean
- Use native Plex viewCount field with watch history fallback
- Add new isWatched boolean rule property
- Share watched rule behavior across media servers
@enoch85

enoch85 commented Mar 10, 2026

Copy link
Copy Markdown
Collaborator

@wortmanb This is now included in jellyfin-dev for testing. Please report back.

@enoch85

enoch85 commented Mar 10, 2026

Copy link
Copy Markdown
Collaborator

Note to self: #2466 (comment)

enoch85 added a commit that referenced this pull request Mar 10, 2026
PR #2453 - fix: improve Plex viewCount reliability and add isWatched boolean
- Use native Plex viewCount field with watch history fallback
- Add new isWatched boolean rule property
- Share watched rule behavior across media servers
- Use uncached Plex history for watched rules
- Share Plex test fixtures
@enoch85

enoch85 commented Mar 10, 2026

Copy link
Copy Markdown
Collaborator

@ydkmlt84

Note to self: #2466 (comment)

The last two commits finish the Plex-side fix from that comment. The functional change removes the fallback to metadata viewCount and uses the item-scoped Plex history endpoint directly, with cache bypassed for watched-rule evaluation. That means the rule no longer depends on the admin-scoped viewCount field and is not stale for up to 5 minutes.

The newest commit is test-only. It extracts shared Plex fixtures so the new history-based behavior is covered more clearly and with less duplicated setup.

@enoch85 enoch85 changed the title fix: improve Plex viewCount reliability and add isWatched boolean fix(server): Use shared watch state for isWatched and Plex viewCount Mar 10, 2026
@enoch85

enoch85 commented Mar 10, 2026

Copy link
Copy Markdown
Collaborator

@wortmanb Sorry for the iterations here, I just forgot the discussion I had, and the comment, from @ydkmlt84 earlier.

Thank s a lot for your work! I'm now done with this. Have been over it several times and are pleased with the results. The latest code is in the jellyfin-dev branch if you want to test. Let's see what @ydkmlt84 has to say.

@enoch85 enoch85 self-assigned this Mar 10, 2026
@wortmanb

Copy link
Copy Markdown
Contributor Author

No worries! I was out of communication for a few days but will take a look this weekend. I guess I need to figure out how to get Jellyfin running alongside Plex now. ;-)

Thanks for your work on this!

@wortmanb

Copy link
Copy Markdown
Contributor Author

I just deployed the jellyfin-dev branch. Unfortunately for my use case, this doesn't seem to work. Neither the "Plex - Is Watched = False" nor the "Plex - Times Viewed = 0" are picking up movies that I marked as watched.

@enoch85

enoch85 commented Mar 17, 2026

Copy link
Copy Markdown
Collaborator

I just deployed the jellyfin-dev branch. Unfortunately for my use case, this doesn't seem to work. Neither the "Plex - Is Watched = False" nor the "Plex - Times Viewed = 0" are picking up movies that I marked as watched.

Hmm, it works for me. 🤔

@enoch85

enoch85 commented Mar 17, 2026

Copy link
Copy Markdown
Collaborator

@wortmanb How did you deploy the jellyfin-dev branch, with docker tag?

@wortmanb

Copy link
Copy Markdown
Contributor Author

I checked it out locally and deployed it to my k8s cluster via:

❯ kubectl -n media set image deploy/maintainerr main=ghcr.io/maintainerr/maintainerr:jellyfin-dev
deployment.apps/maintainerr image updated
❯ kubectl -n media rollout status deploy/maintainerr --timeout=180s
Waiting for deployment "maintainerr" rollout to finish: 0 of 1 updated replicas are available...
deployment "maintainerr" successfully rolled out

@enoch85

enoch85 commented Mar 17, 2026

Copy link
Copy Markdown
Collaborator

I checked it out locally and deployed it to my k8s cluster via:

❯ kubectl -n media set image deploy/maintainerr main=ghcr.io/maintainerr/maintainerr:jellyfin-dev
deployment.apps/maintainerr image updated
❯ kubectl -n media rollout status deploy/maintainerr --timeout=180s
Waiting for deployment "maintainerr" rollout to finish: 0 of 1 updated replicas are available...
deployment "maintainerr" successfully rolled out

OK, seems correct.

I will have another look when time allows. 👍

@wortmanb

Copy link
Copy Markdown
Contributor Author

Actually, I also noticed that a number of movies that should be collected are included too. My rules are this, but I went from 26 in the collection under my code change to 112 now.

Plex - Date Added > 365 AND
Plex - IsWatched = false AND
Plex - Present in amount of other collections (incl. smart collections) = 0

I've also done this changing the second rule to:

Plex - Times Viewed = 0

and got the same result.

@enoch85

enoch85 commented Mar 17, 2026

Copy link
Copy Markdown
Collaborator

Actually, I also noticed that a number of movies that should be collected are included too. My rules are this, but I went from 26 in the collection under my code change to 112 now.

Plex - Date Added > 365 AND Plex - IsWatched = false AND Plex - Present in amount of other collections (incl. smart collections) = 0

I've also done this changing the second rule to:

Plex - Times Viewed = 0

and got the same result.

Can you export the YAML rule here and tell me the exact expectations?

@wortmanb

Copy link
Copy Markdown
Contributor Author
mediaType: MOVIES
rules:
  - "0":
      - firstValue: Plex.addDate
        action: BEFORE
        customValue:
          type: custom_days
          value: "340"
  - "1":
      - operator: AND
        firstValue: Plex.viewCount
        action: EQUALS
        customValue:
          type: number
          value: 0
  - "2":
      - operator: AND
        firstValue: Plex.collectionsIncludingSmart
        action: EQUALS
        customValue:
          type: number
          value: 0

This should find all movies added over a year ago that haven't been watched and aren't in any collections.

@enoch85

enoch85 commented Mar 17, 2026

Copy link
Copy Markdown
Collaborator
mediaType: MOVIES
rules:
  - "0":
      - firstValue: Plex.addDate
        action: BEFORE
        customValue:
          type: custom_days
          value: "340"
  - "1":
      - operator: AND
        firstValue: Plex.viewCount
        action: EQUALS
        customValue:
          type: number
          value: 0
  - "2":
      - operator: AND
        firstValue: Plex.collectionsIncludingSmart
        action: EQUALS
        customValue:
          type: number
          value: 0

This should find all movies added over a year ago that haven't been watched and aren't in any collections.

Thanks!

I don't see any "watched" rule in there?

Also, what does the test say for two known items?

@wortmanb

wortmanb commented Mar 17, 2026

Copy link
Copy Markdown
Contributor Author

This is breaking my brain a little, but I think it's good. The strict AND means all 3 conditions need to be met, so it's normal and good that I'm seeing watched, but uncollected, movies over 365 days old in my collection.

Wait, that's not right, is it? The only movies in there should be unwatched, uncollected, and older than 365 days.

Still not quite what I'm looking for. Could be because some of these are marked watched but never viewed, since I had to re-create my library a while back.

@enoch85

enoch85 commented Mar 17, 2026

Copy link
Copy Markdown
Collaborator

I agree, this fixes my issue.

So all good, no need to change anything here?

@wortmanb

wortmanb commented Mar 17, 2026

Copy link
Copy Markdown
Contributor Author

No, this isn't a fix for me yet. I think the issue might be "watched" versus "viewed". And maybe I'm in the extreme minority who needs to know whether the movie was watched, not whether it was viewed.

@enoch85

enoch85 commented Mar 21, 2026

Copy link
Copy Markdown
Collaborator

Hey @wortmanb, I think I see what's going on here.

You re-created your Plex library, so the watch history is gone. On top of that, some movies are "marked as watched" but were never actually played — so there's no history entry for them either.

The thing is, Plex has two separate concepts: watch history (actual play events from the history API) and watched state (the checkmark in the UI, stored as item metadata). These are not the same thing. Right now both viewCount and isWatched only use the history API, so items without history always return 0/false regardless of what Plex shows in the UI.

Your original fallback to libItem.viewCount covered this, but it got removed in d04c208 because it's per-user (admin token). Fair point for the numeric count, but it broke detection of "marked watched" items entirely.

I've pushed a fix (9b3ddfe) that re-adds the native viewCount as a fallback for the isWatched boolean only — the numeric viewCount still uses history to keep server-wide accuracy.

I will push this to jellyfin-dev in about 5-10 min, please update to the latest image and try again. Please confirm the fix, o if it's still buggy.

enoch85 added a commit that referenced this pull request Mar 21, 2026
PR #2453 - fix(server): fall back to native Plex viewCount for isWatched
- Use native Plex viewCount as fallback for isWatched boolean when watch history is empty
- Covers items marked watched without play events and purged history
- Numeric viewCount still uses history only to preserve server-wide accuracy
When watch history is empty (purged or item marked watched without a
play event), use the native Plex viewCount from item metadata as a
fallback signal for the isWatched boolean.  The numeric viewCount
still uses history only since the native value is per-user (admin
token) and would misrepresent server-wide counts.
@enoch85

enoch85 commented Mar 24, 2026

Copy link
Copy Markdown
Collaborator

@wortmanb Did you have any chance to test the latest changes?

@enoch85 enoch85 self-requested a review as a code owner March 24, 2026 19:54
@enoch85 enoch85 changed the base branch from main to development March 30, 2026 21:16

enoch85 commented Mar 30, 2026

Copy link
Copy Markdown
Collaborator

Rebased onto latest development and opened as #2570 (conflict-free). Thank you @wortmanb for the contribution!

@enoch85 enoch85 closed this Mar 30, 2026
@enoch85

enoch85 commented Mar 30, 2026

Copy link
Copy Markdown
Collaborator

Rebased onto latest development and opened as #2570 (conflict-free). Thank you @wortmanb for the contribution!

@wortmanb may sound a bit harsh, but since we made workflow changes that now are stricter and you can't push to that, the only option was a new PR.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants