Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
11 changes: 9 additions & 2 deletions docs/using-seerr/plex/watchlist-auto-request.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,10 @@ This feature is only available for Plex users. Local users cannot use the Watchl

## Prerequisites

- You must have logged into Seerr at least once with your Plex account
- You must have _one_ of the following
- You have logged into Seerr at least once with your Plex account
- You are Plex friends with your administrator and have kept the default setting to share your Plex Watchlist with friends
- You have your Plex Watchlist set to public
- Your administrator must have granted you the necessary permissions
- Your Plex account must have access to the Plex server configured in Seerr

Expand Down Expand Up @@ -57,6 +60,10 @@ Contact your administrator to verify you have been granted:
- Seerr will automatically create requests for new items
- You'll receive notifications when items are auto-requested

:::warning Watchlist Inaccessible
If you receive the error "User's watchlist is not accessible." when saving your settings at the end of Step 2, you have not fulfilled the first prerequisite.
:::

## How It Works

Once properly configured, Seerr will:
Expand Down Expand Up @@ -90,6 +97,6 @@ Auto-request only works for standard quality content. 4K content must be request

- Local users cannot use this feature
- 4K content requires manual requests
- Users must have logged into Seerr with their Plex account
- Users must have logged into Seerr with their Plex account or have made their watchlist accessible to their administrator's Plex account
- Respects user request limits and quotas
- Won't request content already in your libraries
126 changes: 90 additions & 36 deletions server/api/plextv.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,15 +98,37 @@
};
}

