Skip to content

feat: respect per-user library and label restrictions when reporting availability (Plex, Jellyfin, Emby) - #3296

Open
Izakun wants to merge 16 commits into
seerr-team:developfrom
Izakun:feat/per-user-library-scope
Open

feat: respect per-user library and label restrictions when reporting availability (Plex, Jellyfin, Emby)#3296
Izakun wants to merge 16 commits into
seerr-team:developfrom
Izakun:feat/per-user-library-scope

Conversation

@Izakun

@Izakun Izakun commented Jul 26, 2026

Copy link
Copy Markdown

Description

Media servers let the owner restrict a shared user to specific libraries, and Plex additionally to items carrying specific labels (Library → Restrictions → "Allow only labels"). Seerr computed availability for the whole library regardless of who was asking, so a restricted user was shown titles as Available and then could not play them. In a multi-user setup that is a constant source of "why doesn't this work" questions, and it also leaks what other users have been adding.

This resolves the sharing rules of the requesting user and reports availability from their point of view, for Plex, Jellyfin and Emby.

How it works

A dispatcher picks the resolver from the configured media server type and owns a five minute per-user cache, with concurrent resolutions for the same user sharing a single in-flight promise. Each resolver returns the set of item ids the user may see, or null when the user is unrestricted, in which case nothing is filtered and no request is made at all.

Plex. Sharing rules come from plex.tv shared_servers, which returns the label filters (filterAll, filterMovies, filterTelevision) and the shared sections in a single call, unlike /api/users which only reports a library count. Both label= and negated label!= restrictions are handled, and a server wide filterAll is combined with the per type one. Plex never returns labels in bulk library listings, whatever fields are requested, but it does support filtering by label, so the set is resolved with one request per allowed label rather than one per item. On my library that is 3 requests for a user restricted to 2 labels, against 760 if metadata had to be fetched item by item.

Jellyfin and Emby. The allowed libraries come from the user's policy: EnableAllFolders means unrestricted, otherwise EnabledFolders lists the library ids. Both servers report those fields on the user list, so a single request covers every user. Items are then listed with the owner's credentials rather than through a userId filter, because neither server honours that filter when queried with an administrator token: Jellyfin answers 401 for a forbidden library, and Emby returns an empty list even for an allowed one. Reading the policy is the only behaviour both agree on.

Availability is compared against ratingKey for Plex and jellyfinMediaId for Jellyfin and Emby, which already share the same columns.

Tag restrictions. Both servers carry a second mechanism next to the library selection, the direct analogue of Plex labels, and both enforce it. They express it differently, which was established against real servers rather than taken from the documentation: Jellyfin applies AllowedTags and BlockedTags independently, a denied tag winning over an allowed one, while Emby keeps a single list in BlockedTags whose polarity IsTagBlockingModeInclusive flips, and ignores its own IncludeTags entirely. Both are normalised into the same allow/deny shape already used for Plex labels, so the matching rule lives in one place rather than once per server. Tags are only requested when they can change the outcome, since both servers return a long list of metadata keywords under that same field.

Library identifiers. Emby's policy references libraries by their guid while the identifier stored in the settings is the numeric one it returns elsewhere, so a policy entry is matched against either. Jellyfin is unaffected, its library id already being the guid.

What the user sees

Availability is downgraded to UNKNOWN rather than the item being hidden, so a restricted user can still request the title, instead of it silently disappearing.

One place makes an exception, because there the downgrade would otherwise contradict the caller. The media list endpoint filters by availability in SQL before the per-user view is applied, so an item hidden from that user stayed in an "available" listing merely flagged unavailable, and the Recently Added slider showed a restricted user exactly the titles the media server hides from them. The requested filter is therefore honoured again after the downgrade, and the library is walked further until the page is full, so the client gets a complete page already scoped to the user and needs to know nothing about the restrictions. The walk is bounded to ten pages worth of rows: a user who can see very little gets a short page rather than a full table scan. The reported total stays the library's, which no caller paginates on today.

Filtering is applied in Media.getRelatedMedia and Media.getMedia, which between them cover Discover, search, collections, people and the detail pages, plus the media route that feeds the "Recently Added" slider. Season statuses are downgraded as well, since the UI displays them independently of the show's. Background jobs opt out through a flag, so watchlist sync keeps seeing the library's real state and does not create duplicate requests.

Requesting media that already exists

Seerr already marks such a request COMPLETED without sending anything to Radarr or Sonarr, so nothing is downloaded twice. That branch is now also the place where the owner can optionally grant access, through a new setting that is disabled by default:

  • off (default): the request completes as before and granting library access stays a manual decision
  • on: a label the user is allowed to see is added to the item, so they immediately gain access

This one is Plex specific, since it relies on labels.

The grant is one way. Deleting the request afterwards does not take the label back, and neither does declining it later. Seerr cannot tell a label it applied itself from one the owner set by hand, so removing it on request deletion would risk destroying the owner's own tagging. Revoking access stays a manual action in Plex, see open question 4. Note that removing the label there takes up to the cache TTL to be reflected, see open question 1.

