diff --git a/server/api/externalapi.ts b/server/api/externalapi.ts index 9b71b7a159..aad8b68fe1 100644 --- a/server/api/externalapi.ts +++ b/server/api/externalapi.ts @@ -101,6 +101,31 @@ class ExternalAPI { return response.data; } + protected async put( + endpoint: string, + data?: Record, + config?: AxiosRequestConfig, + ttl?: number + ): Promise { + const cacheKey = this.serializeCacheKey(endpoint, { + config: config?.params, + ...(data ? { data } : {}), + }); + + const cachedItem = this.cache?.get(cacheKey); + if (cachedItem) { + return cachedItem; + } + + const response = await this.axios.put(endpoint, data, config); + + if (this.cache && ttl !== 0) { + this.cache.set(cacheKey, response.data, ttl ?? DEFAULT_TTL); + } + + return response.data; + } + protected async getRolling( endpoint: string, config?: AxiosRequestConfig, diff --git a/server/api/jellyfin.ts b/server/api/jellyfin.ts index 188beb1ff1..10925d425b 100644 --- a/server/api/jellyfin.ts +++ b/server/api/jellyfin.ts @@ -18,6 +18,30 @@ export interface JellyfinUserResponse { }; Policy: { IsAdministrator: boolean; + /** + * Library access, as configured per user under Access. When + * `EnableAllFolders` is set the user reaches every library and + * `EnabledFolders` is empty; otherwise it lists the library item ids the + * user is allowed to browse. + */ + EnableAllFolders?: boolean; + EnabledFolders?: string[]; + /** Legacy Emby counterpart of `EnabledFolders`. */ + BlockedMediaFolders?: string[]; + /** + * Tag based restrictions, the counterpart of Plex labels. The two servers + * disagree on how they are expressed, which was established against real + * servers rather than taken from the documentation: + * + * - Jellyfin uses two independent lists, `AllowedTags` and `BlockedTags`, + * and applies both, a denied tag winning over an allowed one. + * - Emby keeps a single list in `BlockedTags` whose polarity is flipped by + * `IsTagBlockingModeInclusive`: false denies those tags, true allows only + * those tags. Its `IncludeTags` field has no effect. + */ + AllowedTags?: string[]; + BlockedTags?: string[]; + IsTagBlockingModeInclusive?: boolean; }; PrimaryImageTag?: string; } @@ -70,6 +94,12 @@ interface JellyfinMediaFolder { Id: string; Type: string; CollectionType: string; + /** + * Emby only. Its user policy references libraries by this value while `Id` + * stays numeric, so both have to be kept to match a policy entry. Jellyfin + * does not return it, its `Id` already being the guid. + */ + Guid?: string; } export interface JellyfinLibrary { @@ -77,6 +107,8 @@ export interface JellyfinLibrary { key: string; title: string; agent: string; + /** Emby's alternative identifier for the same library, see `JellyfinMediaFolder`. */ + guid?: string; } export interface JellyfinLibraryItem { @@ -93,6 +125,10 @@ export interface JellyfinLibraryItem { IndexNumberEnd?: number; ParentIndexNumber?: number; MediaType: string; + /** Only populated when requested through `fields`. Jellyfin answers with + * `Tags`, Emby with `TagItems`, so callers have to read both. */ + Tags?: string[]; + TagItems?: { Name: string }[]; } export interface JellyfinMediaStream { @@ -442,14 +478,20 @@ class JellyfinAPI extends ExternalAPI { title: Item.Name, type: Item.CollectionType === 'movies' ? 'movie' : 'show', agent: 'jellyfin', + guid: Item.Guid, }; }); } - public async getLibraryContents(id: string): Promise { + public async getLibraryContents( + id: string, + { fields }: { fields?: string } = {} + ): Promise { try { const libraryItemsResponse = await this.get( - `/Items?SortBy=SortName&SortOrder=Ascending&IncludeItemTypes=Series,Movie,Others&Recursive=true&StartIndex=0&ParentId=${id}&collapseBoxSetItems=false` + `/Items?SortBy=SortName&SortOrder=Ascending&IncludeItemTypes=Series,Movie,Others&Recursive=true&StartIndex=0&ParentId=${id}&collapseBoxSetItems=false${ + fields ? `&Fields=${fields}` : '' + }` ); return libraryItemsResponse.Items.filter( diff --git a/server/api/plexapi.ts b/server/api/plexapi.ts index 81e016717f..e3584bdeaa 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, @@ -199,6 +210,101 @@ 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; + } + + /** + * Adds a label to a library item. + * + * Plex merges the labels sent with the ones already set, so the existing set + * is never read back and replayed: two concurrent grants on the same item + * cannot drop each other's label. + */ + 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 params = new URLSearchParams({ + type: type === 'show' ? '2' : '1', + id: ratingKey, + 'label[0].tag.tag': label, + 'label.locked': '1', + }); + + await this.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/api/plextv.test.ts b/server/api/plextv.test.ts new file mode 100644 index 0000000000..38eb954fff --- /dev/null +++ b/server/api/plextv.test.ts @@ -0,0 +1,133 @@ +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('keeps encoded separators inside a label value', () => { + // %26 is `&`, which must not be mistaken for a clause separator. + assert.deepEqual(parsePlexLabelFilter('label=Tom%20%26%20Jerry'), { + allow: ['Tom & Jerry'], + deny: [], + }); + }); + + it('does not throw on a malformed encoding', () => { + assert.deepEqual(parsePlexLabelFilter('label=100%'), { + allow: ['100%'], + deny: [], + }); + }); + + it('drops empty values and surrounding whitespace', () => { + assert.deepEqual(parsePlexLabelFilter('label= Family , ,Kids '), { + allow: ['Family', 'Kids'], + deny: [], + }); + }); +}); + +describe('isAllowedByLabelFilter', () => { + it('hides everything when the restrictions cannot be satisfied', () => { + assert.equal( + isAllowedByLabelFilter({ allow: [], deny: [], allowNothing: true }, [ + 'Family', + ]), + false + ); + }); + + 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..34f3545a18 100644 --- a/server/api/plextv.ts +++ b/server/api/plextv.ts @@ -1,6 +1,7 @@ import type { PlexDevice } from '@server/interfaces/api/plexInterfaces'; import cacheManager from '@server/lib/cache'; import { getSettings } from '@server/lib/settings'; +import { isAllowedByTagRestriction } from '@server/lib/tagrestrictions'; import logger from '@server/logger'; import { randomUUID } from 'node:crypto'; import xml2js from 'xml2js'; @@ -80,6 +81,7 @@ interface ServerResponse { lastSeenAt: string; numLibraries: string; owned: string; + allLibraries?: string; }; } @@ -92,12 +94,215 @@ 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[]; + /** + * Set when the combined restrictions cannot be satisfied by any label, so + * nothing in the library is visible. Distinguishes that case from an empty + * `allow` list, which means "no allow restriction". + */ + allowNothing?: boolean; +} + +interface PlexSharedServer { + $: { + 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; + }; + }[]; +} + +interface SharedServersResponse { + MediaContainer: { + SharedServer?: PlexSharedServer[]; + }; +} + +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: [] }; + +/** + * Turns a `shared_servers` entry into the restrictions that apply to that user, + * or `null` when none do. + */ +const parseSharingRules = ( + sharedServer: PlexSharedServer +): PlexUserSharingRules | 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 }; +}; + +/** + * Decodes a single restriction value. Plex url encodes them, but a malformed + * sequence must not turn parsing a filter into an exception. + */ +const decodeLabel = (value: string): string => { + try { + return decodeURIComponent(value); + } catch { + return value; + } +}; + +/** + * 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[] = []; + + // Clauses are split on the raw string, so an encoded `&` or `|` inside a + // label value is not mistaken for a separator. Values are then decoded before + // being split on commas, because Plex encodes the comma that separates them. + for (const clause of filter.split(/[&|]/)) { + const match = clause.trim().match(/^label(!?)=(.*)$/i); + + if (!match) { + continue; + } + + const labels = decodeLabel(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 => { + const deny = [...new Set([...all.deny, ...specific.deny])]; + let allow: string[]; + let allowNothing = false; + + if (!all.allow.length) { + allow = [...specific.allow]; + } else if (!specific.allow.length) { + allow = [...all.allow]; + } else { + const specificLower = new Set( + specific.allow.map((label) => label.toLowerCase()) + ); + allow = all.allow.filter((label) => specificLower.has(label.toLowerCase())); + + // Two allow lists sharing no label cannot both be satisfied. Treating the + // empty result as "no restriction" would grant access to everything. + allowNothing = allow.length === 0; + } + + return { allow: [...new Set(allow)], deny, allowNothing }; +}; + +/** + * 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 => isAllowedByTagRestriction(filter, labels); + interface WatchlistResponse { MediaContainer: { totalSize: number; @@ -268,6 +473,93 @@ 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 { + try { + const sharedServers = await this.getSharedServers(); + + // No entry means the library is not shared with this user at all; that + // case is handled by checkUserAccess() rather than by filtering here. + const sharedServer = sharedServers?.find( + (s) => parseInt(s.$.userID) === plexUserId + ); + + return sharedServer ? parseSharingRules(sharedServer) : null; + } catch (e) { + logger.error('Failed to retrieve Plex sharing rules for user', { + label: 'Plex.tv API', + plexUserId, + errorMessage: e.message, + }); + return null; + } + } + + /** + * Returns the sharing rules of every restricted user, keyed by Plex user id. + * + * Needed to tell how exclusive a label is before granting it: a label several + * users are allowed to see cannot be used to give access to one of them + * alone. Unrestricted users are left out, since they already see everything + * and applying a label changes nothing for them. + */ + public async getAllUserSharingRules(): Promise< + Map + > { + const rulesByUserId = new Map(); + + try { + for (const sharedServer of (await this.getSharedServers()) ?? []) { + const rules = parseSharingRules(sharedServer); + + if (rules) { + rulesByUserId.set(parseInt(sharedServer.$.userID), rules); + } + } + } catch (e) { + logger.error('Failed to retrieve Plex sharing rules', { + label: 'Plex.tv API', + errorMessage: e.message, + }); + } + + return rulesByUserId; + } + + private async getSharedServers(): Promise { + const settings = getSettings(); + + if (!settings.plex.machineId) { + return undefined; + } + + // `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; + + return parsedXml.MediaContainer.SharedServer; + } + public async getWatchlist({ offset = 0, size = 20, diff --git a/server/entity/Media.ts b/server/entity/Media.ts index 304d80e8c4..b2d1154ab5 100644 --- a/server/entity/Media.ts +++ b/server/entity/Media.ts @@ -8,6 +8,10 @@ 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 { + getMediaServerItemIds, + getVisibleMediaIds, +} from '@server/lib/librarysharing'; import { getSettings } from '@server/lib/settings'; import logger from '@server/logger'; import { DbAwareColumn, resolveDbType } from '@server/utils/DbColumnHelper'; @@ -31,7 +35,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 +62,99 @@ 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. + * + * Media servers let the owner restrict a shared user to specific libraries, + * and Plex additionally to specific 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 visibleIds = await getVisibleMediaIds(user); + + // `null` means the user is unrestricted, the common case. + if (!visibleIds) { + return media; + } + + const isHidden = (itemId?: string | null): boolean => + !itemId || !visibleIds.has(itemId); + + const isAvailable = (status: MediaStatus): boolean => + status === MediaStatus.AVAILABLE || + status === MediaStatus.PARTIALLY_AVAILABLE; + + for (const item of media) { + const { id, id4k } = getMediaServerItemIds(item); + const hidden = isHidden(id); + const hidden4k = isHidden(id4k); + + if (hidden) { + if (isAvailable(item.status)) { + item.status = MediaStatus.UNKNOWN; + } + + // Deep links are built from the media server id regardless of status, + // so they have to be dropped as well: a restricted user must not be + // handed a working "play" link to media they cannot open. + item.mediaUrl = undefined; + item.iOSPlexUrl = undefined; + } + + if (hidden4k) { + if (isAvailable(item.status4k)) { + item.status4k = MediaStatus.UNKNOWN; + } + + item.mediaUrl4k = undefined; + item.iOSPlexUrl4k = undefined; + } + + // Seasons carry their own status, which the UI displays independently of + // the show's, so they have to be downgraded as well. Access is granted 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 +164,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/jellyfinsharing.test.ts b/server/lib/jellyfinsharing.test.ts new file mode 100644 index 0000000000..fd5f5b8435 --- /dev/null +++ b/server/lib/jellyfinsharing.test.ts @@ -0,0 +1,63 @@ +import type { JellyfinUserResponse } from '@server/api/jellyfin'; +import { MediaServerType } from '@server/constants/server'; +import { parseTagRestriction } from '@server/lib/jellyfinsharing'; +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; + +type Policy = JellyfinUserResponse['Policy']; + +const policy = (fields: Partial): Policy => + ({ IsAdministrator: false, ...fields }) as Policy; + +describe('parseTagRestriction', () => { + describe('Jellyfin', () => { + const parse = (fields: Partial) => + parseTagRestriction(policy(fields), MediaServerType.JELLYFIN); + + it('reads the two lists independently', () => { + assert.deepEqual( + parse({ AllowedTags: ['Family'], BlockedTags: ['Private'] }), + { allow: ['Family'], deny: ['Private'] } + ); + }); + + it('returns an empty restriction when the policy carries no tag', () => { + assert.deepEqual(parse({}), { allow: [], deny: [] }); + }); + + it('ignores the Emby inclusive flag', () => { + // The flag exists on Jellyfin policies too but has no meaning there, so + // reading it would silently turn a deny list into an allow list. + assert.deepEqual( + parse({ BlockedTags: ['Private'], IsTagBlockingModeInclusive: true }), + { allow: [], deny: ['Private'] } + ); + }); + }); + + describe('Emby', () => { + const parse = (fields: Partial) => + parseTagRestriction(policy(fields), MediaServerType.EMBY); + + it('treats the single list as a deny list by default', () => { + assert.deepEqual( + parse({ BlockedTags: ['Private'], IsTagBlockingModeInclusive: false }), + { allow: [], deny: ['Private'] } + ); + }); + + it('turns the same list into an allow list in inclusive mode', () => { + assert.deepEqual( + parse({ BlockedTags: ['Family'], IsTagBlockingModeInclusive: true }), + { allow: ['Family'], deny: [] } + ); + }); + + it('ignores AllowedTags, which Emby does not use', () => { + assert.deepEqual(parse({ AllowedTags: ['Family'] }), { + allow: [], + deny: [], + }); + }); + }); +}); diff --git a/server/lib/jellyfinsharing.ts b/server/lib/jellyfinsharing.ts new file mode 100644 index 0000000000..15237d7f5a --- /dev/null +++ b/server/lib/jellyfinsharing.ts @@ -0,0 +1,198 @@ +import type { + JellyfinLibraryItem, + JellyfinUserResponse, +} from '@server/api/jellyfin'; +import JellyfinAPI from '@server/api/jellyfin'; +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 type { TagRestriction } from '@server/lib/tagrestrictions'; +import { isAllowedByTagRestriction } from '@server/lib/tagrestrictions'; +import logger from '@server/logger'; +import { getHostname } from '@server/utils/getHostname'; + +/** + * Builds the set of Jellyfin or Emby item ids visible to a user. + * + * The allowed libraries are taken 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. + * + * Only that library dimension is handled here. Both servers carry a second, + * tag based mechanism which is the direct analogue of Plex labels, and which + * they do enforce: Jellyfin exposes `AllowedTags` and `BlockedTags` on the + * policy, Emby `IncludeTags`, `BlockedTags` and `IsTagBlockingModeInclusive`. + * A user restricted that way is still reported as having access here. + * + * Items are then listed with the owner's credentials, since neither server + * honours a `userId` 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. + * + * Returns `null` when the user reaches every enabled library, which callers + * must treat as "no filtering". + */ +/** + * Normalises the tag restrictions of a user policy, whose shape differs between + * the two servers. Both behaviours were established against real servers: + * Jellyfin applies `AllowedTags` and `BlockedTags` independently, while Emby + * keeps a single list in `BlockedTags` that `IsTagBlockingModeInclusive` turns + * from a deny list into an allow list. + */ +export const parseTagRestriction = ( + policy: JellyfinUserResponse['Policy'], + serverType: MediaServerType +): TagRestriction => { + const blocked = policy.BlockedTags ?? []; + + if (serverType === MediaServerType.EMBY) { + return policy.IsTagBlockingModeInclusive + ? { allow: [...blocked], deny: [] } + : { allow: [], deny: [...blocked] }; + } + + return { allow: [...(policy.AllowedTags ?? [])], deny: [...blocked] }; +}; + +/** Jellyfin reports item tags as `Tags`, Emby as `TagItems`. */ +const getItemTags = (item: JellyfinLibraryItem): string[] => + item.Tags ?? item.TagItems?.map((tag) => tag.Name) ?? []; + +/** + * Builds the test telling whether a configured library is shared with the user. + * + * Emby's policy references libraries by their guid while the id stored in the + * settings is the numeric one it returns elsewhere, so a policy entry has to be + * matched against either identifier. Getting this wrong is not a partial + * failure: no library matches and the whole library reads as unavailable. + */ +const buildLibraryMatcher = async ( + client: JellyfinAPI, + policy: JellyfinUserResponse['Policy'] +): Promise<(library: { id: string }) => boolean> => { + const allowedFolders = new Set(policy.EnabledFolders ?? []); + const guidById = new Map( + (await client.getLibraries()) + .filter((library) => library.guid) + .map((library) => [library.key, library.guid as string]) + ); + + return (library) => { + if (allowedFolders.has(library.id)) { + return true; + } + + const guid = guidById.get(library.id); + + return !!guid && allowedFolders.has(guid); + }; +}; + +export const resolveVisibleJellyfinItemIds = async ( + user: User +): Promise | null> => { + const settings = getSettings(); + + if ( + (settings.main.mediaServerType !== MediaServerType.JELLYFIN && + settings.main.mediaServerType !== MediaServerType.EMBY) || + user.userType === UserType.PLEX || + !user.jellyfinUserId + ) { + return null; + } + + const userRepository = getRepository(User); + const admin = await userRepository.findOne({ + select: { id: true, jellyfinDeviceId: true, jellyfinUserId: true }, + where: { id: 1 }, + }); + + // The owner always reaches the whole library. + if (!admin || user.id === admin.id) { + return null; + } + + const client = new JellyfinAPI( + getHostname(), + settings.jellyfin.apiKey, + admin.jellyfinDeviceId + ); + client.setUserId(admin.jellyfinUserId ?? ''); + + const { users } = await client.getUsers(); + const policy = users.find( + (candidate) => candidate.Id === user.jellyfinUserId + )?.Policy; + + // Without a policy there is nothing to enforce; leave availability untouched + // rather than risk hiding a library the user can actually reach. + if (!policy) { + return null; + } + + const tagRestriction = parseTagRestriction( + policy, + settings.main.mediaServerType + ); + const hasTagRestrictions = + tagRestriction.allow.length > 0 || tagRestriction.deny.length > 0; + const reachesEveryLibrary = policy.EnableAllFolders !== false; + + if (reachesEveryLibrary && !hasTagRestrictions) { + return null; + } + + const enabled = settings.jellyfin.libraries.filter( + (library) => library.enabled + ); + const isLibraryAllowed = reachesEveryLibrary + ? () => true + : await buildLibraryMatcher(client, policy); + + if (enabled.every(isLibraryAllowed) && !hasTagRestrictions) { + return null; + } + + const visible = new Set(); + + for (const library of enabled) { + if (!isLibraryAllowed(library)) { + // Library not shared with this user: nothing from it is visible. + continue; + } + + // Tags are only requested when they can change the outcome, since both + // servers return a long list of metadata keywords under the same field. + const items = await client.getLibraryContents( + library.id, + hasTagRestrictions ? { fields: 'Tags,TagItems' } : {} + ); + + for (const item of items) { + if ( + hasTagRestrictions && + !isAllowedByTagRestriction(tagRestriction, getItemTags(item)) + ) { + continue; + } + + visible.add(item.Id); + } + } + + logger.debug('Resolved media server library restrictions for user', { + label: 'Media Server Sharing', + userId: user.id, + allowedLibraries: enabled + .filter(isLibraryAllowed) + .map((library) => library.name), + tagRestriction: hasTagRestrictions ? tagRestriction : undefined, + visibleItems: visible.size, + }); + + return visible; +}; diff --git a/server/lib/librarysharing.test.ts b/server/lib/librarysharing.test.ts new file mode 100644 index 0000000000..25f2282aaa --- /dev/null +++ b/server/lib/librarysharing.test.ts @@ -0,0 +1,70 @@ +import { MediaServerType } from '@server/constants/server'; +import type Media from '@server/entity/Media'; +import { getMediaServerItemIds } from '@server/lib/librarysharing'; +import { getSettings } from '@server/lib/settings'; +import assert from 'node:assert/strict'; +import { afterEach, describe, it } from 'node:test'; + +const asMedia = (fields: Partial): Media => fields as Media; + +const withMediaServer = (type: MediaServerType) => { + getSettings().main.mediaServerType = type; +}; + +describe('getMediaServerItemIds', () => { + const initialType = getSettings().main.mediaServerType; + + afterEach(() => { + getSettings().main.mediaServerType = initialType; + }); + + it('returns Plex rating keys when the server is Plex', () => { + withMediaServer(MediaServerType.PLEX); + + assert.deepEqual( + getMediaServerItemIds( + asMedia({ + ratingKey: '51', + ratingKey4k: '52', + jellyfinMediaId: 'abc', + jellyfinMediaId4k: 'def', + }) + ), + { id: '51', id4k: '52' } + ); + }); + + it('returns Jellyfin item ids when the server is Jellyfin', () => { + withMediaServer(MediaServerType.JELLYFIN); + + assert.deepEqual( + getMediaServerItemIds( + asMedia({ + ratingKey: '51', + ratingKey4k: '52', + jellyfinMediaId: 'abc', + jellyfinMediaId4k: 'def', + }) + ), + { id: 'abc', id4k: 'def' } + ); + }); + + it('reuses the Jellyfin columns for Emby', () => { + withMediaServer(MediaServerType.EMBY); + + assert.deepEqual( + getMediaServerItemIds(asMedia({ jellyfinMediaId: 'abc' })), + { id: 'abc', id4k: undefined } + ); + }); + + it('tolerates media that carries no identifier yet', () => { + withMediaServer(MediaServerType.PLEX); + + assert.deepEqual(getMediaServerItemIds(asMedia({})), { + id: undefined, + id4k: undefined, + }); + }); +}); diff --git a/server/lib/librarysharing.ts b/server/lib/librarysharing.ts new file mode 100644 index 0000000000..aa579c3570 --- /dev/null +++ b/server/lib/librarysharing.ts @@ -0,0 +1,152 @@ +import { MediaType } from '@server/constants/media'; +import { MediaServerType } from '@server/constants/server'; +import type Media from '@server/entity/Media'; +import type { User } from '@server/entity/User'; +import { resolveVisibleJellyfinItemIds } from '@server/lib/jellyfinsharing'; +import { + grantLabelAccess, + resolveVisiblePlexRatingKeys, +} from '@server/lib/plexsharing'; +import { getSettings } from '@server/lib/settings'; +import logger from '@server/logger'; + +/** + * The set of media server item ids a restricted user is allowed to see, or + * `null` when the user is unrestricted and availability needs no filtering. + */ +export type VisibleMediaIds = Set | null; + +interface CacheEntry { + expiresAt: number; + ids: VisibleMediaIds; +} + +/** + * Resolving the visible ids costs a handful of media server requests, so the + * result is cached per user. Sharing rules 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(); + +/** + * Resolution takes several requests, so concurrent callers for the same user + * share the one in flight instead of each starting their own. + */ +const inFlight = new Map>(); + +export const clearLibrarySharingCache = (userId?: number): void => { + if (userId === undefined) { + cache.clear(); + inFlight.clear(); + return; + } + + cache.delete(userId); + inFlight.delete(userId); +}; + +const resolve = async (user: User): Promise => { + switch (getSettings().main.mediaServerType) { + case MediaServerType.PLEX: + return resolveVisiblePlexRatingKeys(user); + case MediaServerType.JELLYFIN: + case MediaServerType.EMBY: + return resolveVisibleJellyfinItemIds(user); + default: + return null; + } +}; + +export const getVisibleMediaIds = async ( + user: User +): Promise => { + const cached = cache.get(user.id); + + if (cached && cached.expiresAt > Date.now()) { + return cached.ids; + } + + const pending = inFlight.get(user.id); + + if (pending) { + return pending; + } + + const resolution = resolve(user) + .then((ids) => { + cache.set(user.id, { ids, expiresAt: Date.now() + CACHE_TTL_MS }); + return ids; + }) + .catch((e) => { + logger.error('Failed to resolve media server sharing restrictions', { + label: 'Media Server Sharing', + userId: user.id, + errorMessage: e.message, + }); + // Fail open: a transient error must not hide the whole library. + return null; + }) + .finally(() => { + inFlight.delete(user.id); + }); + + inFlight.set(user.id, resolution); + + return resolution; +}; + +/** + * Returns the identifiers a media item carries on the configured media server, + * for the regular and the 4K version. + */ +export const getMediaServerItemIds = ( + media: Media +): { id?: string | null; id4k?: string | null } => { + if (getSettings().main.mediaServerType === MediaServerType.PLEX) { + return { id: media.ratingKey, id4k: media.ratingKey4k }; + } + + return { id: media.jellyfinMediaId, id4k: media.jellyfinMediaId4k }; +}; + +/** + * Grants the requester access to media that has just become available, when the + * owner opted into it. + * + * Both routes a request can take end up here: the library already held the + * media, or a download landed and the scanner made it available. Granting from + * only one of them leaves the requester without access on the other, which is + * the more common case since most requests are downloaded rather than already + * present. + * + * Never throws: a failed grant must not hold up the request it belongs to. + */ +export const grantLibraryAccessForRequest = async ( + user: User | null | undefined, + ratingKey: string | null | undefined, + mediaType: MediaType, + requestId: number +): Promise => { + if (!getSettings().plex.grantLabelOnApproval || !user || !ratingKey) { + return; + } + + try { + const granted = await grantLabelAccess(user, { + ratingKey, + type: mediaType === MediaType.MOVIE ? 'movie' : 'show', + }); + + if (granted) { + // The user's visible set just changed, so drop the cached copy. + clearLibrarySharingCache(user.id); + } + } catch (e) { + logger.error('Failed to grant media server library access for request', { + label: 'Media Request', + requestId, + errorMessage: e instanceof Error ? e.message : String(e), + }); + } +}; diff --git a/server/lib/plexsharing.test.ts b/server/lib/plexsharing.test.ts new file mode 100644 index 0000000000..8c9456fb6d --- /dev/null +++ b/server/lib/plexsharing.test.ts @@ -0,0 +1,85 @@ +import { pickGrantLabel } from '@server/lib/plexsharing'; +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; + +const sharedWith = (counts: Record): Map => + new Map(Object.entries(counts)); + +describe('pickGrantLabel', () => { + it('grants the label named after the user', () => { + assert.deepEqual( + pickGrantLabel( + ['Family', 'Household', 'alice'], + ['alice'], + sharedWith({ family: 2, household: 1 }) + ), + { label: 'alice', alsoVisibleTo: 0 } + ); + }); + + it('grants nothing when no label is named after the user', () => { + assert.equal( + pickGrantLabel( + ['Family', 'Household'], + ['alice'], + sharedWith({ family: 2, household: 1 }) + ), + null + ); + }); + + it('grants nothing when the user has no name to match', () => { + assert.equal(pickGrantLabel(['Family', 'alice'], [], sharedWith({})), null); + }); + + it('matches the user name case-insensitively', () => { + assert.deepEqual( + pickGrantLabel(['Family', 'Alice'], ['alice'], sharedWith({})), + { label: 'Alice', alsoVisibleTo: 0 } + ); + }); + + it('matches any of the names the user is known by', () => { + assert.deepEqual( + pickGrantLabel( + ['Family', 'alice'], + ['alice.plex', 'alice'], + sharedWith({}) + ), + { label: 'alice', alsoVisibleTo: 0 } + ); + }); + + it('reports the other users a matching label is also visible to', () => { + assert.deepEqual( + pickGrantLabel(['alice'], ['alice'], sharedWith({ alice: 2 })), + { label: 'alice', alsoVisibleTo: 2 } + ); + }); + + it('prefers the least shared match when several names apply', () => { + assert.deepEqual( + pickGrantLabel( + ['alice', 'Family', 'alice-plex'], + ['alice', 'alice-plex'], + sharedWith({ alice: 2, 'alice-plex': 0 }) + ), + { label: 'alice-plex', alsoVisibleTo: 0 } + ); + }); + + it('keeps the allow list order when nothing else separates the matches', () => { + assert.deepEqual( + pickGrantLabel( + ['alice-plex', 'alice'], + ['alice', 'alice-plex'], + sharedWith({ alice: 1, 'alice-plex': 1 }) + ), + { label: 'alice-plex', alsoVisibleTo: 1 } + ); + }); + + it('returns null when there is no candidate', () => { + assert.equal(pickGrantLabel([], ['alice'], sharedWith({})), null); + }); +}); diff --git a/server/lib/plexsharing.ts b/server/lib/plexsharing.ts new file mode 100644 index 0000000000..e01460df75 --- /dev/null +++ b/server/lib/plexsharing.ts @@ -0,0 +1,317 @@ +import PlexAPI from '@server/api/plexapi'; +import type { PlexUserSharingRules } from '@server/api/plextv'; +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; + +/** Identifies a single item in the Plex library. */ +export interface PlexItemRef { + ratingKey: string; + type: 'movie' | 'show'; +} + +/** + * Picks which of the labels a user is allowed to see should be applied to grant + * them access to an item. + * + * A label is a shared bucket rather than a per-user permission: applying one + * that other restricted users are also allowed to see hands them the item too. + * Only a label named after the user is therefore ever applied, since that is the + * usual convention for a label meant for a single person, and nothing is granted + * when the user has no such label: a shared bucket is the owner's to hand out, + * not Seerr's. + * + * `sharedWithCount` maps a lowercased label to the number of *other* restricted + * users allowed to see it. Unrestricted users are not counted: they already see + * the item whatever label it carries. It no longer drives the choice, only the + * least shared of several name matches and the count reported to the owner, + * since even a label named after the user may be allowed to others. + */ +export const pickGrantLabel = ( + candidates: string[], + userNames: string[], + sharedWithCount: Map +): { label: string; alsoVisibleTo: number } | null => { + const names = new Set( + userNames.filter(Boolean).map((name) => name.toLowerCase()) + ); + + const ranked = candidates + .map((label, index) => ({ + label, + index, + alsoVisibleTo: sharedWithCount.get(label.toLowerCase()) ?? 0, + })) + .filter(({ label }) => names.has(label.toLowerCase())) + // Ordering is stable, to keep the choice predictable. + .sort((a, b) => a.alsoVisibleTo - b.alsoVisibleTo || a.index - b.index); + + const best = ranked[0]; + + return best ? { label: best.label, alsoVisibleTo: best.alsoVisibleTo } : null; +}; + +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. + */ +export const resolveVisiblePlexRatingKeys = 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; + + if (filter.allowNothing) { + // The restrictions cannot be satisfied, so skip the library entirely + // rather than listing items only to discard them. + continue; + } + + 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); + } + } + + logger.debug('Resolved Plex library restrictions for user', { + label: 'Plex Sharing', + userId: user.id, + visibleItems: visible.size, + }); + + 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; +}; + +/** + * 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, has no allow list to draw a + * label from (a deny-only restriction cannot be satisfied this way), or is + * allowed no label named after them. + */ +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 plexTvClient = new PlexTvAPI(adminToken); + const rulesByPlexUserId = await plexTvClient.getAllUserSharingRules(); + const rules = rulesByPlexUserId.get(user.plexId); + + if (!rules) { + return null; + } + + const filterOf = (candidate: PlexUserSharingRules) => + type === 'show' ? candidate.tv : candidate.movies; + const filter = filterOf(rules); + + if (filter.allowNothing) { + return null; + } + + // Case-insensitive, to match how the filter is evaluated: granting a label + // that the deny list then rejects would achieve nothing. + const denied = new Set(filter.deny.map((entry) => entry.toLowerCase())); + const candidates = filter.allow.filter( + (candidate) => !denied.has(candidate.toLowerCase()) + ); + + // How many other restricted users each candidate label would also expose the + // item to, which is reported to the owner along with the granted label. + const sharedWithCount = new Map(); + + for (const [plexUserId, otherRules] of rulesByPlexUserId) { + if (plexUserId === user.plexId) { + continue; + } + + const otherFilter = filterOf(otherRules); + const otherDenied = new Set( + otherFilter.deny.map((entry) => entry.toLowerCase()) + ); + + for (const allowed of otherFilter.allow) { + const key = allowed.toLowerCase(); + + if (!otherDenied.has(key)) { + sharedWithCount.set(key, (sharedWithCount.get(key) ?? 0) + 1); + } + } + } + + const picked = pickGrantLabel( + candidates, + [user.plexUsername, user.username].filter((name): name is string => !!name), + sharedWithCount + ); + + if (!picked) { + // Worth surfacing: the owner enabled the grant and nothing happened, which + // from the outside is indistinguishable from a failure. + logger.info( + 'No Plex label named after the user to grant, leaving the item untouched', + { + label: 'Plex Sharing', + userId: user.id, + ratingKey, + allowedLabels: candidates, + } + ); + + return null; + } + + await new PlexAPI({ plexToken: adminToken }).addLabel( + ratingKey, + type, + picked.label + ); + + logger.info('Granted Plex library access by adding a label', { + label: 'Plex Sharing', + userId: user.id, + ratingKey, + plexLabel: picked.label, + // Worth surfacing: no label is exclusive to a single user in every setup, + // so the owner should know when granting access widens it to others. + alsoVisibleToOtherUsers: picked.alsoVisibleTo, + }); + + return picked.label; +}; diff --git a/server/lib/settings/index.ts b/server/lib/settings/index.ts index 0a896524eb..634cead729 100644 --- a/server/lib/settings/index.ts +++ b/server/lib/settings/index.ts @@ -42,6 +42,15 @@ 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 the label named after them to it so they gain + * access, instead of leaving the request for the owner to handle. Nothing is + * added when they are allowed no such label. + * + * Disabled by default: granting library access is the owner's decision. + */ + grantLabelOnApproval?: boolean; } export interface JellyfinSettings { diff --git a/server/lib/tagrestrictions.test.ts b/server/lib/tagrestrictions.test.ts new file mode 100644 index 0000000000..f1b61092d4 --- /dev/null +++ b/server/lib/tagrestrictions.test.ts @@ -0,0 +1,62 @@ +import { isAllowedByTagRestriction } from '@server/lib/tagrestrictions'; +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; + +describe('isAllowedByTagRestriction', () => { + it('allows everything when no restriction applies', () => { + assert.equal( + isAllowedByTagRestriction({ allow: [], deny: [] }, ['anything']), + true + ); + assert.equal(isAllowedByTagRestriction({ allow: [], deny: [] }, []), true); + }); + + it('denies an item carrying a denied tag', () => { + assert.equal( + isAllowedByTagRestriction({ allow: [], deny: ['Private'] }, ['Private']), + false + ); + assert.equal( + isAllowedByTagRestriction({ allow: [], deny: ['Private'] }, ['Family']), + true + ); + }); + + it('requires one of the allowed tags when an allow list applies', () => { + const restriction = { allow: ['Family'], deny: [] }; + + assert.equal(isAllowedByTagRestriction(restriction, ['Family']), true); + assert.equal(isAllowedByTagRestriction(restriction, ['Other']), false); + assert.equal(isAllowedByTagRestriction(restriction, []), false); + }); + + it('lets a denied tag win over an allowed one', () => { + // Matches what both Plex and Jellyfin do when the same tag is on both + // lists: nothing carrying it is visible, and nothing without it satisfies + // the allow list either. + const restriction = { allow: ['Shared'], deny: ['Shared'] }; + + assert.equal(isAllowedByTagRestriction(restriction, ['Shared']), false); + assert.equal(isAllowedByTagRestriction(restriction, ['Other']), false); + }); + + it('compares tags case-insensitively', () => { + assert.equal( + isAllowedByTagRestriction({ allow: ['family'], deny: [] }, ['Family']), + true + ); + assert.equal( + isAllowedByTagRestriction({ allow: [], deny: ['PRIVATE'] }, ['private']), + false + ); + }); + + it('shows nothing when the restriction is unsatisfiable', () => { + assert.equal( + isAllowedByTagRestriction({ allow: [], deny: [], allowNothing: true }, [ + 'Family', + ]), + false + ); + }); +}); diff --git a/server/lib/tagrestrictions.ts b/server/lib/tagrestrictions.ts new file mode 100644 index 0000000000..0599f594d6 --- /dev/null +++ b/server/lib/tagrestrictions.ts @@ -0,0 +1,50 @@ +/** + * A restriction expressed as the labels or tags a user may and may not see. + * + * Every media server exposes the same idea under a different name: Plex calls + * them labels and encodes them in the sharing filter, Jellyfin splits them into + * `AllowedTags` and `BlockedTags`, Emby keeps a single list whose polarity is + * flipped by a flag. They are normalised into this shape so the matching rule + * lives in one place rather than once per server. + */ +export interface TagRestriction { + allow: string[]; + deny: string[]; + /** + * Set when the combined restrictions cannot be satisfied by any tag, so + * nothing is visible and the media server need not be queried at all. + */ + allowNothing?: boolean; +} + +export const EMPTY_TAG_RESTRICTION: TagRestriction = { allow: [], deny: [] }; + +/** + * Evaluates a restriction against the tags carried by an item. Items without + * tags are only visible when no allow list applies. + * + * A deny entry wins over an allow entry, which is what both Plex and Jellyfin + * do: a user allowed and denied the same tag sees nothing carrying it. + */ +export const isAllowedByTagRestriction = ( + restriction: TagRestriction, + tags: string[] +): boolean => { + if (restriction.allowNothing) { + return false; + } + + const normalized = tags.map((tag) => tag.toLowerCase()); + + if (restriction.deny.some((tag) => normalized.includes(tag.toLowerCase()))) { + return false; + } + + if (!restriction.allow.length) { + return true; + } + + return restriction.allow.some((tag) => + normalized.includes(tag.toLowerCase()) + ); +}; 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..762ee3ccee 100644 --- a/server/routes/media.ts +++ b/server/routes/media.ts @@ -27,29 +27,31 @@ mediaRoutes.get('/', async (req, res, next) => { const pageSize = req.query.take ? Number(req.query.take) : 20; const skip = req.query.skip ? Number(req.query.skip) : 0; - let statusFilter = undefined; + let acceptedStatuses: MediaStatus[] | undefined; switch (req.query.filter) { case 'available': - statusFilter = MediaStatus.AVAILABLE; + acceptedStatuses = [MediaStatus.AVAILABLE]; break; case 'partial': - statusFilter = MediaStatus.PARTIALLY_AVAILABLE; + acceptedStatuses = [MediaStatus.PARTIALLY_AVAILABLE]; break; case 'allavailable': - statusFilter = In([ + acceptedStatuses = [ MediaStatus.AVAILABLE, MediaStatus.PARTIALLY_AVAILABLE, - ]); + ]; break; case 'processing': - statusFilter = MediaStatus.PROCESSING; + acceptedStatuses = [MediaStatus.PROCESSING]; break; case 'pending': - statusFilter = MediaStatus.PENDING; + acceptedStatuses = [MediaStatus.PENDING]; break; } + const statusFilter = acceptedStatuses ? In(acceptedStatuses) : undefined; + let sortFilter: FindOneOptions['order'] = { id: 'DESC', }; @@ -75,12 +77,50 @@ mediaRoutes.get('/', async (req, res, next) => { } try { - const [media, mediaCount] = await mediaRepository.findAndCount({ - order: sortFilter, - where: whereClause, - take: pageSize, - skip, - }); + const mediaCount = await mediaRepository.count({ where: whereClause }); + + // This endpoint feeds the "Recently Added" slider, so availability has to + // be reported from the requesting user's point of view. The requested + // filter is then applied a second time: an item hidden from that user is no + // longer in the state the caller asked for, and without this pass the + // slider shows a restricted user exactly the titles the media server hides + // from them, merely flagged unavailable. + // + // Dropping them would leave a short page, so the library is walked further + // until the page is full. The walk is bounded, because a user who can see + // very little must not turn this endpoint into a full table scan; such a + // user gets a short page rather than an expensive one. + const results: Media[] = []; + const maxRowsScanned = pageSize * 10; + let offset = skip; + let rowsScanned = 0; + + while (results.length < pageSize && rowsScanned < maxRowsScanned) { + const batch = await mediaRepository.find({ + order: sortFilter, + where: whereClause, + take: pageSize, + skip: offset, + }); + + if (!batch.length) { + break; + } + + offset += batch.length; + rowsScanned += batch.length; + + const restricted = req.user + ? await Media.applySharingRestrictions(req.user, batch) + : batch; + + results.push( + ...(acceptedStatuses + ? restricted.filter((item) => acceptedStatuses.includes(item.status)) + : restricted) + ); + } + return res.status(200).json({ pageInfo: { pages: Math.ceil(mediaCount / pageSize), @@ -88,7 +128,7 @@ mediaRoutes.get('/', async (req, res, next) => { results: mediaCount, page: Math.ceil(skip / pageSize) + 1, }, - results: media, + results: results.slice(0, pageSize), } 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/request.ts b/server/routes/request.ts index fafa90692e..09b90a8160 100644 --- a/server/routes/request.ts +++ b/server/routes/request.ts @@ -29,6 +29,31 @@ import { Router } from 'express'; const requestRoutes = Router(); +/** + * Applies the requesting user's media server restrictions to the media carried + * by requests. + * + * Requests load their media eagerly, which bypasses the filtering done by the + * `Media` finders, so it has to be applied here as well. Without this the + * requests list reports media hidden from the user as available, and hands them + * a deep link they cannot open. + */ +const hideRestrictedMedia = async ( + user: User | undefined, + requests: MediaRequest[] +): Promise => { + if (!user) { + return; + } + + await Media.applySharingRestrictions( + user, + requests + .map((request) => request.media) + .filter((media): media is Media => !!media) + ); +}; + requestRoutes.get, RequestResultsResponse>( '/', async (req, res, next) => { @@ -181,6 +206,8 @@ requestRoutes.get, RequestResultsResponse>( .skip(skip) .getManyAndCount(); + await hideRestrictedMedia(req.user, requests); + const settings = getSettings(); // get all quality profiles for every configured sonarr server @@ -447,6 +474,8 @@ requestRoutes.get('/:requestId', async (req, res, next) => { }); } + await hideRestrictedMedia(req.user, [request]); + return res.status(200).json(request); } catch (e) { logger.debug('Failed to retrieve request.', { 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: { diff --git a/server/subscriber/MediaRequestSubscriber.ts b/server/subscriber/MediaRequestSubscriber.ts index c4b2263ccd..d17d0c7dfa 100644 --- a/server/subscriber/MediaRequestSubscriber.ts +++ b/server/subscriber/MediaRequestSubscriber.ts @@ -17,6 +17,7 @@ import Media from '@server/entity/Media'; import { MediaRequest } from '@server/entity/MediaRequest'; import Season from '@server/entity/Season'; import SeasonRequest from '@server/entity/SeasonRequest'; +import { grantLibraryAccessForRequest } from '@server/lib/librarysharing'; import notificationManager, { Notification } from '@server/lib/notifications'; import { getSettings } from '@server/lib/settings'; import logger from '@server/logger'; @@ -192,6 +193,48 @@ export class MediaRequestSubscriber implements EntitySubscriberInterface { try { - const requestRepository = getRepository(MediaRequest); + const requestRepository = manager.getRepository(MediaRequest); if (entity.status !== MediaRequestStatus.FAILED) { entity.status = MediaRequestStatus.FAILED; @@ -491,6 +506,43 @@ export class MediaRequestSubscriber implements EntitySubscriberInterface { + season.status = MediaRequestStatus.COMPLETED; + }); + await requestRepository.save(entity); + return; + } + if (settings.sonarr.length === 0 && !settings.sonarr[0]) { logger.warn( 'No Sonarr server configured, skipping request processing', @@ -541,32 +593,6 @@ export class MediaRequestSubscriber implements EntitySubscriberInterface { - season.status = MediaRequestStatus.COMPLETED; - }); - await requestRepository.save(entity); - return; - } - const tmdb = new TheMovieDb(); const sonarr = new SonarrAPI({ apiKey: sonarrSettings.apiKey, @@ -751,7 +777,7 @@ export class MediaRequestSubscriber implements EntitySubscriberInterface { try { - const requestRepository = getRepository(MediaRequest); + const requestRepository = manager.getRepository(MediaRequest); if (entity.status !== MediaRequestStatus.FAILED) { entity.status = MediaRequestStatus.FAILED; diff --git a/server/subscriber/MediaSubscriber.ts b/server/subscriber/MediaSubscriber.ts index 1ee26273fc..3851f63b07 100644 --- a/server/subscriber/MediaSubscriber.ts +++ b/server/subscriber/MediaSubscriber.ts @@ -7,6 +7,7 @@ import Media from '@server/entity/Media'; import { MediaRequest } from '@server/entity/MediaRequest'; import Season from '@server/entity/Season'; import SeasonRequest from '@server/entity/SeasonRequest'; +import { grantLibraryAccessForRequest } from '@server/lib/librarysharing'; import logger from '@server/logger'; import { withNestedTransaction } from '@server/utils/nestedTransaction'; import type { @@ -131,6 +132,51 @@ export class MediaSubscriber implements EntitySubscriberInterface { } await requestRepository.save(completedRequests); + + return completedRequests; + } + + return []; + } + + /** + * Grants the requester access to the media their request was just completed + * by, when the owner opted into it. + * + * The other half of the grant done when the library already held the media: + * most requests are downloaded rather than already present, and without this + * a restricted user still cannot see what they requested. + * + * Deliberately run outside the transaction that completed the requests, since + * granting talks to plex.tv and to the media server, which must not happen + * with a write lock held. + */ + private async grantLibraryAccessForCompleted( + completedRequests: MediaRequest[], + event: Media, + databaseEvent: Media, + is4k: boolean + ): Promise { + // A request also completes when the media is marked deleted, and the rating + // key survives that for as long as a request is still active, so that case + // is skipped rather than left to fail against Plex. + if ((is4k ? event.status4k : event.status) === MediaStatus.DELETED) { + return; + } + + // Read from the entity being saved, since the scanner sets the rating key + // in the same write that makes the media available. + const ratingKey = is4k + ? (event.ratingKey4k ?? databaseEvent.ratingKey4k) + : (event.ratingKey ?? databaseEvent.ratingKey); + + for (const request of completedRequests) { + await grantLibraryAccessForRequest( + request.requestedBy, + ratingKey, + request.type, + request.id + ); } } @@ -230,14 +276,23 @@ export class MediaSubscriber implements EntitySubscriberInterface { seasonStatusCheck(false))) && validStatuses.includes(event.entity.status) ) { - await withNestedTransaction(event.manager, async (manager) => { - await this.updateRelatedMediaRequest( - manager, - event.entity as Media, - event.databaseEntity as Media, - false - ); - }); + const completedRequests = await withNestedTransaction( + event.manager, + async (manager) => + this.updateRelatedMediaRequest( + manager, + event.entity as Media, + event.databaseEntity as Media, + false + ) + ); + + await this.grantLibraryAccessForCompleted( + completedRequests, + event.entity as Media, + event.databaseEntity as Media, + false + ); } } catch (e) { logger.error( @@ -258,14 +313,23 @@ export class MediaSubscriber implements EntitySubscriberInterface { seasonStatusCheck(true))) && validStatuses.includes(event.entity.status4k) ) { - await withNestedTransaction(event.manager, async (manager) => { - await this.updateRelatedMediaRequest( - manager, - event.entity as Media, - event.databaseEntity as Media, - true - ); - }); + const completedRequests = await withNestedTransaction( + event.manager, + async (manager) => + this.updateRelatedMediaRequest( + manager, + event.entity as Media, + event.databaseEntity as Media, + true + ) + ); + + await this.grantLibraryAccessForCompleted( + completedRequests, + event.entity as Media, + event.databaseEntity as Media, + true + ); } } catch (e) { logger.error( diff --git a/src/components/Settings/SettingsPlex.tsx b/src/components/Settings/SettingsPlex.tsx index 2945344b73..98722b7a76 100644 --- a/src/components/Settings/SettingsPlex.tsx +++ b/src/components/Settings/SettingsPlex.tsx @@ -70,6 +70,9 @@ const messages = defineMessages('components.Settings', { webAppUrl: 'Web App URL', webAppUrlTip: 'Optionally direct users to the web app on your server instead of https://app.plex.tv/desktop', + grantLabelOnApproval: 'Grant Library Access on Approval', + grantLabelOnApprovalTip: + 'When a user restricted by labels requests media already in your library, grant them access by adding the label named after them, instead of leaving the request for you to handle manually. Nothing is added when they are allowed no such label', tautulliSettings: 'Tautulli Settings', tautulliSettingsDescription: 'Optionally configure the settings for your Tautulli server. Seerr fetches watch history data for your Plex media from Tautulli.', @@ -386,6 +389,7 @@ const SettingsPlex = ({ isSetupSettings }: SettingsPlexProps) => { useSsl: data?.useSsl, selectedPreset: undefined, webAppUrl: data?.webAppUrl, + grantLabelOnApproval: data?.grantLabelOnApproval ?? false, }} validationSchema={PlexSettingsSchema} validateOnMount={true} @@ -407,6 +411,7 @@ const SettingsPlex = ({ isSetupSettings }: SettingsPlexProps) => { port: Number(values.port), useSsl: values.useSsl, webAppUrl: values.webAppUrl, + grantLabelOnApproval: values.grantLabelOnApproval, } as PlexSettings); syncLibraries(); @@ -612,6 +617,31 @@ const SettingsPlex = ({ isSetupSettings }: SettingsPlexProps) => { )} +
+ +
+ { + setFieldValue( + 'grantLabelOnApproval', + !values.grantLabelOnApproval + ); + }} + /> +
+
diff --git a/src/i18n/locale/en.json b/src/i18n/locale/en.json index d30b6e1fdc..fbb0f4e2db 100644 --- a/src/i18n/locale/en.json +++ b/src/i18n/locale/en.json @@ -1204,6 +1204,8 @@ "components.Settings.failed": "Does not work", "components.Settings.failedToSaveMetadataSettings": "Failed to save metadata provider settings", "components.Settings.general": "General", + "components.Settings.grantLabelOnApproval": "Grant Library Access on Approval", + "components.Settings.grantLabelOnApprovalTip": "When a user restricted by labels requests media already in your library, grant them access by adding the label named after them, instead of leaving the request for you to handle manually. Nothing is added when they are allowed no such label", "components.Settings.hostname": "Hostname or IP Address", "components.Settings.importBlocklistedTagsTip": "Import blocklisted tag configuration", "components.Settings.invalidKeyword": "{keywordId} is not a TMDB keyword.",