Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
5231ed2
feat(plex): respect per-user library restrictions when reporting avai…
Izakun Jul 26, 2026
ea25f33
feat(plex): optionally grant library access when a request is approved
Izakun Jul 26, 2026
f91e04b
feat(jellyfin): respect per-user library restrictions when reporting …
Izakun Jul 26, 2026
f3cab77
fix: address review feedback on library sharing restrictions
Izakun Jul 26, 2026
8a802e6
fix(media): drop deep links for restricted media
Izakun Jul 26, 2026
552c4db
fix(requests): apply library restrictions to request media
Izakun Jul 26, 2026
a00bc31
fix(plex): grant the most exclusive label a user is allowed to see
Izakun Jul 26, 2026
009cbed
docs(settings): describe which label the grant setting applies
Izakun Jul 26, 2026
4914b8b
chore(plex): log the resolved visible item count
Izakun Jul 26, 2026
f9bc03d
docs(jellyfin): correct the claim that access is per library only
Izakun Jul 26, 2026
95b112e
fix(emby): match library policy entries on the guid as well
Izakun Jul 26, 2026
d72a015
feat(jellyfin,emby): honour tag restrictions alongside library access
Izakun Jul 26, 2026
e7e76ec
fix(requests): complete already available media before any arr lookup
Izakun Jul 27, 2026
6ae0211
test(plex): use fictional names in the grant label fixtures
Izakun Jul 27, 2026
e2dd49a
fix(media): drop items the per-user downgrade took out of the filter
Izakun Jul 27, 2026
d30d065
fix(media): keep the page full for a restricted user
Izakun Jul 27, 2026
49d4251
fix(plex): only grant the label named after the user
Izakun Aug 18, 2026
e3a05be
fix(requests): grant the label once the download makes the media avai…
Izakun Aug 18, 2026
817f3e8
fix(requests): grant the label outside the completing transaction
Izakun Aug 18, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions server/api/externalapi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,31 @@ class ExternalAPI {
return response.data;
}

protected async put<T>(
endpoint: string,
data?: Record<string, unknown>,
config?: AxiosRequestConfig,
ttl?: number
): Promise<T> {
const cacheKey = this.serializeCacheKey(endpoint, {
config: config?.params,
...(data ? { data } : {}),
});

const cachedItem = this.cache?.get<T>(cacheKey);
if (cachedItem) {
return cachedItem;
}

const response = await this.axios.put<T>(endpoint, data, config);

if (this.cache && ttl !== 0) {
this.cache.set(cacheKey, response.data, ttl ?? DEFAULT_TTL);
}

return response.data;
}

protected async getRolling<T>(
endpoint: string,
config?: AxiosRequestConfig,
Expand Down
46 changes: 44 additions & 2 deletions server/api/jellyfin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down Expand Up @@ -70,13 +94,21 @@ 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 {
type: 'show' | 'movie';
key: string;
title: string;
agent: string;
/** Emby's alternative identifier for the same library, see `JellyfinMediaFolder`. */
guid?: string;
}

export interface JellyfinLibraryItem {
Expand All @@ -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 {
Expand Down Expand Up @@ -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<JellyfinLibraryItem[]> {
public async getLibraryContents(
id: string,
{ fields }: { fields?: string } = {}
): Promise<JellyfinLibraryItem[]> {
try {
const libraryItemsResponse = await this.get<any>(
`/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(
Expand Down
106 changes: 106 additions & 0 deletions server/api/plexapi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,17 @@ interface PlexMetadataResponse {
};
}

interface PlexLabelResponse {
MediaContainer: {
Metadata?: {
librarySectionID?: number | string;
Label?: {
tag: string;
}[];
}[];
};
}

class PlexAPI extends ExternalAPI {
constructor({
plexToken,
Expand Down Expand Up @@ -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<string[]> {
const ratingKeys: string[] = [];
const size = 500;
let offset = 0;
let totalSize = 0;

do {
const response = await this.get<PlexLibraryResponse>(
`/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<void> {
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()}`);
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved

/**
* 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<PlexLabelResponse>(
`/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 } = {}
Expand Down
133 changes: 133 additions & 0 deletions server/api/plextv.test.ts
Original file line number Diff line number Diff line change
@@ -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
);
});
});
Loading