Which label gets applied. A label is a shared bucket, not a per-user permission, so applying one that other restricted users are also allowed to see hands them the item too. What I implemented picks the label the fewest other restricted users can see, with a label named after the user as the tie break, since naming a label after its user is a common convention. Users who are unrestricted are not counted, because they already see the item whatever label it carries, and the number of other users the grant widens access to is logged, since no setup is guaranteed to have a label exclusive to a single user. I am not convinced this is the most relevant default, see open question 3.

Performance

Unrestricted users return early before any request to the media server, so existing setups pay nothing. Resolution fails open: a transient error must never hide the whole library.

How Has This Been Tested?

Verified against real servers rather than from the documentation, which is how the Jellyfin and Emby divergence described above was found.

Plex 1.43, on my own server: 8 shared users, 3 of them restricted by label, ~760 movies and ~85 shows.

  • a movie carrying an allowed label stays available for the restricted user, while a movie carrying none of their labels reports unavailable and stays available for the owner
  • a hidden show reports unavailable, seasons included
  • the "Recently Added" list reports per user availability
  • requesting hidden media completes without reaching Radarr, and the label is applied when the setting is on
  • for a user restricted to five labels, three of them exclusive and two shared with another user, the exclusive label matching their own name was the one applied
  • the requests list and the single request route report availability per user, which they did not at first: requests load their media eagerly and so bypassed the filtering done in the finders
  • a restricted user is not handed a play deep link, which is built from the media server id independently of status
  • the resolved visible set was compared against an independent count taken straight from Plex, using per-label queries: 717 movies + 62 shows + 10 concerts, matching exactly
  • label writes were checked to merge rather than replace, which is why the existing set is no longer read back before writing

Jellyfin 10.11 and Emby 4.9, on throwaway servers with two movie libraries and a user restricted to one of them:

  • media in a forbidden library reports unavailable for that user and available for the owner
  • granting access to that library makes it available again
  • a user with EnableAllFolders and no tag restriction returns early, with no enumeration at all
  • a user restricted by tag alone, reaching every library, is filtered rather than skipped: of five movies, the one carrying the denied tag reports unavailable and the other four stay available
  • both tag polarities were exercised on each server and checked against what the server itself serves that user: as a deny list, two of a three movie library remain; in Emby's inclusive mode, only the tagged one does
  • the Emby guid mismatch was caught this way: with the policy written as Emby itself writes it, no library matched and every title reported unavailable, including the library Emby grants. That case now resolves to the right single library
  • the two behaviours listed above for Plex were re-checked here rather than assumed, since neither is Plex specific: the play link is written into the same mediaUrl field for all three server types, and the requests list bypasses the finders whatever the server is. On Jellyfin, with the user restricted to LibA, the two LibB movies report unavailable with no play link while the three LibA ones report available with one; on Emby, with the user restricted to LibB, the mirror image holds. The requests list agrees with the detail page for that user on both, and the owner keeps full availability throughout.

Automated

  • 38 unit tests: the Plex restriction parser (url encoded values, encoded separators, malformed escapes, comma separated lists, negation, non label clauses), the label matcher (allow, deny, unsatisfiable, case insensitivity), the grant label selection (exclusivity, name tie break, least shared fallback, stable ordering), the tag matcher shared by the three servers (allow, deny, deny winning over allow, case insensitivity, unsatisfiable), the per server tag policy normalisation including Emby's polarity flag and the fields each server ignores, and the identifier selection per server type
  • pnpm test: 171/171
  • pnpm typecheck, pnpm lint, pnpm build, prettier and commitlint all clean

Screenshots / Logs (if applicable)

The new setting, off by default, under Settings → Plex:

Grant Library Access on Approval setting

The same movie seen by the owner on the left and by a user restricted by labels on the right, with Plex's own tag editor for that movie underneath. Before the request, the movie carries no label, so the owner is told it is available and gets a play button while the restricted user is told it is not and can request it:

Restricted user sees the movie as unavailable

After the request, with the grant setting on. Nothing was downloaded, the request was completed, and the label that only that user is allowed to see was added, so the movie is now available to them as well:

After the request, the label is applied and the movie is available

Jellyfin, with the media server's own view on either side. The owner on the far left reaches both libraries, the restricted user on the far right only LibA. In the middle, the same LibB movie in Seerr: available with a play button for the owner, requestable for the restricted user.

Jellyfin, owner and restricted user side by side

The same arrangement on Emby, whose library identifiers and tag semantics differ from Jellyfin's:

Emby, owner and restricted user side by side

Resolution for a Plex user restricted to two labels, and for a Jellyfin user restricted to one library:

[Media Server Sharing] Resolved media server library restrictions for user
  {"userId":3,"allowedLibraries":["LibA"],"visibleItems":3}

Request for media the library already holds, with the grant setting on:

[Media Request] Media already exists, marking request as COMPLETED
[Plex Sharing] Granted Plex library access by adding a label
  {"userId":3,"ratingKey":"51","plexLabel":"alice","alsoVisibleToOtherUsers":0}