interface WatchlistResponse {
interface HomeUsersResponse {
MediaContainer: {
totalSize: number;
Metadata?: {
ratingKey: string;
User: {
$: {
id: string;
uuid: string;
title: string;
username: string;
email: string;
thumb: string;
};
}[];
};
}

interface WatchlistResponse {
data?: {
user: {
watchlist: {
nodes: {
id: string;
}[];
pageInfo: {
hasNextPage: boolean;
endCursor: string;
};
};
};
} | null;
}

type PlexMetadataItem = {
ratingKey: string;
type: 'movie' | 'show';
Expand All @@ -115,6 +137,7 @@
id: `imdb://tt${number}` | `tmdb://${number}` | `tvdb://${number}`;
}[];
};

interface MetadataResponse {
MediaContainer: {
Metadata?: PlexMetadataItem[];
Expand Down Expand Up @@ -268,35 +291,65 @@
return parsedXml;
}

public async getWatchlist({
offset = 0,
size = 20,
}: { offset?: number; size?: number } = {}): Promise<{
offset: number;
size: number;
public async getHomeUsers(): Promise<HomeUsersResponse> {
const response = await this.axios.get('/api/home/users', {
transformResponse: [],
responseType: 'text',
});

Check failure

Code scanning / CodeQL

Server-side request forgery Critical

The
host
of this request depends on a
user-provided value
.
The
host
of this request depends on a
user-provided value
.
Comment thread
0xSysR3ll marked this conversation as resolved.
Dismissed

const parsedXml = (await xml2js.parseStringPromise(
response.data
)) as HomeUsersResponse;
return parsedXml;
}

public async getWatchlist(
uuid: string,
{
first = 20,
after = undefined,
}: { first?: number; after?: string | undefined } = {}
): Promise<{
first: number;
after: string | undefined;
endCursor: string | undefined;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
totalSize: number;
items: PlexWatchlistItem[];
Comment on lines +306 to 317
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot May 10, 2026

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

This only processes the first watchlist page.

getWatchlist() fetches one page (first = 20) and never follows pageInfo.hasNextPage/endCursor. Every caller added in this PR uses the default path, so any Plex watchlist with more than 20 entries will be silently truncated during sync/import. totalSize is also derived from the current page length, so callers cannot detect the truncation from the return value.

Also applies to: 323-352, 364-433

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

In `@server/api/plextv.ts` around lines 306 - 317, getWatchlist currently only
fetches the first page (default first=20) and returns page-limited items and
totalSize, so long lists are truncated; update getWatchlist to follow pagination
by looping while pageInfo.hasNextPage is true, calling the same fetch logic with
pageInfo.endCursor/after to request subsequent pages, concatenating
PlexWatchlistItem results into a single items array, update endCursor to the
last page's endCursor, and set totalSize from the server-provided total (if
available) or the final items.length instead of per-page length; apply the same
pagination fix to the other similar blocks referenced (around lines 323-352 and
364-433) to ensure full list retrieval and correct totalSize reporting.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Known issue and to be done as stated in the PR description.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

@xNul, understood — thanks for the clarification! I can see the PR description already calls this out as an outstanding item with TODOs marking the relevant spots in the code. I'll defer to that tracking. Would you like me to open a follow-up GitHub issue to track the cursor-pagination implementation for getWatchlist() so it doesn't get lost after this PR merges?

(。•ᴗ•。)🐇

}> {
try {
const watchlistCache = cacheManager.getCache('plexwatchlist');
let cachedWatchlist = watchlistCache.data.get<PlexWatchlistCache>(
this.authToken
);
let cachedWatchlist = watchlistCache.data.get<PlexWatchlistCache>(uuid);

const response = await this.axios.get<WatchlistResponse>(
'/library/sections/watchlist/all',
const response = await this.axios.post<WatchlistResponse>(
'/api',
{
params: {
'X-Plex-Container-Start': offset,
'X-Plex-Container-Size': size,
},
headers: {
'If-None-Match': cachedWatchlist?.etag,
query: `query GetWatchlistHub($uuid: ID = "", $first: PaginationInt!, $after: String) {
user(id: $uuid) {
watchlist(first: $first, after: $after) {
nodes {
...itemFields
}
pageInfo {
hasNextPage
endCursor
}
}
}
}

fragment itemFields on MetadataItem {
id
}`,
variables: {
uuid: uuid,
first: first,
after: after,
},
baseURL: 'https://discover.provider.plex.tv',
validateStatus: (status) => status < 400, // Allow HTTP 304 to return without error
},
{
baseURL: 'https://community.plex.tv',
}
);

Check failure

Code scanning / CodeQL

Server-side request forgery Critical

The
host
of this request depends on a
user-provided value
.
The
host
of this request depends on a
user-provided value
.

// If we don't recieve HTTP 304, the watchlist has been updated and we need to update the cache.
if (response.status >= 200 && response.status <= 299) {
Expand All @@ -305,27 +358,24 @@
response: response.data,
};

watchlistCache.data.set<PlexWatchlistCache>(
this.authToken,
cachedWatchlist
);
watchlistCache.data.set<PlexWatchlistCache>(uuid, cachedWatchlist);
}

const watchlistDetails = await Promise.all(
(cachedWatchlist?.response.MediaContainer.Metadata ?? []).map(
(cachedWatchlist?.response.data?.user.watchlist.nodes ?? []).map(
async (watchlistItem) => {
let detailedResponse: MetadataResponse;
try {
detailedResponse = await this.getRolling<MetadataResponse>(
`/library/metadata/${watchlistItem.ratingKey}`,
`/library/metadata/${watchlistItem.id}`,
{
baseURL: 'https://discover.provider.plex.tv',
}
);
} catch (e) {
if (e.response?.status === 404) {
logger.warn(
`Item with ratingKey ${watchlistItem.ratingKey} not found, it may have been removed from the server.`,
`Item with id ${watchlistItem.id} not found, it may have been removed from the server.`,
{ label: 'Plex.TV Metadata API' }
);
return null;
Expand All @@ -340,7 +390,7 @@

if (!metadata) {
logger.warn(
`Item with ratingKey ${watchlistItem.ratingKey} returned no metadata, skipping.`,
`Item with id ${watchlistItem.id} returned no metadata, skipping.`,
{ label: 'Plex.TV Metadata API' }
);
return null;
Expand Down Expand Up @@ -373,9 +423,12 @@
) as PlexWatchlistItem[];

return {
offset,
size,
totalSize: cachedWatchlist?.response.MediaContainer.totalSize ?? 0,
first,
after,
endCursor:
cachedWatchlist?.response.data?.user.watchlist.pageInfo.endCursor,
totalSize:
cachedWatchlist?.response.data?.user.watchlist.nodes.length ?? 0,
items: filteredList,
};
} catch (e) {
Expand All @@ -384,8 +437,9 @@
errorMessage: e.message,
});
return {
offset,
size,
first,
after,
endCursor: undefined,
totalSize: 0,
items: [],
};
Expand Down
1 change: 1 addition & 0 deletions server/constants/error.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,4 +9,5 @@ export enum ApiErrorCode {
SyncErrorNoLibraries = 'SYNC_ERROR_NO_LIBRARIES',
Unauthorized = 'UNAUTHORIZED',
Unknown = 'UNKNOWN',
WatchlistAccessDenied = 'WATCHLIST_ACCESS_DENIED',
}
4 changes: 4 additions & 0 deletions server/entity/User.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ export class User {
static readonly filteredFields: string[] = [
'email',
'plexId',
'plexUuid',
'password',
'resetPasswordGuid',
'jellyfinDeviceId',
Expand Down Expand Up @@ -89,6 +90,9 @@ export class User {
@Column({ type: 'integer', nullable: true, select: true })
public plexId?: number | null;

@Column({ type: 'varchar', nullable: true, select: false })
public plexUuid?: string | null;

@Column({ type: 'varchar', nullable: true })
public jellyfinUserId?: string | null;

Expand Down
76 changes: 69 additions & 7 deletions server/lib/watchlistsync.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import PlexTvAPI from '@server/api/plextv';
import { MediaStatus, MediaType } from '@server/constants/media';
import { UserType } from '@server/constants/user';
import { getRepository } from '@server/datasource';
import Media from '@server/entity/Media';
import {
Expand All @@ -18,22 +19,62 @@ class WatchlistSync {
public async syncWatchlist() {
const userRepository = getRepository(User);

// Taken from auth.ts
const mainUser = await userRepository.findOneOrFail({
select: { id: true, plexToken: true },
where: { id: 1 },
});

// Old imported plex users may not have plex uuids stored in the db
const nullUsers = await userRepository
.createQueryBuilder('user')
.where('user.userType = :userType', { userType: UserType.PLEX })
.andWhere('user.plexUuid IS NULL')
.getMany();

if (nullUsers.length > 0) {
logger.warn(
'Found plex users without assigned uuids. Obtaining corresponding uuids.',
{
label: 'Plex Watchlist Sync',
}
);

const mainPlexTv = new PlexTvAPI(mainUser.plexToken ?? '');

const plexHomeUsersResponse = await mainPlexTv.getHomeUsers();
for (const rawUser of plexHomeUsersResponse.MediaContainer.User) {
const account = rawUser.$;

const user = await userRepository
.createQueryBuilder('user')
.where('user.plexId = :id', { id: account.id })
.getOne();

if (user) {
user.plexUuid = account.uuid;
await userRepository.save(user);
}
}
}

// Get users who actually have plex tokens
const users = await userRepository
.createQueryBuilder('user')
.addSelect('user.plexToken')
.addSelect('user.plexUuid')
.leftJoinAndSelect('user.settings', 'settings')
.where("user.plexToken != ''")
.where('user.userType = :userType', { userType: UserType.PLEX })
.getMany();

for (const user of users) {
await this.syncUserWatchlist(user);
await this.syncUserWatchlist(user, mainUser.plexToken ?? '');
}
}

private async syncUserWatchlist(user: User) {
if (!user.plexToken) {
logger.warn('Skipping user watchlist sync for user without plex token', {
private async syncUserWatchlist(user: User, mainPlexToken: string) {
if (!user.plexUuid) {
logger.warn('Skipping user watchlist sync for user without plex uuid', {
label: 'Plex Watchlist Sync',
user: user.displayName,
});
Expand Down Expand Up @@ -61,9 +102,30 @@ class WatchlistSync {
return;
}

const plexTvApi = new PlexTvAPI(user.plexToken);
// Token sync if the user has a token, else fallback to sync using the main user's token
const plexTvApi = user.plexToken
? new PlexTvAPI(user.plexToken)
: new PlexTvAPI(mainPlexToken);

const response = await plexTvApi.getWatchlist(user.plexUuid);

// endCursor will be undefined if the GQL query fails to return Watchlist data
if (response.endCursor === undefined && user.settings) {
user.settings.watchlistSyncMovies = false;
user.settings.watchlistSyncTv = false;

const response = await plexTvApi.getWatchlist({ size: 20 });
const userRepository = getRepository(User);
await userRepository.save(user);

logger.warn(
'Disabling watchlist sync for user because access is denied',
{
label: 'Plex Watchlist Sync',
user: user.displayName,
}
);
return;
}

const mediaItems = await Media.getRelatedMedia(
user,
Expand Down
15 changes: 15 additions & 0 deletions server/migration/postgres/1777262170937-AddPlexUuidToUser.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import type { MigrationInterface, QueryRunner } from 'typeorm';

export class AddPlexUuidToUser1777262170937 implements MigrationInterface {
name = 'AddPlexUuidToUser1777262170937';

public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "user" ADD "plexUuid" character varying`
);
}

public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "user" DROP COLUMN "plexUuid"`);
}
}
Loading
Loading