feat: respect per-user library and label restrictions when reporting availability (Plex, Jellyfin, Emby) - #3296
feat: respect per-user library and label restrictions when reporting availability (Plex, Jellyfin, Emby)#3296Izakun wants to merge 16 commits into
Conversation
…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.
|
Note Reviews pausedIt 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 Use the following commands to manage reviews:
Use the checkboxes below for quick actions:
📝 WalkthroughWalkthroughAdds 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. ChangesMedia server sharing restrictions
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
Possibly related PRs
Suggested labels: Suggested reviewers: Poem
🚥 Pre-merge checks | ✅ 3 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (3 passed)
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. Comment |
There was a problem hiding this comment.
Actionable comments posted: 6
🧹 Nitpick comments (4)
server/api/plextv.test.ts (1)
65-109: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winConsider covering the merge semantics too.
mergeLabelFilterscarries the trickiest logic (allow intersection vs deny union, seeserver/api/plextv.tslines 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 valueHoist the lowercasing out of the loops.
filter.deny/filter.allowentries 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 winGuard against a non-advancing/oversized paging loop.
totalSizefrom Plex's filtered listing reflects the total match count, and the loop incrementsoffsetby a fixedsizeregardless 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 winConsider avoiding in-place mutation of loaded entities.
applySharingRestrictionsmutatesitem.status/item.status4kand 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.tsgetMedia/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 callsmediaRepository.save()on the same reference would risk persisting the downgradedUNKNOWNstatus 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
📒 Files selected for processing (13)
server/api/plexapi.tsserver/api/plextv.test.tsserver/api/plextv.tsserver/entity/Media.tsserver/lib/plexsharing.tsserver/lib/settings/index.tsserver/lib/watchlistsync.tsserver/routes/media.tsserver/routes/movie.tsserver/routes/tv.tsserver/subscriber/MediaRequestSubscriber.tssrc/components/Settings/SettingsPlex.tsxsrc/i18n/locale/en.json
…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.
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.
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.
There was a problem hiding this comment.
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
grantLabelAccessdoesn't verify the item's library is actually shared with the user.
resolveVisiblePlexRatingKeysexplicitly skips a library when!rules.allLibraries && !rules.sharedSectionKeys.includes(library.id), butgrantLabelAccessonly inspectsfilter.allow/filter.deny/filter.allowNothingfor the media type — it never checks whether the item's own library section is inrules.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 truthypicked.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.addLabelviagetItemLabels) and short-circuiting when it isn't inrules.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 liftConsider adding tests for
resolveVisiblePlexRatingKeysandgrantLabelAccess.
pickGrantLabelis well covered, but the two functions carrying most of the access-control logic (library/label resolution,allowNothinghandling, and now the sharedSectionKeys gap noted inplexsharing.ts) have no direct unit tests here. Given they gate what a restricted user is told is "available", targeted tests (mockingPlexTvAPI/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 valueWrite-path request responses (
PUT,retry,approve/decline) don't applyhideRestrictedMedia.Only the two
GETendpoints 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 valueRemove the unused
BlockedMediaFoldersdeclaration or document that it is not supported.
BlockedMediaFoldersis declared inserver/api/jellyfin.ts, butresolveVisibleJellyfinItemIdsonly usesEnableAllFoldersandEnabledFoldersfor 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
📒 Files selected for processing (18)
server/api/externalapi.tsserver/api/jellyfin.tsserver/api/plexapi.tsserver/api/plextv.test.tsserver/api/plextv.tsserver/entity/Media.tsserver/lib/jellyfinsharing.test.tsserver/lib/jellyfinsharing.tsserver/lib/librarysharing.test.tsserver/lib/librarysharing.tsserver/lib/plexsharing.test.tsserver/lib/plexsharing.tsserver/lib/tagrestrictions.test.tsserver/lib/tagrestrictions.tsserver/routes/request.tsserver/subscriber/MediaRequestSubscriber.tssrc/components/Settings/SettingsPlex.tsxsrc/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
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.
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
nullwhen 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/userswhich only reports a library count. Bothlabel=and negatedlabel!=restrictions are handled, and a server widefilterAllis 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:
EnableAllFoldersmeans unrestricted, otherwiseEnabledFolderslists 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 auserIdfilter, 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
ratingKeyfor Plex andjellyfinMediaIdfor 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
AllowedTagsandBlockedTagsindependently, a denied tag winning over an allowed one, while Emby keeps a single list inBlockedTagswhose polarityIsTagBlockingModeInclusiveflips, and ignores its ownIncludeTagsentirely. 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
UNKNOWNrather 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
medialist 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.getRelatedMediaandMedia.getMedia, which between them cover Discover, search, collections, people and the detail pages, plus themediaroute 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
COMPLETEDwithout 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: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.
Jellyfin 10.11 and Emby 4.9, on throwaway servers with two movie libraries and a user restricted to one of them:
EnableAllFoldersand no tag restriction returns early, with no enumeration at allmediaUrlfield for all three server types, and the requests list bypasses the finders whatever the server is. On Jellyfin, with the user restricted toLibA, the twoLibBmovies report unavailable with no play link while the threeLibAones report available with one; on Emby, with the user restricted toLibB, 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
pnpm test: 171/171pnpm typecheck,pnpm lint,pnpm build,prettierandcommitlintall cleanScreenshots / Logs (if applicable)
The new setting, off by default, under Settings → Plex:
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:
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:
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 sameLibBmovie in Seerr: available with a play button for the owner, requestable for the restricted user.The same arrangement on Emby, whose library identifiers and tag semantics differ from Jellyfin's:
Resolution for a Plex user restricted to two labels, and for a Jellyfin user restricted to one library:
Request for media the library already holds, with the grant setting on:
Checklist:
pnpm buildpnpm i18n:extractAI 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
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?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?
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.
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?
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.
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.