Checklist:

  • I have read and followed the contribution guidelines.
  • Disclosed any use of AI (see our policy)
  • I have updated the documentation accordingly.
  • All new and existing tests passed.
  • Successful build pnpm build
  • Translation keys pnpm i18n:extract
  • Database migration (if required)

AI disclosure. An AI assistant was used as a coding aid. Every change was reviewed and exercised against the real Plex, Jellyfin and Emby servers described above, and the review round on this PR led to real corrections rather than being waved through: the redundant guard was removed once I confirmed Seerr already handled that case, an allow list intersection that failed open was fixed, and one suggested change was rejected because it broke Plex's own encoding of the label separator, which the tests caught.

No documentation change is included, and no database migration is required: nothing is persisted, the resolved visibility lives in an in-memory cache. Tell me if you would rather see the new setting documented somewhere specific.

Open questions for reviewers

  1. Cache invalidation. The five minute TTL means a restriction changed on the media server takes up to five minutes to apply. clearLibrarySharingCache() is exported and called after a label grant, but nothing calls it on a settings change or a user sync. Would you rather hook it somewhere specific, or resolve the rules on login and store them on the user?

  2. Whether the grant setting belongs here at all. It makes a request flow write to the media server, which is a side effect Seerr does not otherwise have, and it only works on Plex: on Jellyfin and Emby access is granted per library, so there is no equivalent and the setting is asymmetric between server types. It is off by default, and dropping it would leave this PR purely read only, with granting access staying a manual step in Plex. I went with the setting because being told a title is unavailable and then having no way to obtain it is the frustrating half of the problem, but I am genuinely unsure it is the right call for Seerr. Would you rather it stayed out of scope?

  3. Which label to grant. As described above, the most exclusive label wins and a label named after the user breaks ties. On my server that lands on the label named after the requester, which is what I would expect, but other behaviours are just as defensible: applying every label the user is allowed to see, only ever using a label that matches their name and doing nothing when there is none, or leaving the choice to the owner through a setting. Each trades exclusivity against predictability differently, and I have no strong view on which fits Seerr best.

    There is also the question of what the label should represent, which I think matters more than the picking rule. A label named after the requester is only one convention: a shared one standing for a household or an audience may be closer to what an owner actually wants, and the two answer different needs. That leads to a choice I deliberately have not made. As it stands the grant can only use a label the owner already created in Plex and already allowed that user to see, so Seerr never invents anything. Creating a label on the fly when a request comes in would lift that constraint, but it would mean writing new taxonomy into someone else's library, and on its own it would not even work: a brand new label is not in the user's allow list until the owner puts it there, so the grant would still not grant anything. A dedicated setting where the owner names the label to use would be a third option. Which of these is in scope? I would rather be told before building any of them.

  4. Whether deleting a request should take the label back. It does not today: the grant is one way, and declining or deleting the request afterwards leaves the label in place. The reason is that Seerr cannot tell a label it applied from one the owner set by hand, so removing it blindly risks destroying the owner's own tagging. Doing it properly would mean recording what was granted, which is precisely what this PR avoids today, since nothing is persisted and no migration is needed. Three answers seem defensible: leave revocation manual as it is now, persist the granted label so it can be taken back exactly and accept the migration, or treat the grant as deliberately permanent and say so in the setting's description. Which would you want?

  5. Placement of the filter. Doing it inside the entity keeps the diff small and covers every caller, but if you prefer it in a middleware or in the response mappers, say so and I will move it.

  6. Requesting media that is hidden and already requested. The duplicate request check keys on tmdb id alone, so if one restricted user has a pending request for media hidden from them, a second restricted user requesting the same title gets a 409 they can do nothing about. The check predates this PR, but the case only becomes reachable with it, since both users would previously have been told the media was available. Tell me if you would rather I let the request through when the media is hidden from the requester.

Izakun added 2 commits July 26, 2026 17:44
…lability

Plex lets the owner restrict a shared user to specific libraries, or to items
carrying specific labels. Seerr computed availability for the whole library
regardless, so a restricted user was told a title was available and then could
not play it.

Sharing rules are now read from plex.tv `shared_servers`, which returns both the
label filters and the shared sections in a single call, and are turned into the
set of rating keys the user may see. Plex never returns labels in bulk library
listings, but it does support filtering by label, so the set is resolved with one
request per allowed label rather than one per item. The result is cached per user
for five minutes.

Availability is downgraded to UNKNOWN, rather than the item being hidden, so the
user can still request it and be granted access. Season statuses are downgraded
too, since the UI shows them independently of the show's.

Filtering is applied in Media.getRelatedMedia and Media.getMedia, which cover
Discover, search, collections, people and the detail pages, and in the media
route that feeds the "Recently Added" slider. Background jobs opt out, so they
keep seeing the library's real state.

Resolution fails open: a transient Plex error must not hide the whole library.
Unrestricted users return early and pay no extra cost.
Because restricted users now see hidden media as unavailable, they can request
something the library already holds. Such a request must never reach Radarr or
Sonarr, or the file would be downloaded a second time, so it is short-circuited
before dispatch.

What happens next is left to the owner. A new setting, disabled by default, adds
one of the user's allowed labels to the item so they immediately gain access.
When it stays off, the request simply follows the usual approval flow and
granting access remains a manual decision.

