From 5231ed2d61aef16366536062e39257d8a73eecb1 Mon Sep 17 00:00:00 2001 From: Izakun <30216483+Izakun@users.noreply.github.com> Date: Sun, 26 Jul 2026 17:41:58 +0200 Subject: [PATCH 01/19] feat(plex): respect per-user library restrictions when reporting availability 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. --- server/api/plexapi.ts | 40 +++++++ server/api/plextv.test.ts | 109 +++++++++++++++++ server/api/plextv.ts | 231 ++++++++++++++++++++++++++++++++++++ server/entity/Media.ts | 90 +++++++++++++- server/lib/plexsharing.ts | 188 +++++++++++++++++++++++++++++ server/lib/watchlistsync.ts | 5 +- server/routes/media.ts | 9 +- server/routes/movie.ts | 2 +- server/routes/tv.ts | 2 +- 9 files changed, 668 insertions(+), 8 deletions(-) create mode 100644 server/api/plextv.test.ts create mode 100644 server/lib/plexsharing.ts diff --git a/server/api/plexapi.ts b/server/api/plexapi.ts index 81e016717f..aac7f8ee28 100644 --- a/server/api/plexapi.ts +++ b/server/api/plexapi.ts @@ -199,6 +199,46 @@ class PlexAPI extends ExternalAPI { }; } + /** + * Returns the rating keys of every item in a library section carrying the + * given label. + * + * Plex never returns labels in bulk library listings, whatever the requested + * fields, but it does support filtering by label. Resolving one request per + * label is therefore far cheaper than fetching metadata item by item. + */ + public async getRatingKeysByLabel( + sectionId: string, + label: string + ): Promise { + const ratingKeys: string[] = []; + const size = 500; + let offset = 0; + let totalSize = 0; + + do { + const response = await this.get( + `/library/sections/${sectionId}/all?label=${encodeURIComponent(label)}`, + { + headers: { + 'X-Plex-Container-Start': `${offset}`, + 'X-Plex-Container-Size': `${size}`, + }, + } + ); + + totalSize = response.MediaContainer.totalSize ?? 0; + ratingKeys.push( + ...(response.MediaContainer.Metadata ?? []).map( + (item) => item.ratingKey + ) + ); + offset += size; + } while (offset < totalSize); + + return ratingKeys; + } + public async getMetadata( key: string, options: { includeChildren?: boolean } = {} diff --git a/server/api/plextv.test.ts b/server/api/plextv.test.ts new file mode 100644 index 0000000000..44740bf41d --- /dev/null +++ b/server/api/plextv.test.ts @@ -0,0 +1,109 @@ +import { + isAllowedByLabelFilter, + parsePlexLabelFilter, +} from '@server/api/plextv'; +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; + +describe('parsePlexLabelFilter', () => { + it('returns empty lists when no restriction is set', () => { + for (const value of [undefined, null, '']) { + assert.deepEqual(parsePlexLabelFilter(value), { allow: [], deny: [] }); + } + }); + + it('parses a single allowed label', () => { + assert.deepEqual(parsePlexLabelFilter('label=Family'), { + allow: ['Family'], + deny: [], + }); + }); + + it('parses several comma separated labels', () => { + assert.deepEqual(parsePlexLabelFilter('label=Family,Kids'), { + allow: ['Family', 'Kids'], + deny: [], + }); + }); + + it('decodes url encoded values, as returned by plex.tv', () => { + assert.deepEqual(parsePlexLabelFilter('label=Family%2CKids'), { + allow: ['Family', 'Kids'], + deny: [], + }); + }); + + it('parses negated restrictions into the deny list', () => { + assert.deepEqual(parsePlexLabelFilter('label!=Private'), { + allow: [], + deny: ['Private'], + }); + }); + + it('handles allow and deny clauses in the same filter', () => { + assert.deepEqual(parsePlexLabelFilter('label=Family&label!=Private'), { + allow: ['Family'], + deny: ['Private'], + }); + }); + + it('ignores clauses that are not label based', () => { + assert.deepEqual(parsePlexLabelFilter('contentRating=PG&label=Family'), { + allow: ['Family'], + deny: [], + }); + }); + + it('drops empty values and surrounding whitespace', () => { + assert.deepEqual(parsePlexLabelFilter('label= Family , ,Kids '), { + allow: ['Family', 'Kids'], + deny: [], + }); + }); +}); + +describe('isAllowedByLabelFilter', () => { + it('allows anything when no restriction applies', () => { + assert.equal(isAllowedByLabelFilter({ allow: [], deny: [] }, []), true); + assert.equal( + isAllowedByLabelFilter({ allow: [], deny: [] }, ['Family']), + true + ); + }); + + it('requires one of the allowed labels', () => { + const filter = { allow: ['Family', 'Kids'], deny: [] }; + + assert.equal(isAllowedByLabelFilter(filter, ['Family']), true); + assert.equal(isAllowedByLabelFilter(filter, ['Other', 'Kids']), true); + assert.equal(isAllowedByLabelFilter(filter, ['Other']), false); + }); + + it('hides unlabelled media when an allow list is set', () => { + assert.equal( + isAllowedByLabelFilter({ allow: ['Family'], deny: [] }, []), + false + ); + }); + + it('denies media carrying a denied label, even when also allowed', () => { + assert.equal( + isAllowedByLabelFilter({ allow: ['Family'], deny: ['Private'] }, [ + 'Family', + 'Private', + ]), + false + ); + }); + + it('compares labels case insensitively', () => { + assert.equal( + isAllowedByLabelFilter({ allow: ['family'], deny: [] }, ['Family']), + true + ); + assert.equal( + isAllowedByLabelFilter({ allow: [], deny: ['private'] }, ['PRIVATE']), + false + ); + }); +}); diff --git a/server/api/plextv.ts b/server/api/plextv.ts index b8f3c9a47b..02d0a62462 100644 --- a/server/api/plextv.ts +++ b/server/api/plextv.ts @@ -80,6 +80,7 @@ interface ServerResponse { lastSeenAt: string; numLibraries: string; owned: string; + allLibraries?: string; }; } @@ -92,12 +93,161 @@ interface UsersResponse { username: string; email: string; thumb: string; + restricted?: string; + home?: string; + // Sharing restrictions, as configured in Plex under + // Library -> Restrictions. Values are URL encoded, e.g. + // `label=Family%2CKids` or `label!=Private`. + filterAll?: string; + filterMovies?: string; + filterTelevision?: string; + filterMusic?: string; + filterPhotos?: string; }; Server: ServerResponse[]; }[]; }; } +/** + * Label based sharing restrictions for a single library type. + * + * A media item is visible to the user when it carries at least one of the + * `allow` labels (or `allow` is empty, meaning no restriction) and none of + * the `deny` labels. + */ +export interface PlexLabelFilter { + allow: string[]; + deny: string[]; +} + +interface SharedServersResponse { + MediaContainer: { + SharedServer?: { + $: { + id: string; + username: string; + email: string; + userID: string; + machineIdentifier?: string; + allLibraries?: string; + owned?: string; + filterAll?: string; + filterMovies?: string; + filterTelevision?: string; + filterMusic?: string; + filterPhotos?: string; + }; + Section?: { + $: { + id: string; + /** Library section key on the server itself, e.g. `1`. */ + key: string; + title: string; + type: string; + shared: string; + }; + }[]; + }[]; + }; +} + +export interface PlexUserSharingRules { + /** The user has access to every library on the server. */ + allLibraries: boolean; + /** + * Section keys of the libraries actually shared with the user, matching the + * keys returned by the server's own `/library/sections`. Empty when + * `allLibraries` is set, since every library is then accessible. + */ + sharedSectionKeys: string[]; + movies: PlexLabelFilter; + tv: PlexLabelFilter; +} + +const EMPTY_LABEL_FILTER: PlexLabelFilter = { allow: [], deny: [] }; + +/** + * Parses a Plex restriction string into its label allow/deny lists. + * + * Plex encodes restrictions as `key=value` clauses, where several values are + * comma separated and several clauses are separated by `&` or `|`. Only the + * `label` key is relevant here; other keys (`contentRating`, ...) are ignored. + */ +export const parsePlexLabelFilter = ( + filter?: string | null +): PlexLabelFilter => { + if (!filter) { + return { ...EMPTY_LABEL_FILTER }; + } + + const allow: string[] = []; + const deny: string[] = []; + + for (const clause of decodeURIComponent(filter).split(/[&|]/)) { + const match = clause.trim().match(/^label(!?)=(.*)$/i); + + if (!match) { + continue; + } + + const labels = match[2] + .split(',') + .map((label) => label.trim()) + .filter((label) => label.length > 0); + + (match[1] === '!' ? deny : allow).push(...labels); + } + + return { allow, deny }; +}; + +/** + * Combines the server wide restriction (`filterAll`) with a per library type + * one. Allow lists are intersected when both are set, since the user has to + * satisfy both, while deny lists simply add up. + */ +const mergeLabelFilters = ( + all: PlexLabelFilter, + specific: PlexLabelFilter +): PlexLabelFilter => { + let allow: string[]; + + if (!all.allow.length) { + allow = [...specific.allow]; + } else if (!specific.allow.length) { + allow = [...all.allow]; + } else { + allow = all.allow.filter((label) => specific.allow.includes(label)); + } + + return { + allow: [...new Set(allow)], + deny: [...new Set([...all.deny, ...specific.deny])], + }; +}; + +/** + * Evaluates label based restrictions against the labels carried by a media + * item. Items without labels are only visible when no allow list applies. + */ +export const isAllowedByLabelFilter = ( + filter: PlexLabelFilter, + labels: string[] +): boolean => { + const normalized = labels.map((label) => label.toLowerCase()); + + if (filter.deny.some((label) => normalized.includes(label.toLowerCase()))) { + return false; + } + + if (!filter.allow.length) { + return true; + } + + return filter.allow.some((label) => normalized.includes(label.toLowerCase())); +}; + interface WatchlistResponse { MediaContainer: { totalSize: number; @@ -268,6 +418,87 @@ class PlexTvAPI extends ExternalAPI { return parsedXml; } + /** + * Returns the label based sharing restrictions the server owner configured + * for a shared user, so availability can be evaluated from that user's point + * of view instead of the owner's. + * + * Returns `null` when no restriction applies, which is the case for the + * owner, for users sharing every library, and whenever the rules cannot be + * determined. Callers must treat `null` as "no filtering". + */ + public async getUserSharingRules( + plexUserId: number + ): Promise { + const settings = getSettings(); + + if (!settings.plex.machineId) { + return null; + } + + try { + // `shared_servers` exposes the filters *and* the shared sections in a + // single call, unlike `/api/users` which only reports a library count. + const response = await this.axios.get( + `/api/servers/${settings.plex.machineId}/shared_servers`, + { + transformResponse: [], + responseType: 'text', + } + ); + + const parsedXml = (await xml2js.parseStringPromise( + response.data + )) as SharedServersResponse; + + const sharedServer = parsedXml.MediaContainer.SharedServer?.find( + (s) => parseInt(s.$.userID) === plexUserId + ); + + // No entry means the library is not shared with this user at all; that + // case is handled by checkUserAccess() rather than by filtering here. + if (!sharedServer) { + return null; + } + + const all = parsePlexLabelFilter(sharedServer.$.filterAll); + const movies = mergeLabelFilters( + all, + parsePlexLabelFilter(sharedServer.$.filterMovies) + ); + const tv = mergeLabelFilters( + all, + parsePlexLabelFilter(sharedServer.$.filterTelevision) + ); + + const allLibraries = sharedServer.$.allLibraries === '1'; + const sharedSectionKeys = allLibraries + ? [] + : (sharedServer.Section ?? []) + .filter((section) => section.$.shared === '1') + .map((section) => section.$.key); + + const hasLabelRestrictions = + movies.allow.length > 0 || + movies.deny.length > 0 || + tv.allow.length > 0 || + tv.deny.length > 0; + + if (allLibraries && !hasLabelRestrictions) { + return null; + } + + return { allLibraries, sharedSectionKeys, movies, tv }; + } catch (e) { + logger.error('Failed to retrieve Plex sharing rules for user', { + label: 'Plex.tv API', + plexUserId, + errorMessage: e.message, + }); + return null; + } + } + public async getWatchlist({ offset = 0, size = 20, diff --git a/server/entity/Media.ts b/server/entity/Media.ts index 304d80e8c4..215a98874d 100644 --- a/server/entity/Media.ts +++ b/server/entity/Media.ts @@ -8,6 +8,7 @@ import type { User } from '@server/entity/User'; import { Watchlist } from '@server/entity/Watchlist'; import type { DownloadingItem } from '@server/lib/downloadtracker'; import downloadTracker from '@server/lib/downloadtracker'; +import { getVisibleRatingKeys } from '@server/lib/plexsharing'; import { getSettings } from '@server/lib/settings'; import logger from '@server/logger'; import { DbAwareColumn, resolveDbType } from '@server/utils/DbColumnHelper'; @@ -31,7 +32,12 @@ import Season from './Season'; class Media { public static async getRelatedMedia( user: User | undefined, - items: { tmdbId: number; mediaType: string }[] + items: { tmdbId: number; mediaType: string }[], + // Background jobs work from the library's point of view, not a user's, so + // they opt out to avoid acting on an artificially downgraded status. + { + applySharingRestrictions = true, + }: { applySharingRestrictions?: boolean } = {} ): Promise { const mediaRepository = getRepository(Media); @@ -53,18 +59,84 @@ class Media { .where(' media.tmdbId in (:...finalIds)', { finalIds }) .getMany(); - return media.filter((m) => + const relatedMedia = media.filter((m) => items.some((i) => i.tmdbId === m.tmdbId && i.mediaType === m.mediaType) ); + + if (!user || !applySharingRestrictions) { + return relatedMedia; + } + + return await Media.applySharingRestrictions(user, relatedMedia); } catch (e) { logger.error(e.message); return []; } } + /** + * Hides availability of media the user has no access to on the media server. + * + * Plex lets the owner restrict a shared user to specific libraries or labels, + * but availability is otherwise computed for the whole library. Without this, + * a restricted user is told a title is available and then cannot play it. + * + * The status is downgraded rather than the item removed, so the user can + * still request it and be granted access. + */ + public static async applySharingRestrictions( + user: User, + media: Media[] + ): Promise { + const visibleRatingKeys = await getVisibleRatingKeys(user); + + // `null` means the user is unrestricted, the common case. + if (!visibleRatingKeys) { + return media; + } + + const isHidden = (ratingKey?: string | null): boolean => + !ratingKey || !visibleRatingKeys.has(ratingKey); + + const isAvailable = (status: MediaStatus): boolean => + status === MediaStatus.AVAILABLE || + status === MediaStatus.PARTIALLY_AVAILABLE; + + for (const item of media) { + const hidden = isHidden(item.ratingKey); + const hidden4k = isHidden(item.ratingKey4k); + + if (isAvailable(item.status) && hidden) { + item.status = MediaStatus.UNKNOWN; + } + + if (isAvailable(item.status4k) && hidden4k) { + item.status4k = MediaStatus.UNKNOWN; + } + + // Seasons carry their own status, which the UI displays independently of + // the show's, so they have to be downgraded as well. Plex restricts + // access at show level, hence the show's own visibility is used. + for (const season of item.seasons ?? []) { + if (isAvailable(season.status) && hidden) { + season.status = MediaStatus.UNKNOWN; + } + + if (isAvailable(season.status4k) && hidden4k) { + season.status4k = MediaStatus.UNKNOWN; + } + } + } + + return media; + } + public static async getMedia( id: number, - mediaType: MediaType + mediaType: MediaType, + // Passing the requesting user hides availability of media they have no + // access to on the media server. + user?: User ): Promise { const mediaRepository = getRepository(Media); @@ -74,7 +146,17 @@ class Media { relations: { requests: true, issues: true }, }); - return media ?? undefined; + if (!media) { + return undefined; + } + + if (!user) { + return media; + } + + const [restricted] = await Media.applySharingRestrictions(user, [media]); + + return restricted; } catch (e) { logger.error(e.message); return undefined; diff --git a/server/lib/plexsharing.ts b/server/lib/plexsharing.ts new file mode 100644 index 0000000000..7eed376b7f --- /dev/null +++ b/server/lib/plexsharing.ts @@ -0,0 +1,188 @@ +import PlexAPI from '@server/api/plexapi'; +import PlexTvAPI from '@server/api/plextv'; +import { MediaServerType } from '@server/constants/server'; +import { UserType } from '@server/constants/user'; +import { getRepository } from '@server/datasource'; +import { User } from '@server/entity/User'; +import { getSettings } from '@server/lib/settings'; +import logger from '@server/logger'; + +/** + * The set of Plex rating keys a restricted user is allowed to see, or `null` + * when the user is unrestricted and availability needs no filtering. + */ +export type PlexVisibleRatingKeys = Set | null; + +interface CacheEntry { + expiresAt: number; + ratingKeys: PlexVisibleRatingKeys; +} + +/** + * Resolving the visible rating keys costs a handful of Plex requests, so the + * result is cached per user. Sharing rules and labels change rarely, and a + * stale entry only delays a restriction by a few minutes. + */ +const CACHE_TTL_MS = 5 * 60 * 1000; +const cache = new Map(); + +export const clearPlexSharingCache = (userId?: number): void => { + if (userId === undefined) { + cache.clear(); + return; + } + + cache.delete(userId); +}; + +const getAdminPlexToken = async (): Promise => { + const userRepository = getRepository(User); + const admin = await userRepository.findOne({ + select: { id: true, plexToken: true }, + where: { id: 1 }, + }); + + return admin?.plexToken ?? null; +}; + +/** + * Builds the set of rating keys visible to a user, honouring both the shared + * library selection and the label restrictions configured in Plex. + * + * Returns `null` when the user is unrestricted, which callers must treat as + * "show everything", so unrestricted setups keep their current behaviour and + * pay no extra cost. + */ +const resolveVisibleRatingKeys = async ( + user: User +): Promise => { + const settings = getSettings(); + + if ( + settings.main.mediaServerType !== MediaServerType.PLEX || + user.userType !== UserType.PLEX || + !user.plexId + ) { + return null; + } + + // The owner always sees the whole library. + if (user.id === 1) { + return null; + } + + const adminToken = await getAdminPlexToken(); + + if (!adminToken) { + return null; + } + + const rules = await new PlexTvAPI(adminToken).getUserSharingRules( + user.plexId + ); + + if (!rules) { + return null; + } + + const plexClient = new PlexAPI({ plexToken: adminToken }); + const libraries = settings.plex.libraries.filter( + (library) => library.enabled + ); + const visible = new Set(); + + for (const library of libraries) { + if (!rules.allLibraries && !rules.sharedSectionKeys.includes(library.id)) { + // Library not shared with this user: nothing from it is visible. + continue; + } + + const filter = library.type === 'show' ? rules.tv : rules.movies; + const allowed = new Set(); + + if (filter.allow.length) { + for (const label of filter.allow) { + for (const ratingKey of await plexClient.getRatingKeysByLabel( + library.id, + label + )) { + allowed.add(ratingKey); + } + } + } else { + // The library is shared without an allow list, so everything in it is + // visible unless a deny label excludes it. + for (const ratingKey of await getSectionRatingKeys( + plexClient, + library.id + )) { + allowed.add(ratingKey); + } + } + + for (const label of filter.deny) { + for (const ratingKey of await plexClient.getRatingKeysByLabel( + library.id, + label + )) { + allowed.delete(ratingKey); + } + } + + for (const ratingKey of allowed) { + visible.add(ratingKey); + } + } + + return visible; +}; + +/** Lists every rating key of a library section, paging through the results. */ +const getSectionRatingKeys = async ( + plexClient: PlexAPI, + sectionId: string +): Promise => { + const ratingKeys: string[] = []; + const size = 500; + let offset = 0; + let totalSize = 0; + + do { + const { totalSize: total, items } = await plexClient.getLibraryContents( + sectionId, + { offset, size } + ); + totalSize = total; + ratingKeys.push(...items.map((item) => item.ratingKey)); + offset += size; + } while (offset < totalSize); + + return ratingKeys; +}; + +export const getVisibleRatingKeys = async ( + user: User +): Promise => { + const cached = cache.get(user.id); + + if (cached && cached.expiresAt > Date.now()) { + return cached.ratingKeys; + } + + try { + const ratingKeys = await resolveVisibleRatingKeys(user); + cache.set(user.id, { + ratingKeys, + expiresAt: Date.now() + CACHE_TTL_MS, + }); + return ratingKeys; + } catch (e) { + logger.error('Failed to resolve Plex sharing restrictions', { + label: 'Plex Sharing', + userId: user.id, + errorMessage: e.message, + }); + // Fail open: a transient Plex error must not hide the whole library. + return null; + } +}; diff --git a/server/lib/watchlistsync.ts b/server/lib/watchlistsync.ts index cb8a5d35e2..18bd943ddc 100644 --- a/server/lib/watchlistsync.ts +++ b/server/lib/watchlistsync.ts @@ -70,7 +70,10 @@ class WatchlistSync { response.items.map((i) => ({ tmdbId: i.tmdbId, mediaType: i.type === 'show' ? MediaType.TV : MediaType.MOVIE, - })) + })), + // Sync decides what already exists in the library, so it must see the + // real status rather than one downgraded by sharing restrictions. + { applySharingRestrictions: false } ); const watchlistTmdbIds = response.items.map((i) => i.tmdbId); diff --git a/server/routes/media.ts b/server/routes/media.ts index 2295f0021e..e6f3a121b2 100644 --- a/server/routes/media.ts +++ b/server/routes/media.ts @@ -81,6 +81,13 @@ mediaRoutes.get('/', async (req, res, next) => { take: pageSize, skip, }); + + // This endpoint feeds the "Recently Added" slider, so availability has to + // be reported from the requesting user's point of view. + const results = req.user + ? await Media.applySharingRestrictions(req.user, media) + : media; + return res.status(200).json({ pageInfo: { pages: Math.ceil(mediaCount / pageSize), @@ -88,7 +95,7 @@ mediaRoutes.get('/', async (req, res, next) => { results: mediaCount, page: Math.ceil(skip / pageSize) + 1, }, - results: media, + results, } as MediaResultsResponse); } catch (e) { next({ status: 500, message: e.message }); diff --git a/server/routes/movie.ts b/server/routes/movie.ts index bd86e447a9..e2cb66435d 100644 --- a/server/routes/movie.ts +++ b/server/routes/movie.ts @@ -22,7 +22,7 @@ movieRoutes.get('/:id', async (req, res, next) => { language: (req.query.language as string) ?? req.locale, }); - const media = await Media.getMedia(tmdbMovie.id, MediaType.MOVIE); + const media = await Media.getMedia(tmdbMovie.id, MediaType.MOVIE, req.user); const onUserWatchlist = await getRepository(Watchlist).exist({ where: { diff --git a/server/routes/tv.ts b/server/routes/tv.ts index 743f407e48..0b833853a1 100644 --- a/server/routes/tv.ts +++ b/server/routes/tv.ts @@ -30,7 +30,7 @@ tvRoutes.get('/:id', async (req, res, next) => { tvId: Number(req.params.id), language: (req.query.language as string) ?? req.locale, }); - const media = await Media.getMedia(tv.id, MediaType.TV); + const media = await Media.getMedia(tv.id, MediaType.TV, req.user); const onUserWatchlist = await getRepository(Watchlist).exist({ where: { From ea25f33b81b292ab644d55efb73ec55ff58af907 Mon Sep 17 00:00:00 2001 From: Izakun <30216483+Izakun@users.noreply.github.com> Date: Sun, 26 Jul 2026 17:42:25 +0200 Subject: [PATCH 02/19] feat(plex): optionally grant library access when a request is approved 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. --- server/api/plexapi.ts | 72 +++++++++++++++++++++ server/lib/plexsharing.ts | 67 +++++++++++++++++++ server/lib/settings/index.ts | 8 +++ server/subscriber/MediaRequestSubscriber.ts | 58 +++++++++++++++++ src/components/Settings/SettingsPlex.tsx | 30 +++++++++ src/i18n/locale/en.json | 2 + 6 files changed, 237 insertions(+) diff --git a/server/api/plexapi.ts b/server/api/plexapi.ts index aac7f8ee28..dd0f1b52d3 100644 --- a/server/api/plexapi.ts +++ b/server/api/plexapi.ts @@ -93,6 +93,17 @@ interface PlexMetadataResponse { }; } +interface PlexLabelResponse { + MediaContainer: { + Metadata?: { + librarySectionID?: number | string; + Label?: { + tag: string; + }[]; + }[]; + }; +} + class PlexAPI extends ExternalAPI { constructor({ plexToken, @@ -239,6 +250,67 @@ class PlexAPI extends ExternalAPI { return ratingKeys; } + /** + * Adds a label to a library item, keeping the labels it already carries. + * + * Plex replaces the whole label set on write, so existing labels have to be + * sent again alongside the new one. + */ + public async addLabel( + ratingKey: string, + type: 'movie' | 'show', + label: string + ): Promise { + const { sectionId, labels: existing } = await this.getItemLabels(ratingKey); + + if (!sectionId) { + throw new Error( + `Unable to resolve the library section of Plex item ${ratingKey}` + ); + } + + if (existing.some((l) => l.toLowerCase() === label.toLowerCase())) { + return; + } + + const labels = [...existing, label]; + const params = new URLSearchParams({ + type: type === 'show' ? '2' : '1', + id: ratingKey, + 'label.locked': '1', + }); + + labels.forEach((value, index) => { + params.append(`label[${index}].tag.tag`, value); + }); + + // ExternalAPI does not wrap PUT, so the axios instance is used directly. + await this.axios.put( + `/library/sections/${sectionId}/all?${params.toString()}` + ); + } + + /** + * Returns the labels currently set on a library item, along with the section + * it belongs to, which is required to write labels back. + */ + public async getItemLabels( + ratingKey: string + ): Promise<{ sectionId?: string; labels: string[] }> { + const response = await this.get( + `/library/metadata/${ratingKey}` + ); + + const metadata = response.MediaContainer.Metadata?.[0]; + + return { + sectionId: metadata?.librarySectionID + ? `${metadata.librarySectionID}` + : undefined, + labels: (metadata?.Label ?? []).map((label) => label.tag), + }; + } + public async getMetadata( key: string, options: { includeChildren?: boolean } = {} diff --git a/server/lib/plexsharing.ts b/server/lib/plexsharing.ts index 7eed376b7f..57c32748f5 100644 --- a/server/lib/plexsharing.ts +++ b/server/lib/plexsharing.ts @@ -13,6 +13,12 @@ import logger from '@server/logger'; */ export type PlexVisibleRatingKeys = Set | null; +/** Identifies a single item in the Plex library. */ +export interface PlexItemRef { + ratingKey: string; + type: 'movie' | 'show'; +} + interface CacheEntry { expiresAt: number; ratingKeys: PlexVisibleRatingKeys; @@ -160,6 +166,67 @@ const getSectionRatingKeys = async ( return ratingKeys; }; +/** + * Grants a restricted user access to an item already present in the library, by + * adding one of the labels they are allowed to see. + * + * Returns the label that was applied, or `null` when nothing had to be done: + * the user is unrestricted, already has access, or has no allow list to draw a + * label from (a deny-only restriction cannot be satisfied this way). + */ +export const grantLabelAccess = async ( + user: User, + { ratingKey, type }: PlexItemRef +): Promise => { + const settings = getSettings(); + + if ( + settings.main.mediaServerType !== MediaServerType.PLEX || + user.userType !== UserType.PLEX || + !user.plexId || + user.id === 1 + ) { + return null; + } + + const adminToken = await getAdminPlexToken(); + + if (!adminToken) { + return null; + } + + const rules = await new PlexTvAPI(adminToken).getUserSharingRules( + user.plexId + ); + + if (!rules) { + return null; + } + + const filter = type === 'show' ? rules.tv : rules.movies; + const label = filter.allow.find( + (candidate) => !filter.deny.includes(candidate) + ); + + if (!label) { + return null; + } + + await new PlexAPI({ plexToken: adminToken }).addLabel(ratingKey, type, label); + + // The user's visible set just changed, so drop the cached copy. + clearPlexSharingCache(user.id); + + logger.info('Granted Plex library access by adding a label', { + label: 'Plex Sharing', + userId: user.id, + ratingKey, + plexLabel: label, + }); + + return label; +}; + export const getVisibleRatingKeys = async ( user: User ): Promise => { diff --git a/server/lib/settings/index.ts b/server/lib/settings/index.ts index 0a896524eb..708611ae32 100644 --- a/server/lib/settings/index.ts +++ b/server/lib/settings/index.ts @@ -42,6 +42,14 @@ export interface PlexSettings { useSsl?: boolean; libraries: Library[]; webAppUrl?: string; + /** + * When a user restricted by Plex labels requests media that already exists in + * the library, automatically add one of their allowed labels to it so they + * gain access, instead of leaving the request for the owner to handle. + * + * Disabled by default: granting library access is the owner's decision. + */ + grantLabelOnApproval?: boolean; } export interface JellyfinSettings { diff --git a/server/subscriber/MediaRequestSubscriber.ts b/server/subscriber/MediaRequestSubscriber.ts index c4b2263ccd..bb4a9e9521 100644 --- a/server/subscriber/MediaRequestSubscriber.ts +++ b/server/subscriber/MediaRequestSubscriber.ts @@ -18,6 +18,7 @@ import { MediaRequest } from '@server/entity/MediaRequest'; import Season from '@server/entity/Season'; import SeasonRequest from '@server/entity/SeasonRequest'; import notificationManager, { Notification } from '@server/lib/notifications'; +import { grantLabelAccess } from '@server/lib/plexsharing'; import { getSettings } from '@server/lib/settings'; import logger from '@server/logger'; import { withNestedTransaction } from '@server/utils/nestedTransaction'; @@ -181,6 +182,55 @@ export class MediaRequestSubscriber implements EntitySubscriberInterface