Writing a label requires sending the existing ones back, since Plex replaces the
whole set, and the library section is resolved from the item's own metadata.
@Izakun
Izakun requested a review from a team as a code owner July 26, 2026 15:49
@coderabbitai

coderabbitai Bot commented Jul 26, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Adds cross-server sharing restrictions for Jellyfin, Emby, and Plex, cached per-user visibility resolution, user-aware media masking, and optional Plex label grants when approved media is already available.

Changes

Media server sharing restrictions

Layer / File(s) Summary
Restriction contracts and evaluators
server/lib/tagrestrictions.ts, server/lib/jellyfinsharing.ts, server/api/jellyfin.ts, server/api/plextv.ts, server/api/plexapi.ts, server/api/*test.ts
Adds normalized tag and label restriction models, server-specific parsing, Plex label APIs, and validation coverage.
Server-specific visibility resolution
server/lib/librarysharing.ts, server/lib/jellyfinsharing.ts, server/lib/plexsharing.ts, server/api/plextv.ts, server/api/plexapi.ts
Resolves visible item identifiers from library and label rules, including Plex label selection and granting.
Cached visibility and media masking
server/entity/Media.ts, server/routes/*.ts, server/lib/librarysharing.ts, server/lib/watchlistsync.ts
Caches visibility results, masks hidden media state and Plex URLs, and applies restrictions across media, requests, routes, and watchlist synchronization.
Approval-time Plex label access
server/subscriber/MediaRequestSubscriber.ts, server/api/externalapi.ts, server/lib/settings/index.ts, src/components/Settings/SettingsPlex.tsx, src/i18n/locale/en.json
Adds configurable approval-time label grants for already-available requests and invalidates visibility caches after grants.

Estimated code review effort: 5 (Critical) | ~100 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Requester
  participant MediaRequestSubscriber
  participant plexsharing
  participant PlexAPI
  participant librarysharing
  Requester->>MediaRequestSubscriber: approve already-available request
  MediaRequestSubscriber->>plexsharing: grantLabelAccess(user, item)
  plexsharing->>PlexAPI: inspect labels and add eligible label
  PlexAPI-->>plexsharing: label update result
  plexsharing->>librarysharing: clear user visibility cache
  MediaRequestSubscriber-->>Requester: complete request without download orchestration
Loading

Possibly related PRs

Suggested labels: bug

Suggested reviewers: gauthier-th, 0xsysr3ll, m0nsterrr

Poem

I’m a rabbit with labels tucked neatly away,
Letting the right little viewers see play.
Hidden films fade, allowed ones shine,
Cache hops along in a permissions line.
A grant at approval makes access take wing.

🚥 Pre-merge checks | ✅ 3 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Out of Scope Changes check ⚠️ Warning The PR adds a Plex label-on-approval workflow and broader Plex/Emby behavior that go beyond #180's Jellyfin access-restriction fix. Split the Jellyfin access-restriction fix from the new Plex approval-label and cross-server enhancements, or track those goals in separate issues.
✅ Passed checks (3 passed)
Check name Status Explanation
Linked Issues check ✅ Passed The changes address #180 by resolving visible items per user and hiding inaccessible media for restricted Jellyfin users.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main change: per-user library and label restriction handling across Plex, Jellyfin, and Emby when reporting availability.

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@Izakun
Izakun marked this pull request as draft July 26, 2026 15:49

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Actionable comments posted: 6

🧹 Nitpick comments (4)
server/api/plextv.test.ts (1)

65-109: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Consider covering the merge semantics too.

mergeLabelFilters carries the trickiest logic (allow intersection vs deny union, see server/api/plextv.ts lines 210-228) but is unexported and untested. Exporting it for tests would pin the disjoint/case-mismatch behaviour discussed on that function.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@server/api/plextv.test.ts` around lines 65 - 109, Export mergeLabelFilters
from server/api/plextv.ts and add focused tests alongside isAllowedByLabelFilter
covering allow-list intersection, deny-list union, and disjoint or
case-mismatched labels. Use the existing filter symbols and verify the merged
behavior explicitly.
server/api/plextv.ts (1)

234-249: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Hoist the lowercasing out of the loops.

filter.deny/filter.allow entries are lowercased on every comparison. Pre-normalizing both sides once is clearer and avoids repeated work on large label sets.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@server/api/plextv.ts` around lines 234 - 249, Update isAllowedByLabelFilter
to pre-normalize filter.deny and filter.allow once, alongside the existing
normalized labels, then compare normalized values directly in both some checks.
Preserve the current deny precedence, empty-allow behavior, and boolean result.
server/api/plexapi.ts (1)

215-245: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

Guard against a non-advancing/oversized paging loop.

totalSize from Plex's filtered listing reflects the total match count, and the loop increments offset by a fixed size regardless of how many items came back. If Plex caps container size below 500 (some versions clamp), you silently skip rating keys rather than paging correctly. Consider advancing by the number of returned items and breaking when a page is empty.

♻️ Suggested paging hardening
       totalSize = response.MediaContainer.totalSize ?? 0;
-      ratingKeys.push(
-        ...(response.MediaContainer.Metadata ?? []).map(
-          (item) => item.ratingKey
-        )
-      );
-      offset += size;
+      const items = response.MediaContainer.Metadata ?? [];
+      ratingKeys.push(...items.map((item) => item.ratingKey));
+
+      if (!items.length) {
+        break;
+      }
+
+      offset += items.length;
     } while (offset < totalSize);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@server/api/plexapi.ts` around lines 215 - 245, Update getRatingKeysByLabel so
pagination advances by the number of Metadata items actually returned rather
than the fixed size. Break the loop when a page returns no items, while
retaining the totalSize boundary, to prevent non-advancing or skipped-page
behavior when Plex clamps the requested container size.
server/entity/Media.ts (1)

87-132: 🗄️ Data Integrity & Integration | 🔵 Trivial | ⚡ Quick win

Consider avoiding in-place mutation of loaded entities.

applySharingRestrictions mutates item.status/item.status4k and each season's status directly on the passed-in entities rather than working on copies. All current call sites (media.ts list route, movie.ts/tv.ts getMedia/getRelatedMedia) only serialize the result to JSON and never save it back, so there's no active bug today. But because this is now a shared static helper, any future caller that fetches media and later calls mediaRepository.save() on the same reference would risk persisting the downgraded UNKNOWN status to the database, corrupting real availability data.

Returning shallow clones (e.g. { ...item, seasons: item.seasons?.map((s) => ({ ...s })) }) before mutating would remove this footgun for future maintainers.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@server/entity/Media.ts` around lines 87 - 132, Update
applySharingRestrictions to avoid mutating the input Media entities: create
shallow copies of each item and its seasons before applying status downgrades,
then return those copies while preserving the existing visibility and
restriction logic.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@server/api/plexapi.ts`:
- Around line 253-285: The addLabel method’s read-modify-write PUT can lose
labels under concurrency and bypasses ExternalAPI handling. Replace the raw
axios.put flow with the existing ExternalAPI wrapper request path, and use its
supported label-update operation so adding a label does not replace an unguarded
snapshot of the full label set; preserve duplicate-label avoidance and section
validation.

In `@server/api/plextv.ts`:
- Around line 210-228: The mergeLabelFilters function currently turns a disjoint
or case-variant allow-list intersection into an empty list that is interpreted
as unrestricted. Normalize labels case-insensitively during intersection and
represent a non-empty-input intersection with no matches using the filter’s
established deny-all or no-match mechanism, updating isAllowedByLabelFilter as
needed so only genuinely absent allow restrictions remain unrestricted.
- Around line 187-200: Update the restriction parsing loop around the clause
split so the raw filter is split on literal & and | before decoding each clause,
preventing encoded separators in label values from becoming clause boundaries.
Decode each clause or value with malformed-encoding protection so URIError is
ignored safely and direct callers receive parsed results instead of an
exception; preserve the existing %2C comma-separator behavior and allow/deny
handling.

In `@server/lib/plexsharing.ts`:
- Around line 100-144: Avoid enumerating entire libraries in the no-allow-list
branch of the visible-rating-key resolution: collect only deny-labelled rating
keys and represent the library as visible by default with those exclusions,
preserving allow-list behavior. In getVisibleRatingKeys, cache and reuse the
in-flight resolution promise so concurrent callers share one computation before
the resolved result is stored.
- Around line 206-215: Update the deny check in the label-selection logic around
filter.allow and filter.deny to compare labels case-insensitively, matching
isAllowedByLabelFilter. Ensure candidates differing only by letter case from any
denied label are excluded before addLabel is called, while preserving the
existing null return when no allowed label remains.

In `@server/subscriber/MediaRequestSubscriber.ts`:
- Around line 184-241: Update isAlreadyInLibrary to mark already-available
requests as COMPLETED before returning true, preserving the existing movie and
per-season TV completion behavior from sendToRadarr and sendToSonarr. Move or
reuse that completion logic within isAlreadyInLibrary so both early-return
callers transition APPROVED requests correctly while still skipping
Radarr/Sonarr.

---

Nitpick comments:
In `@server/api/plexapi.ts`:
- Around line 215-245: Update getRatingKeysByLabel so pagination advances by the
number of Metadata items actually returned rather than the fixed size. Break the
loop when a page returns no items, while retaining the totalSize boundary, to
prevent non-advancing or skipped-page behavior when Plex clamps the requested
container size.

In `@server/api/plextv.test.ts`:
- Around line 65-109: Export mergeLabelFilters from server/api/plextv.ts and add
focused tests alongside isAllowedByLabelFilter covering allow-list intersection,
deny-list union, and disjoint or case-mismatched labels. Use the existing filter
symbols and verify the merged behavior explicitly.

In `@server/api/plextv.ts`:
- Around line 234-249: Update isAllowedByLabelFilter to pre-normalize
filter.deny and filter.allow once, alongside the existing normalized labels,
then compare normalized values directly in both some checks. Preserve the
current deny precedence, empty-allow behavior, and boolean result.

In `@server/entity/Media.ts`:
- Around line 87-132: Update applySharingRestrictions to avoid mutating the
input Media entities: create shallow copies of each item and its seasons before
applying status downgrades, then return those copies while preserving the
existing visibility and restriction logic.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 6872df71-6fe6-4956-8e17-5b7ab2eaeef6

📥 Commits

Reviewing files that changed from the base of the PR and between 4b65ab5 and 3b92a71.

📒 Files selected for processing (13)
  • server/api/plexapi.ts
  • server/api/plextv.test.ts
  • server/api/plextv.ts
  • server/entity/Media.ts
  • server/lib/plexsharing.ts
  • server/lib/settings/index.ts
  • server/lib/watchlistsync.ts
  • server/routes/media.ts
  • server/routes/movie.ts
  • server/routes/tv.ts
  • server/subscriber/MediaRequestSubscriber.ts
  • src/components/Settings/SettingsPlex.tsx
  • src/i18n/locale/en.json

Comment thread server/api/plexapi.ts
Comment thread server/api/plextv.ts Outdated
Comment thread server/api/plextv.ts
Comment thread server/lib/plexsharing.ts
Comment thread server/lib/plexsharing.ts Outdated
Comment thread server/subscriber/MediaRequestSubscriber.ts Outdated
…availability

Extends the previous Plex work to Jellyfin and Emby, which restrict access per
library rather than per label.

The allowed libraries come from the user's policy: `EnableAllFolders` means
unrestricted, otherwise `EnabledFolders` lists the library ids the user may
browse. Both servers report those fields on the user list, so a single request
covers every user.

Items are then listed with the owner's credentials rather than through a
`userId` filter, because neither server honours that filter when queried with an
administrator token: Jellyfin answers 401 for a forbidden library, and Emby
returns an empty list even for an allowed one. Reading the policy is the only
behaviour both agree on.

Resolution moves behind a small dispatcher that picks the implementation from
the configured media server type and owns the per-user cache, so `plexsharing`
becomes a plain resolver. Availability is compared against `ratingKey` for Plex
and `jellyfinMediaId` for Jellyfin and Emby, which share the same columns.

Users reaching every enabled library return early, before any request, so
unrestricted setups keep their current behaviour.
@Izakun Izakun changed the title feat(plex): respect per-user library and label restrictions when reporting availability feat: respect per-user library and label restrictions when reporting availability (Plex, Jellyfin, Emby) Jul 26, 2026
Requests for media already in the library no longer stay stuck in APPROVED. The
guard added earlier returned before the pre-existing branch that marks such a
request COMPLETED, which also made the guard redundant: that branch already runs
before anything reaches Radarr or Sonarr, so nothing was ever downloaded twice.
Granting library access now hooks into it instead.

Two allow lists sharing no label no longer collapse into "no restriction", which
granted access to the whole library instead of denying it. The combined filter
can now express "nothing is visible", and the intersection is case insensitive,
as evaluation already was. Picking a label to grant compares the deny list case
insensitively too, so a label the filter would then reject is never applied.

Restriction strings are split into clauses before decoding, so an encoded `&` or
`|` inside a label value is no longer taken for a separator, and a malformed
escape no longer throws. Values are still decoded before being split on commas,
because Plex encodes the comma that separates them.

Concurrent resolutions for the same user now share a single in-flight promise
rather than each starting their own, and a library whose restrictions cannot be
satisfied is skipped instead of being listed only to discard everything.

Adding a label no longer reads the existing set to replay it, since Plex merges
what it is sent, so two concurrent grants cannot drop each other's label. The
write goes through a new ExternalAPI put wrapper rather than the raw axios
instance.
Izakun added 8 commits July 26, 2026 19:05
Play links are built from the media server id in an @afterload hook, which
runs regardless of status, so downgrading availability was not enough: a
restricted user was still handed a working "Play on Plex" button for media
they cannot open. Clear mediaUrl and iOSPlexUrl, 4k included, whenever an
item is hidden.
Requests load their media eagerly, so the requests list and the single
request route returned it untouched by the filtering done in the Media
finders. A restricted user saw hidden media reported as available there,
with a working play link, while the detail page correctly reported it as
unknown.
A label is a shared bucket rather than a per-user permission, so applying
the first one of the allow list handed the item to every other user allowed
to see that label. On my server a user restricted to five labels was granted
the one shared with another user, exposing the movie to them as well.

The label the fewest other restricted users can see now wins, and a label
named after the user breaks ties, which is the usual convention. Unrestricted
users are not counted, since they already see the item whatever label it
carries, and the count of users the grant widens access to is logged.
The description said one of the allowed labels was added, which was accurate
when the first of the list was used. It now says which one, since the label
the fewest other users can see is picked. English only, the key is new and
Weblate owns the other locales.
The Jellyfin resolver logs what it resolved for a user, the Plex one did not,
which makes a restriction that looks wrong hard to tell apart from a stale
cache entry.
Jellyfin and Emby both carry a tag based restriction next to the library
selection, the direct analogue of Plex labels, and both enforce it: a user
allowed a library but denied one of its tags is shown fewer items than the
library holds. Only the library dimension is implemented, so say so instead
of claiming the mechanism does not exist.
Emby's user policy references libraries by their guid while the identifier
stored in the settings is the numeric one it returns elsewhere, so no
configured library ever matched a policy entry. That is not a partial
failure: with nothing matching, a restricted user was told the entire
library was unavailable, including the libraries Emby does grant them.

Jellyfin was unaffected, its library id already being the guid, which is
why this went unnoticed. Policy entries are now matched against either
identifier, so the same code covers both servers.
Both servers carry a tag based restriction next to the library selection,
the direct analogue of Plex labels, and both enforce it. Availability was
computed from the library selection alone, so a user denied a tag was still
told the items carrying it were available, with a working play link.

The two servers express it differently, which was established against real
servers rather than taken from the documentation: Jellyfin applies
`AllowedTags` and `BlockedTags` independently, a denied tag winning over an
allowed one, while Emby keeps a single list in `BlockedTags` whose polarity
`IsTagBlockingModeInclusive` flips, and ignores its own `IncludeTags`.

Both are normalised into the allow/deny shape already used for Plex labels,
so the matching rule now lives in one place instead of once per server.
Tags are only requested when they can change the outcome, since both
servers return metadata keywords under the same field. A user restricted by
tags alone is no longer skipped, which the previous early return did.
@Izakun
Izakun marked this pull request as ready for review July 26, 2026 19:28

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
server/lib/plexsharing.ts (1)

211-305: 🎯 Functional Correctness | 🟠 Major | 🏗️ Heavy lift

grantLabelAccess doesn't verify the item's library is actually shared with the user.

resolveVisiblePlexRatingKeys explicitly skips a library when !rules.allLibraries && !rules.sharedSectionKeys.includes(library.id), but grantLabelAccess only inspects filter.allow/filter.deny/filter.allowNothing for the media type — it never checks whether the item's own library section is in rules.sharedSectionKeys. If a user has some libraries shared+label-restricted and other libraries not shared at all, and the completed request's media lives in an unshared library, this function can still pick and apply a label from the media-type-wide allow list, mutate Plex metadata, return a truthy picked.label, and cause the caller to log a successful grant and clear the visibility cache — even though the user still cannot see the item because the whole section isn't shared.

Consider resolving the item's section (already fetched inside PlexAPI.addLabel via getItemLabels) and short-circuiting when it isn't in rules.allLibraries || rules.sharedSectionKeys.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@server/lib/plexsharing.ts` around lines 211 - 305, Update grantLabelAccess to
verify the requested item’s Plex library section is shared with the user before
selecting or applying a label. Resolve the item’s section using the existing
Plex item lookup flow (as used by PlexAPI.addLabel/getItemLabels), then return
null when rules.allLibraries is false and the section ID is absent from
rules.sharedSectionKeys; preserve the existing label selection and grant
behavior for shared sections.
🧹 Nitpick comments (3)
server/lib/plexsharing.test.ts (1)

1-63: 📐 Maintainability & Code Quality | 🔵 Trivial | 🏗️ Heavy lift

Consider adding tests for resolveVisiblePlexRatingKeys and grantLabelAccess.

pickGrantLabel is well covered, but the two functions carrying most of the access-control logic (library/label resolution, allowNothing handling, and now the sharedSectionKeys gap noted in plexsharing.ts) have no direct unit tests here. Given they gate what a restricted user is told is "available", targeted tests (mocking PlexTvAPI/PlexAPI) would materially reduce regression risk.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@server/lib/plexsharing.test.ts` around lines 1 - 63, Extend the plexsharing
test suite with direct coverage for resolveVisiblePlexRatingKeys and
grantLabelAccess. Mock PlexTvAPI and PlexAPI to verify library/label resolution,
allowNothing behavior, and the sharedSectionKeys gap handling identified in
plexsharing.ts. Keep the tests focused on the access decisions and reported
availability for restricted users.
server/routes/request.ts (1)

489-628: 🎯 Functional Correctness | 🔵 Trivial | 💤 Low value

Write-path request responses (PUT, retry, approve/decline) don't apply hideRestrictedMedia.

Only the two GET endpoints filter restricted media in the response. If a restricted user's own request is approved/updated and the underlying media happens to already be marked available server-side, these responses could echo back an unfiltered status/deep link. This is a narrower, speculative edge case relative to the main listing/detail paths already covered.

Also applies to: 692-734

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@server/routes/request.ts` around lines 489 - 628, Apply the existing
hideRestrictedMedia response filtering to the PUT request handler and the retry
and approve/decline handlers, not only the GET endpoints. Before returning
write-path request responses, sanitize restricted media status and deep-link
fields using the same established logic and permission checks as the GET
responses, including when the requester owns the request. Preserve normal
response data for users who are allowed to view restricted media.
server/lib/jellyfinsharing.ts (1)

94-158: 🎯 Functional Correctness | 🔵 Trivial | 💤 Low value

Remove the unused BlockedMediaFolders declaration or document that it is not supported.

BlockedMediaFolders is declared in server/api/jellyfin.ts, but resolveVisibleJellyfinItemIds only uses EnableAllFolders and EnabledFolders for library-level restrictions.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@server/lib/jellyfinsharing.ts` around lines 94 - 158, Remove the unused
BlockedMediaFolders declaration from the Jellyfin policy handling, or explicitly
document it as unsupported. Keep resolveVisibleJellyfinItemIds focused on the
existing EnableAllFolders and EnabledFolders library restrictions.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@server/subscriber/MediaRequestSubscriber.ts`:
- Around line 401-404: In server/subscriber/MediaRequestSubscriber.ts:401-404,
move the movie media lookup and completion/grant block ahead of all Radarr
configuration validation, tag handling, and API calls. Apply the same ordering
change at server/subscriber/MediaRequestSubscriber.ts:600-603 for TV requests,
placing completion and optional access granting before Sonarr validation, tag
handling, and API work so already-available media completes without an *arr
server.

---

Outside diff comments:
In `@server/lib/plexsharing.ts`:
- Around line 211-305: Update grantLabelAccess to verify the requested item’s
Plex library section is shared with the user before selecting or applying a
label. Resolve the item’s section using the existing Plex item lookup flow (as
used by PlexAPI.addLabel/getItemLabels), then return null when
rules.allLibraries is false and the section ID is absent from
rules.sharedSectionKeys; preserve the existing label selection and grant
behavior for shared sections.

---

Nitpick comments:
In `@server/lib/jellyfinsharing.ts`:
- Around line 94-158: Remove the unused BlockedMediaFolders declaration from the
Jellyfin policy handling, or explicitly document it as unsupported. Keep
resolveVisibleJellyfinItemIds focused on the existing EnableAllFolders and
EnabledFolders library restrictions.

In `@server/lib/plexsharing.test.ts`:
- Around line 1-63: Extend the plexsharing test suite with direct coverage for
resolveVisiblePlexRatingKeys and grantLabelAccess. Mock PlexTvAPI and PlexAPI to
verify library/label resolution, allowNothing behavior, and the
sharedSectionKeys gap handling identified in plexsharing.ts. Keep the tests
focused on the access decisions and reported availability for restricted users.

In `@server/routes/request.ts`:
- Around line 489-628: Apply the existing hideRestrictedMedia response filtering
to the PUT request handler and the retry and approve/decline handlers, not only
the GET endpoints. Before returning write-path request responses, sanitize
restricted media status and deep-link fields using the same established logic
and permission checks as the GET responses, including when the requester owns
the request. Preserve normal response data for users who are allowed to view
restricted media.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 005c682c-9d1a-4425-a647-41cd50881150

📥 Commits

Reviewing files that changed from the base of the PR and between 3b92a71 and 059ea28.

📒 Files selected for processing (18)
  • server/api/externalapi.ts
  • server/api/jellyfin.ts
  • server/api/plexapi.ts
  • server/api/plextv.test.ts
  • server/api/plextv.ts
  • server/entity/Media.ts
  • server/lib/jellyfinsharing.test.ts
  • server/lib/jellyfinsharing.ts
  • server/lib/librarysharing.test.ts
  • server/lib/librarysharing.ts
  • server/lib/plexsharing.test.ts
  • server/lib/plexsharing.ts
  • server/lib/tagrestrictions.test.ts
  • server/lib/tagrestrictions.ts
  • server/routes/request.ts
  • server/subscriber/MediaRequestSubscriber.ts
  • src/components/Settings/SettingsPlex.tsx
  • src/i18n/locale/en.json
🚧 Files skipped from review as they are similar to previous changes (4)
  • src/i18n/locale/en.json
  • src/components/Settings/SettingsPlex.tsx
  • server/api/plextv.test.ts
  • server/api/plextv.ts

Comment thread server/subscriber/MediaRequestSubscriber.ts Outdated
Izakun added 4 commits July 27, 2026 09:57
The branch marking a request COMPLETED when the library already holds the
media sat after the Radarr and Sonarr configuration checks, both of which
return early. On an instance with no default server the request therefore
stayed approved forever, and with request tagging enabled the server was
called for nothing before the branch was ever reached.

Media already in the library needs no download client, so it is now settled
first. Reported by CodeRabbit on this PR.
The fixtures were copied from a real server and carried the Plex usernames
of actual shared users. Replaced with placeholder names.
The list endpoint filters by availability in SQL, then reports availability
from the requesting user's point of view. An item hidden from that user
therefore stayed in an "available" listing, merely flagged unavailable, so
the Recently Added slider showed a restricted user exactly the titles the
media server hides from them.

The requested filter is now honoured after the downgrade. Only the returned
page shrinks; the total stays the library's, which no caller paginates on.
Reapplying the filter after the per-user downgrade left a restricted user
with a short page: the Recently Added slider showed them thirteen titles
where the owner got twenty. The library is now walked further until the page
is full, so the client receives a complete page already scoped to the user
and needs to know nothing about the restrictions.

The walk is bounded to ten pages worth of rows. A user who can see very
little gets a short page rather than a full table scan.
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.

[Feature Request] Not respecting Media Server User Access Restrictions

1 participant