From 1c6a8e7d83f8191e5fd922f6760aecb78dd8128c Mon Sep 17 00:00:00 2001 From: xNul <894305+xNul@users.noreply.github.com> Date: Mon, 27 Apr 2026 04:17:44 +0000 Subject: [PATCH 1/6] feat: add GraphQL support for Plex Watchlist Sync Previous Plex Watchlist Sync was limited to those who logged into Seerr with their Plex accounts. GraphQL-based Plex Watchlist Sync expands sync to all who are friends with those who logged into Seerr and unrestricted/public Plex Watchlists. Oftentimes, just importing a Plex user is enough to allow Plex Watchlist Sync. BREAKING CHANGE: Addition of a new plexUuid column to the User table in the database. --- server/api/plextv.ts | 124 ++++++++---- server/entity/User.ts | 4 + server/lib/watchlistsync.ts | 25 ++- .../1777262170937-AddPlexUuidToUser.ts | 15 ++ .../sqlite/1777247406444-AddPlexUuidToUser.ts | 191 ++++++++++++++++++ server/routes/auth.ts | 2 + server/routes/discover.ts | 14 +- server/routes/user/index.ts | 20 +- server/routes/user/usersettings.ts | 1 + 9 files changed, 348 insertions(+), 48 deletions(-) create mode 100644 server/migration/postgres/1777262170937-AddPlexUuidToUser.ts create mode 100644 server/migration/sqlite/1777247406444-AddPlexUuidToUser.ts diff --git a/server/api/plextv.ts b/server/api/plextv.ts index b8f3c9a47b..3293555586 100644 --- a/server/api/plextv.ts +++ b/server/api/plextv.ts @@ -98,15 +98,37 @@ interface UsersResponse { }; } -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; + }; + }; + }; + }; +} + type PlexMetadataItem = { ratingKey: string; type: 'movie' | 'show'; @@ -115,6 +137,7 @@ type PlexMetadataItem = { id: `imdb://tt${number}` | `tmdb://${number}` | `tvdb://${number}`; }[]; }; + interface MetadataResponse { MediaContainer: { Metadata?: PlexMetadataItem[]; @@ -268,33 +291,63 @@ class PlexTvAPI extends ExternalAPI { return parsedXml; } - public async getWatchlist({ - offset = 0, - size = 20, - }: { offset?: number; size?: number } = {}): Promise<{ - offset: number; - size: number; + public async getHomeUsers(): Promise { + const response = await this.axios.get('/api/home/users', { + transformResponse: [], + responseType: 'text', + }); + + const parsedXml = (await xml2js.parseStringPromise( + response.data + )) as HomeUsersResponse; + return parsedXml; + } + + public async getWatchlist( + uuid: string, + { + first = 20, + after = undefined, + }: { first?: number; after?: number | undefined } = {} + ): Promise<{ + first: number; + after: number | undefined; + endCursor: string | undefined; totalSize: number; items: PlexWatchlistItem[]; }> { try { const watchlistCache = cacheManager.getCache('plexwatchlist'); - let cachedWatchlist = watchlistCache.data.get( - this.authToken - ); + let cachedWatchlist = watchlistCache.data.get(uuid); - const response = await this.axios.get( - '/library/sections/watchlist/all', + const response = await this.axios.post( + '/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', } ); @@ -305,19 +358,16 @@ class PlexTvAPI extends ExternalAPI { response: response.data, }; - watchlistCache.data.set( - this.authToken, - cachedWatchlist - ); + watchlistCache.data.set(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( - `/library/metadata/${watchlistItem.ratingKey}`, + `/library/metadata/${watchlistItem.id}`, { baseURL: 'https://discover.provider.plex.tv', } @@ -325,7 +375,7 @@ class PlexTvAPI extends ExternalAPI { } 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; @@ -373,9 +423,12 @@ class PlexTvAPI extends ExternalAPI { ) 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) { @@ -384,8 +437,9 @@ class PlexTvAPI extends ExternalAPI { errorMessage: e.message, }); return { - offset, - size, + first, + after, + endCursor: undefined, totalSize: 0, items: [], }; diff --git a/server/entity/User.ts b/server/entity/User.ts index 739fff32ea..b0eca7fb99 100644 --- a/server/entity/User.ts +++ b/server/entity/User.ts @@ -43,6 +43,7 @@ export class User { static readonly filteredFields: string[] = [ 'email', 'plexId', + 'plexUuid', 'password', 'resetPasswordGuid', 'jellyfinDeviceId', @@ -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; diff --git a/server/lib/watchlistsync.ts b/server/lib/watchlistsync.ts index 550a6524ad..f6d1b473b7 100644 --- a/server/lib/watchlistsync.ts +++ b/server/lib/watchlistsync.ts @@ -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 { @@ -18,22 +19,29 @@ 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 }, + }); + // 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, }); @@ -61,9 +69,12 @@ 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({ size: 20 }); + const response = await plexTvApi.getWatchlist(user.plexUuid); const mediaItems = await Media.getRelatedMedia( user, diff --git a/server/migration/postgres/1777262170937-AddPlexUuidToUser.ts b/server/migration/postgres/1777262170937-AddPlexUuidToUser.ts new file mode 100644 index 0000000000..e9461ff8e4 --- /dev/null +++ b/server/migration/postgres/1777262170937-AddPlexUuidToUser.ts @@ -0,0 +1,15 @@ +import type { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddPlexUuidToUser1777262170937 implements MigrationInterface { + name = 'AddPlexUuidToUser1777262170937'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "user" ADD "plexUuid" character varying` + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "user" DROP COLUMN "plexUuid"`); + } +} diff --git a/server/migration/sqlite/1777247406444-AddPlexUuidToUser.ts b/server/migration/sqlite/1777247406444-AddPlexUuidToUser.ts new file mode 100644 index 0000000000..ed2798d074 --- /dev/null +++ b/server/migration/sqlite/1777247406444-AddPlexUuidToUser.ts @@ -0,0 +1,191 @@ +import type { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddPlexUuidToUser1777247406444 implements MigrationInterface { + name = 'AddPlexUuidToUser1777247406444'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP INDEX "IDX_03f7958328e311761b0de675fb"`); + await queryRunner.query( + `CREATE TABLE "temporary_user_push_subscription" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "endpoint" varchar NOT NULL, "p256dh" varchar NOT NULL, "auth" varchar NOT NULL, "userId" integer, "userAgent" varchar, "createdAt" datetime DEFAULT (CURRENT_TIMESTAMP), CONSTRAINT "UQ_f90ab5a4ed54905a4bb51a7148b" UNIQUE ("auth"), CONSTRAINT "UQ_6427d07d9a171a3a1ab87480005" UNIQUE ("endpoint", "userId"), CONSTRAINT "FK_03f7958328e311761b0de675fbe" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)` + ); + await queryRunner.query( + `INSERT INTO "temporary_user_push_subscription"("id", "endpoint", "p256dh", "auth", "userId", "userAgent", "createdAt") SELECT "id", "endpoint", "p256dh", "auth", "userId", "userAgent", "createdAt" FROM "user_push_subscription"` + ); + await queryRunner.query(`DROP TABLE "user_push_subscription"`); + await queryRunner.query( + `ALTER TABLE "temporary_user_push_subscription" RENAME TO "user_push_subscription"` + ); + await queryRunner.query( + `CREATE INDEX "IDX_03f7958328e311761b0de675fb" ON "user_push_subscription" ("userId") ` + ); + await queryRunner.query(`DROP INDEX "IDX_356721a49f145aa439c16e6b99"`); + await queryRunner.query(`DROP INDEX "IDX_09b94c932e84635c5461f3c0a9"`); + await queryRunner.query( + `CREATE TABLE "temporary_blocklist" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "mediaType" varchar NOT NULL, "title" varchar, "tmdbId" integer NOT NULL, "blocklistedTags" varchar, "createdAt" datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP), "userId" integer, "mediaId" integer, CONSTRAINT "UQ_6bbafa28411e6046421991ea21c" UNIQUE ("tmdbId"), CONSTRAINT "REL_62b7ade94540f9f8d8bede54b9" UNIQUE ("mediaId"), CONSTRAINT "FK_5c8af2d0e83b3be6d250eccc19d" FOREIGN KEY ("mediaId") REFERENCES "media" ("id") ON DELETE CASCADE ON UPDATE NO ACTION, CONSTRAINT "FK_356721a49f145aa439c16e6b999" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE NO ACTION ON UPDATE NO ACTION)` + ); + await queryRunner.query( + `INSERT INTO "temporary_blocklist"("id", "mediaType", "title", "tmdbId", "blocklistedTags", "createdAt", "userId", "mediaId") SELECT "id", "mediaType", "title", "tmdbId", "blocklistedTags", "createdAt", "userId", "mediaId" FROM "blocklist"` + ); + await queryRunner.query(`DROP TABLE "blocklist"`); + await queryRunner.query( + `ALTER TABLE "temporary_blocklist" RENAME TO "blocklist"` + ); + await queryRunner.query( + `CREATE INDEX "IDX_356721a49f145aa439c16e6b99" ON "blocklist" ("userId") ` + ); + await queryRunner.query( + `CREATE INDEX "IDX_09b94c932e84635c5461f3c0a9" ON "blocklist" ("tmdbId") ` + ); + await queryRunner.query( + `CREATE TABLE "temporary_user" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "email" varchar NOT NULL, "username" varchar, "plexId" integer, "plexToken" varchar, "permissions" integer NOT NULL DEFAULT (0), "avatar" varchar NOT NULL, "createdAt" datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP), "updatedAt" datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP), "password" varchar, "userType" integer NOT NULL DEFAULT (1), "plexUsername" varchar, "resetPasswordGuid" varchar, "recoveryLinkExpirationDate" datetime, "movieQuotaLimit" integer, "movieQuotaDays" integer, "tvQuotaLimit" integer, "tvQuotaDays" integer, "jellyfinUsername" varchar, "jellyfinAuthToken" varchar, "jellyfinUserId" varchar, "jellyfinDeviceId" varchar, "avatarETag" varchar, "avatarVersion" varchar, "plexUuid" varchar, CONSTRAINT "UQ_e12875dfb3b1d92d7d7c5377e22" UNIQUE ("email"))` + ); + await queryRunner.query( + `INSERT INTO "temporary_user"("id", "email", "username", "plexId", "plexToken", "permissions", "avatar", "createdAt", "updatedAt", "password", "userType", "plexUsername", "resetPasswordGuid", "recoveryLinkExpirationDate", "movieQuotaLimit", "movieQuotaDays", "tvQuotaLimit", "tvQuotaDays", "jellyfinUsername", "jellyfinAuthToken", "jellyfinUserId", "jellyfinDeviceId", "avatarETag", "avatarVersion") SELECT "id", "email", "username", "plexId", "plexToken", "permissions", "avatar", "createdAt", "updatedAt", "password", "userType", "plexUsername", "resetPasswordGuid", "recoveryLinkExpirationDate", "movieQuotaLimit", "movieQuotaDays", "tvQuotaLimit", "tvQuotaDays", "jellyfinUsername", "jellyfinAuthToken", "jellyfinUserId", "jellyfinDeviceId", "avatarETag", "avatarVersion" FROM "user"` + ); + await queryRunner.query(`DROP TABLE "user"`); + await queryRunner.query(`ALTER TABLE "temporary_user" RENAME TO "user"`); + await queryRunner.query(`DROP INDEX "IDX_03f7958328e311761b0de675fb"`); + await queryRunner.query( + `CREATE TABLE "temporary_user_push_subscription" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "endpoint" varchar NOT NULL, "p256dh" varchar NOT NULL, "auth" varchar NOT NULL, "userId" integer, "userAgent" varchar, "createdAt" datetime DEFAULT (CURRENT_TIMESTAMP), CONSTRAINT "UQ_f90ab5a4ed54905a4bb51a7148b" UNIQUE ("auth"), CONSTRAINT "UQ_6427d07d9a171a3a1ab87480005" UNIQUE ("endpoint", "userId"), CONSTRAINT "FK_03f7958328e311761b0de675fbe" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)` + ); + await queryRunner.query( + `INSERT INTO "temporary_user_push_subscription"("id", "endpoint", "p256dh", "auth", "userId", "userAgent", "createdAt") SELECT "id", "endpoint", "p256dh", "auth", "userId", "userAgent", "createdAt" FROM "user_push_subscription"` + ); + await queryRunner.query(`DROP TABLE "user_push_subscription"`); + await queryRunner.query( + `ALTER TABLE "temporary_user_push_subscription" RENAME TO "user_push_subscription"` + ); + await queryRunner.query( + `CREATE INDEX "IDX_03f7958328e311761b0de675fb" ON "user_push_subscription" ("userId") ` + ); + await queryRunner.query(`DROP INDEX "IDX_356721a49f145aa439c16e6b99"`); + await queryRunner.query(`DROP INDEX "IDX_09b94c932e84635c5461f3c0a9"`); + await queryRunner.query( + `CREATE TABLE "temporary_blocklist" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "mediaType" varchar NOT NULL, "title" varchar, "tmdbId" integer NOT NULL, "blocklistedTags" varchar, "createdAt" datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP), "userId" integer, "mediaId" integer, CONSTRAINT "UQ_6bbafa28411e6046421991ea21c" UNIQUE ("tmdbId"), CONSTRAINT "REL_62b7ade94540f9f8d8bede54b9" UNIQUE ("mediaId"), CONSTRAINT "FK_5c8af2d0e83b3be6d250eccc19d" FOREIGN KEY ("mediaId") REFERENCES "media" ("id") ON DELETE CASCADE ON UPDATE NO ACTION, CONSTRAINT "FK_356721a49f145aa439c16e6b999" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE NO ACTION ON UPDATE NO ACTION)` + ); + await queryRunner.query( + `INSERT INTO "temporary_blocklist"("id", "mediaType", "title", "tmdbId", "blocklistedTags", "createdAt", "userId", "mediaId") SELECT "id", "mediaType", "title", "tmdbId", "blocklistedTags", "createdAt", "userId", "mediaId" FROM "blocklist"` + ); + await queryRunner.query(`DROP TABLE "blocklist"`); + await queryRunner.query( + `ALTER TABLE "temporary_blocklist" RENAME TO "blocklist"` + ); + await queryRunner.query( + `CREATE INDEX "IDX_356721a49f145aa439c16e6b99" ON "blocklist" ("userId") ` + ); + await queryRunner.query( + `CREATE INDEX "IDX_09b94c932e84635c5461f3c0a9" ON "blocklist" ("tmdbId") ` + ); + await queryRunner.query(`DROP INDEX "IDX_356721a49f145aa439c16e6b99"`); + await queryRunner.query(`DROP INDEX "IDX_09b94c932e84635c5461f3c0a9"`); + await queryRunner.query( + `CREATE TABLE "temporary_blocklist" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "mediaType" varchar NOT NULL, "title" varchar, "tmdbId" integer NOT NULL, "blocklistedTags" varchar, "createdAt" datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP), "userId" integer, "mediaId" integer, CONSTRAINT "UQ_6bbafa28411e6046421991ea21c" UNIQUE ("tmdbId"), CONSTRAINT "REL_62b7ade94540f9f8d8bede54b9" UNIQUE ("mediaId"), CONSTRAINT "UQ_81504e02db89b4c1e3152729fa6" UNIQUE ("tmdbId", "mediaType"), CONSTRAINT "FK_5c8af2d0e83b3be6d250eccc19d" FOREIGN KEY ("mediaId") REFERENCES "media" ("id") ON DELETE CASCADE ON UPDATE NO ACTION, CONSTRAINT "FK_356721a49f145aa439c16e6b999" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE NO ACTION ON UPDATE NO ACTION)` + ); + await queryRunner.query( + `INSERT INTO "temporary_blocklist"("id", "mediaType", "title", "tmdbId", "blocklistedTags", "createdAt", "userId", "mediaId") SELECT "id", "mediaType", "title", "tmdbId", "blocklistedTags", "createdAt", "userId", "mediaId" FROM "blocklist"` + ); + await queryRunner.query(`DROP TABLE "blocklist"`); + await queryRunner.query( + `ALTER TABLE "temporary_blocklist" RENAME TO "blocklist"` + ); + await queryRunner.query( + `CREATE INDEX "IDX_356721a49f145aa439c16e6b99" ON "blocklist" ("userId") ` + ); + await queryRunner.query( + `CREATE INDEX "IDX_09b94c932e84635c5461f3c0a9" ON "blocklist" ("tmdbId") ` + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP INDEX "IDX_09b94c932e84635c5461f3c0a9"`); + await queryRunner.query(`DROP INDEX "IDX_356721a49f145aa439c16e6b99"`); + await queryRunner.query( + `ALTER TABLE "blocklist" RENAME TO "temporary_blocklist"` + ); + await queryRunner.query( + `CREATE TABLE "blocklist" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "mediaType" varchar NOT NULL, "title" varchar, "tmdbId" integer NOT NULL, "blocklistedTags" varchar, "createdAt" datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP), "userId" integer, "mediaId" integer, CONSTRAINT "UQ_6bbafa28411e6046421991ea21c" UNIQUE ("tmdbId"), CONSTRAINT "REL_62b7ade94540f9f8d8bede54b9" UNIQUE ("mediaId"), CONSTRAINT "FK_5c8af2d0e83b3be6d250eccc19d" FOREIGN KEY ("mediaId") REFERENCES "media" ("id") ON DELETE CASCADE ON UPDATE NO ACTION, CONSTRAINT "FK_356721a49f145aa439c16e6b999" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE NO ACTION ON UPDATE NO ACTION)` + ); + await queryRunner.query( + `INSERT INTO "blocklist"("id", "mediaType", "title", "tmdbId", "blocklistedTags", "createdAt", "userId", "mediaId") SELECT "id", "mediaType", "title", "tmdbId", "blocklistedTags", "createdAt", "userId", "mediaId" FROM "temporary_blocklist"` + ); + await queryRunner.query(`DROP TABLE "temporary_blocklist"`); + await queryRunner.query( + `CREATE INDEX "IDX_09b94c932e84635c5461f3c0a9" ON "blocklist" ("tmdbId") ` + ); + await queryRunner.query( + `CREATE INDEX "IDX_356721a49f145aa439c16e6b99" ON "blocklist" ("userId") ` + ); + await queryRunner.query(`DROP INDEX "IDX_09b94c932e84635c5461f3c0a9"`); + await queryRunner.query(`DROP INDEX "IDX_356721a49f145aa439c16e6b99"`); + await queryRunner.query( + `ALTER TABLE "blocklist" RENAME TO "temporary_blocklist"` + ); + await queryRunner.query( + `CREATE TABLE "blocklist" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "mediaType" varchar NOT NULL, "title" varchar, "tmdbId" integer NOT NULL, "blocklistedTags" varchar, "createdAt" datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP), "userId" integer, "mediaId" integer, CONSTRAINT "UQ_6bbafa28411e6046421991ea21c" UNIQUE ("tmdbId"), CONSTRAINT "REL_62b7ade94540f9f8d8bede54b9" UNIQUE ("mediaId"), CONSTRAINT "FK_5c8af2d0e83b3be6d250eccc19d" FOREIGN KEY ("mediaId") REFERENCES "media" ("id") ON DELETE CASCADE ON UPDATE NO ACTION, CONSTRAINT "FK_356721a49f145aa439c16e6b999" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE NO ACTION ON UPDATE NO ACTION)` + ); + await queryRunner.query( + `INSERT INTO "blocklist"("id", "mediaType", "title", "tmdbId", "blocklistedTags", "createdAt", "userId", "mediaId") SELECT "id", "mediaType", "title", "tmdbId", "blocklistedTags", "createdAt", "userId", "mediaId" FROM "temporary_blocklist"` + ); + await queryRunner.query(`DROP TABLE "temporary_blocklist"`); + await queryRunner.query( + `CREATE INDEX "IDX_09b94c932e84635c5461f3c0a9" ON "blocklist" ("tmdbId") ` + ); + await queryRunner.query( + `CREATE INDEX "IDX_356721a49f145aa439c16e6b99" ON "blocklist" ("userId") ` + ); + await queryRunner.query(`DROP INDEX "IDX_03f7958328e311761b0de675fb"`); + await queryRunner.query( + `ALTER TABLE "user_push_subscription" RENAME TO "temporary_user_push_subscription"` + ); + await queryRunner.query( + `CREATE TABLE "user_push_subscription" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "endpoint" varchar NOT NULL, "p256dh" varchar NOT NULL, "auth" varchar NOT NULL, "userId" integer, "userAgent" varchar, "createdAt" datetime DEFAULT (CURRENT_TIMESTAMP), CONSTRAINT "UQ_f90ab5a4ed54905a4bb51a7148b" UNIQUE ("auth"), CONSTRAINT "UQ_6427d07d9a171a3a1ab87480005" UNIQUE ("endpoint", "userId"), CONSTRAINT "FK_03f7958328e311761b0de675fbe" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)` + ); + await queryRunner.query( + `INSERT INTO "user_push_subscription"("id", "endpoint", "p256dh", "auth", "userId", "userAgent", "createdAt") SELECT "id", "endpoint", "p256dh", "auth", "userId", "userAgent", "createdAt" FROM "temporary_user_push_subscription"` + ); + await queryRunner.query(`DROP TABLE "temporary_user_push_subscription"`); + await queryRunner.query( + `CREATE INDEX "IDX_03f7958328e311761b0de675fb" ON "user_push_subscription" ("userId") ` + ); + await queryRunner.query(`ALTER TABLE "user" RENAME TO "temporary_user"`); + await queryRunner.query( + `CREATE TABLE "user" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "email" varchar NOT NULL, "username" varchar, "plexId" integer, "plexToken" varchar, "permissions" integer NOT NULL DEFAULT (0), "avatar" varchar NOT NULL, "createdAt" datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP), "updatedAt" datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP), "password" varchar, "userType" integer NOT NULL DEFAULT (1), "plexUsername" varchar, "resetPasswordGuid" varchar, "recoveryLinkExpirationDate" datetime, "movieQuotaLimit" integer, "movieQuotaDays" integer, "tvQuotaLimit" integer, "tvQuotaDays" integer, "jellyfinUsername" varchar, "jellyfinAuthToken" varchar, "jellyfinUserId" varchar, "jellyfinDeviceId" varchar, "avatarETag" varchar, "avatarVersion" varchar, CONSTRAINT "UQ_e12875dfb3b1d92d7d7c5377e22" UNIQUE ("email"))` + ); + await queryRunner.query( + `INSERT INTO "user"("id", "email", "username", "plexId", "plexToken", "permissions", "avatar", "createdAt", "updatedAt", "password", "userType", "plexUsername", "resetPasswordGuid", "recoveryLinkExpirationDate", "movieQuotaLimit", "movieQuotaDays", "tvQuotaLimit", "tvQuotaDays", "jellyfinUsername", "jellyfinAuthToken", "jellyfinUserId", "jellyfinDeviceId", "avatarETag", "avatarVersion") SELECT "id", "email", "username", "plexId", "plexToken", "permissions", "avatar", "createdAt", "updatedAt", "password", "userType", "plexUsername", "resetPasswordGuid", "recoveryLinkExpirationDate", "movieQuotaLimit", "movieQuotaDays", "tvQuotaLimit", "tvQuotaDays", "jellyfinUsername", "jellyfinAuthToken", "jellyfinUserId", "jellyfinDeviceId", "avatarETag", "avatarVersion" FROM "temporary_user"` + ); + await queryRunner.query(`DROP TABLE "temporary_user"`); + await queryRunner.query(`DROP INDEX "IDX_09b94c932e84635c5461f3c0a9"`); + await queryRunner.query(`DROP INDEX "IDX_356721a49f145aa439c16e6b99"`); + await queryRunner.query( + `ALTER TABLE "blocklist" RENAME TO "temporary_blocklist"` + ); + await queryRunner.query( + `CREATE TABLE "blocklist" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "mediaType" varchar NOT NULL, "title" varchar, "tmdbId" integer NOT NULL, "blocklistedTags" varchar, "createdAt" datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP), "userId" integer, "mediaId" integer, CONSTRAINT "UQ_6bbafa28411e6046421991ea21c" UNIQUE ("tmdbId"), CONSTRAINT "REL_62b7ade94540f9f8d8bede54b9" UNIQUE ("mediaId"), CONSTRAINT "FK_5c8af2d0e83b3be6d250eccc19d" FOREIGN KEY ("mediaId") REFERENCES "media" ("id") ON DELETE CASCADE ON UPDATE NO ACTION, CONSTRAINT "FK_356721a49f145aa439c16e6b999" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE NO ACTION ON UPDATE NO ACTION)` + ); + await queryRunner.query( + `INSERT INTO "blocklist"("id", "mediaType", "title", "tmdbId", "blocklistedTags", "createdAt", "userId", "mediaId") SELECT "id", "mediaType", "title", "tmdbId", "blocklistedTags", "createdAt", "userId", "mediaId" FROM "temporary_blocklist"` + ); + await queryRunner.query(`DROP TABLE "temporary_blocklist"`); + await queryRunner.query( + `CREATE INDEX "IDX_09b94c932e84635c5461f3c0a9" ON "blocklist" ("tmdbId") ` + ); + await queryRunner.query( + `CREATE INDEX "IDX_356721a49f145aa439c16e6b99" ON "blocklist" ("userId") ` + ); + await queryRunner.query(`DROP INDEX "IDX_03f7958328e311761b0de675fb"`); + await queryRunner.query( + `ALTER TABLE "user_push_subscription" RENAME TO "temporary_user_push_subscription"` + ); + await queryRunner.query( + `CREATE TABLE "user_push_subscription" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "endpoint" varchar NOT NULL, "p256dh" varchar NOT NULL, "auth" varchar NOT NULL, "userId" integer, "userAgent" varchar, "createdAt" datetime DEFAULT (CURRENT_TIMESTAMP), CONSTRAINT "UQ_f90ab5a4ed54905a4bb51a7148b" UNIQUE ("auth"), CONSTRAINT "UQ_6427d07d9a171a3a1ab87480005" UNIQUE ("endpoint", "userId"), CONSTRAINT "FK_03f7958328e311761b0de675fbe" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)` + ); + await queryRunner.query( + `INSERT INTO "user_push_subscription"("id", "endpoint", "p256dh", "auth", "userId", "userAgent", "createdAt") SELECT "id", "endpoint", "p256dh", "auth", "userId", "userAgent", "createdAt" FROM "temporary_user_push_subscription"` + ); + await queryRunner.query(`DROP TABLE "temporary_user_push_subscription"`); + await queryRunner.query( + `CREATE INDEX "IDX_03f7958328e311761b0de675fb" ON "user_push_subscription" ("userId") ` + ); + } +} diff --git a/server/routes/auth.ts b/server/routes/auth.ts index f625e68f0e..d7c8dc326c 100644 --- a/server/routes/auth.ts +++ b/server/routes/auth.ts @@ -84,6 +84,7 @@ authRoutes.post('/plex', async (req, res, next) => { email: account.email, plexUsername: account.username, plexId: account.id, + plexUuid: account.uuid, plexToken: account.authToken, permissions: Permission.ADMIN, avatar: account.thumb, @@ -174,6 +175,7 @@ authRoutes.post('/plex', async (req, res, next) => { email: account.email, plexUsername: account.username, plexId: account.id, + plexUuid: account.uuid, plexToken: account.authToken, permissions: settings.main.defaultPermissions, avatar: account.thumb, diff --git a/server/routes/discover.ts b/server/routes/discover.ts index 7f250e6a6e..6bad349958 100644 --- a/server/routes/discover.ts +++ b/server/routes/discover.ts @@ -929,7 +929,7 @@ discoverRoutes.get, WatchlistResponse>( const activeUser = await userRepository.findOne({ where: { id: req.user?.id }, - select: ['id', 'plexToken'], + select: ['id', 'plexToken', 'plexUuid'], }); if (activeUser && !activeUser?.plexToken) { @@ -961,11 +961,21 @@ discoverRoutes.get, WatchlistResponse>( results: [], }); } + if (!activeUser?.plexUuid) { + // We will just return an empty array if the user has no Plex UUID + return res.json({ + page: 1, + totalPages: 1, + totalResults: 0, + results: [], + }); + } // List watchlist from Plex const plexTV = new PlexTvAPI(activeUser.plexToken); - const watchlist = await plexTV.getWatchlist({ offset }); + // TODO: Reimplement offset-like pagination with endCursor + const watchlist = await plexTV.getWatchlist(activeUser.plexUuid); return res.json({ page, diff --git a/server/routes/user/index.ts b/server/routes/user/index.ts index 663945027a..0dafcddb01 100644 --- a/server/routes/user/index.ts +++ b/server/routes/user/index.ts @@ -670,9 +670,9 @@ router.post( }); const mainPlexTv = new PlexTvAPI(mainUser.plexToken ?? ''); - const plexUsersResponse = await mainPlexTv.getUsers(); + const plexHomeUsersResponse = await mainPlexTv.getHomeUsers(); const createdUsers: User[] = []; - for (const rawUser of plexUsersResponse.MediaContainer.User) { + for (const rawUser of plexHomeUsersResponse.MediaContainer.User) { const account = rawUser.$; if (account.email) { @@ -703,6 +703,7 @@ router.post( email: account.email, permissions: settings.main.defaultPermissions, plexId: parseInt(account.id), + plexUuid: account.uuid, plexToken: '', avatar: account.thumb, userType: UserType.PLEX, @@ -953,7 +954,7 @@ router.get<{ id: string }, WatchlistResponse>( const user = await getRepository(User).findOneOrFail({ where: { id: Number(req.params.id) }, - select: ['id', 'plexToken'], + select: ['id', 'plexToken', 'plexUuid'], }); if (user) { @@ -986,9 +987,20 @@ router.get<{ id: string }, WatchlistResponse>( }); } + // We will just return an empty array if the user has no Plex UUID + if (!user.plexUuid) { + return res.json({ + page: 1, + totalPages: 1, + totalResults: 0, + results: [], + }); + } + const plexTV = new PlexTvAPI(user.plexToken); - const watchlist = await plexTV.getWatchlist({ offset }); + // TODO: Reimplement offset-like pagination with endCursor + const watchlist = await plexTV.getWatchlist(user.plexUuid); return res.json({ page, diff --git a/server/routes/user/usersettings.ts b/server/routes/user/usersettings.ts index 784f7b5fd8..b6c2caa153 100644 --- a/server/routes/user/usersettings.ts +++ b/server/routes/user/usersettings.ts @@ -304,6 +304,7 @@ userSettingsRoutes.post<{ authToken: string }>( // valid plex user found, link to current user user.userType = UserType.PLEX; user.plexId = account.id; + user.plexUuid = account.uuid; user.plexUsername = account.username; user.plexToken = account.authToken; await userRepository.save(user); From 315ed09742626e627f684ca477ee9186fc1ebb96 Mon Sep 17 00:00:00 2001 From: xNul <894305+xNul@users.noreply.github.com> Date: Tue, 28 Apr 2026 01:31:46 +0000 Subject: [PATCH 2/6] fix: fix migration for GraphQL-based Plex Watchlist Sync GraphQL-based Plex Watchlist Sync requires usage of Plex UUIDs. Migration from older versions would result in a new User.plexUuid column, but filled with NULL values. Now, migration from older versions will automatically populate the User.plexUuid column where applicable. --- server/lib/watchlistsync.ts | 35 +++++++++++++++++++++++++++++++++++ server/utils/seedTestDb.ts | 2 ++ 2 files changed, 37 insertions(+) diff --git a/server/lib/watchlistsync.ts b/server/lib/watchlistsync.ts index f6d1b473b7..1a02094ddf 100644 --- a/server/lib/watchlistsync.ts +++ b/server/lib/watchlistsync.ts @@ -25,6 +25,41 @@ class WatchlistSync { where: { id: 1 }, }); + // Old imported plex users may not have plex uuids stored in the db + const users = await userRepository + .createQueryBuilder('user') + .where('user.userType = :userType', { userType: UserType.PLEX }) + .andWhere('user.plexUuid IS NULL') + .getMany(); + + if (users.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.$; + + if (account.email) { + 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') diff --git a/server/utils/seedTestDb.ts b/server/utils/seedTestDb.ts index 266169d45c..412666b95e 100644 --- a/server/utils/seedTestDb.ts +++ b/server/utils/seedTestDb.ts @@ -34,6 +34,7 @@ async function seedTestUsers(): Promise { })) ?? new User(); user.plexId = admin?.plexId ?? 1; user.plexToken = '1234'; + user.plexUuid = 'a63j9ad8'; user.plexUsername = 'admin'; user.username = 'admin'; user.email = 'admin@seerr.dev'; @@ -50,6 +51,7 @@ async function seedTestUsers(): Promise { })) ?? new User(); otherUser.plexId = admin?.plexId ?? 1; otherUser.plexToken = '1234'; + otherUser.plexUuid = 'a63j9ad8'; otherUser.plexUsername = 'friend'; otherUser.username = 'friend'; otherUser.email = 'friend@seerr.dev'; From 480ffe90b04018ef132f7d4156bfd0d5c1571a01 Mon Sep 17 00:00:00 2001 From: xNul <894305+xNul@users.noreply.github.com> Date: Tue, 28 Apr 2026 02:11:04 +0000 Subject: [PATCH 3/6] fix: fix minor const bug --- server/lib/watchlistsync.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/server/lib/watchlistsync.ts b/server/lib/watchlistsync.ts index 1a02094ddf..735b8311f2 100644 --- a/server/lib/watchlistsync.ts +++ b/server/lib/watchlistsync.ts @@ -26,13 +26,13 @@ class WatchlistSync { }); // Old imported plex users may not have plex uuids stored in the db - const users = await userRepository + const nullUsers = await userRepository .createQueryBuilder('user') .where('user.userType = :userType', { userType: UserType.PLEX }) .andWhere('user.plexUuid IS NULL') .getMany(); - if (users.length > 0) { + if (nullUsers.length > 0) { logger.warn( 'Found plex users without assigned uuids. Obtaining corresponding uuids.', { From 27cbe73d362e1e860a4f12c4550fc47431fee8c3 Mon Sep 17 00:00:00 2001 From: xNul <894305+xNul@users.noreply.github.com> Date: Sun, 10 May 2026 00:04:55 +0000 Subject: [PATCH 4/6] feat: add access check to Plex Watchlist Sync user settings Plex Watchlist Sync for a user may result in an access denied error. As a preventative measure and to minimize GQL requests, a check is now performed on the enabling of Plex Watchlist Sync for a user to validate access. Additionally, if access to a user's Plex Watchlist is denied in the course of normal operations, the user's Plex Watchlist Sync settings will be disabled. --- server/api/plextv.ts | 10 ++-- server/constants/error.ts | 1 + server/lib/watchlistsync.ts | 22 ++++++++- server/routes/user/usersettings.ts | 47 +++++++++++++++++++ .../UserGeneralSettings/index.tsx | 11 +++++ 5 files changed, 84 insertions(+), 7 deletions(-) diff --git a/server/api/plextv.ts b/server/api/plextv.ts index 3293555586..ff66542284 100644 --- a/server/api/plextv.ts +++ b/server/api/plextv.ts @@ -114,7 +114,7 @@ interface HomeUsersResponse { } interface WatchlistResponse { - data: { + data?: { user: { watchlist: { nodes: { @@ -126,7 +126,7 @@ interface WatchlistResponse { }; }; }; - }; + } | null; } type PlexMetadataItem = { @@ -362,7 +362,7 @@ class PlexTvAPI extends ExternalAPI { } const watchlistDetails = await Promise.all( - (cachedWatchlist?.response.data.user.watchlist.nodes ?? []).map( + (cachedWatchlist?.response.data?.user.watchlist.nodes ?? []).map( async (watchlistItem) => { let detailedResponse: MetadataResponse; try { @@ -426,9 +426,9 @@ class PlexTvAPI extends ExternalAPI { first, after, endCursor: - cachedWatchlist?.response.data.user.watchlist.pageInfo.endCursor, + cachedWatchlist?.response.data?.user.watchlist.pageInfo.endCursor, totalSize: - cachedWatchlist?.response.data.user.watchlist.nodes.length ?? 0, + cachedWatchlist?.response.data?.user.watchlist.nodes.length ?? 0, items: filteredList, }; } catch (e) { diff --git a/server/constants/error.ts b/server/constants/error.ts index daa02f1a1c..29e4537c03 100644 --- a/server/constants/error.ts +++ b/server/constants/error.ts @@ -9,4 +9,5 @@ export enum ApiErrorCode { SyncErrorNoLibraries = 'SYNC_ERROR_NO_LIBRARIES', Unauthorized = 'UNAUTHORIZED', Unknown = 'UNKNOWN', + WatchlistAccessDenied = 'WATCHLIST_ACCESS_DENIED', } diff --git a/server/lib/watchlistsync.ts b/server/lib/watchlistsync.ts index 735b8311f2..bbac2df942 100644 --- a/server/lib/watchlistsync.ts +++ b/server/lib/watchlistsync.ts @@ -19,7 +19,7 @@ class WatchlistSync { public async syncWatchlist() { const userRepository = getRepository(User); - // taken from auth.ts + // Taken from auth.ts const mainUser = await userRepository.findOneOrFail({ select: { id: true, plexToken: true }, where: { id: 1 }, @@ -104,13 +104,31 @@ class WatchlistSync { return; } - // token sync if the user has a token, else fallback to sync using the main user's token + // 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 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, response.items.map((i) => ({ diff --git a/server/routes/user/usersettings.ts b/server/routes/user/usersettings.ts index b6c2caa153..8c459f5466 100644 --- a/server/routes/user/usersettings.ts +++ b/server/routes/user/usersettings.ts @@ -82,6 +82,18 @@ userSettingsRoutes.post< where: { id: Number(req.params.id) }, }); + if (user) { + const hiddenFields = await userRepository.findOne({ + select: { id: true, plexToken: true, plexUuid: true }, + where: { id: Number(req.params.id) }, + }); + + if (hiddenFields) { + user.plexToken = hiddenFields.plexToken; + user.plexUuid = hiddenFields.plexUuid; + } + } + if (!user) { return next({ status: 404, message: 'User not found.' }); } @@ -119,6 +131,41 @@ userSettingsRoutes.post< user.tvQuotaLimit = req.body.tvQuotaLimit; } + // Cannot enable Plex Watchlist Sync without access + if (req.body.watchlistSyncMovies || req.body.watchlistSyncTv) { + // Taken from auth.ts + const mainUser = await userRepository.findOneOrFail({ + select: { id: true, plexToken: true }, + where: { id: 1 }, + }); + + const plexTvApi = user.plexToken + ? new PlexTvAPI(user.plexToken) + : new PlexTvAPI(mainUser.plexToken ?? ''); + + // If a user has updated from an old version of Seerr and has not run the + // plexwatchlistsync job at least once, there may be a null value in the plexUuid column + if (!user.plexUuid) { + const plexHomeUsersResponse = await plexTvApi.getHomeUsers(); + for (const rawUser of plexHomeUsersResponse.MediaContainer.User) { + const account = rawUser.$; + + if (account.email && Number(account.id) === user.plexId) { + user.plexUuid = account.uuid; + await userRepository.save(user); + break; + } + } + } + + const response = await plexTvApi.getWatchlist(user.plexUuid ?? ''); + + // endCursor will be undefined if the GQL query fails to return Watchlist data + if (response.endCursor === undefined) { + throw new ApiError(400, ApiErrorCode.WatchlistAccessDenied); + } + } + if (!user.settings) { user.settings = new UserSettings({ user: req.user, diff --git a/src/components/UserProfile/UserSettings/UserGeneralSettings/index.tsx b/src/components/UserProfile/UserSettings/UserGeneralSettings/index.tsx index 525dabb07d..9c7aaebc32 100644 --- a/src/components/UserProfile/UserSettings/UserGeneralSettings/index.tsx +++ b/src/components/UserProfile/UserSettings/UserGeneralSettings/index.tsx @@ -48,6 +48,7 @@ const messages = defineMessages( toastSettingsFailureEmail: 'This email is already taken!', toastSettingsFailureEmailEmpty: 'Another user already has this username. You must set an email', + toastSettingsWatchlistAccessDenied: "User's watchlist is not accessible.", region: 'Discover Region', regionTip: 'Filter content by regional availability', discoverRegion: 'Discover Region', @@ -224,6 +225,16 @@ const UserGeneralSettings = () => { } ); } + } else if ( + e?.response?.data?.message === ApiErrorCode.WatchlistAccessDenied + ) { + addToast( + intl.formatMessage(messages.toastSettingsWatchlistAccessDenied), + { + autoDismiss: true, + appearance: 'error', + } + ); } else { addToast(intl.formatMessage(messages.toastSettingsFailure), { autoDismiss: true, From 90c712d38a56e06068b465fa567f53490a27a1ab Mon Sep 17 00:00:00 2001 From: xNul <894305+xNul@users.noreply.github.com> Date: Sun, 10 May 2026 06:26:45 +0000 Subject: [PATCH 5/6] fix: miscellaneous fixes and docs --- docs/using-seerr/plex/watchlist-auto-request.md | 11 +++++++++-- server/api/plextv.ts | 2 +- server/lib/watchlistsync.ts | 16 +++++++--------- server/routes/auth.ts | 5 +++++ server/routes/user/index.ts | 1 + server/routes/user/usersettings.ts | 2 +- server/utils/seedTestDb.ts | 2 +- src/i18n/locale/en.json | 1 + 8 files changed, 26 insertions(+), 14 deletions(-) diff --git a/docs/using-seerr/plex/watchlist-auto-request.md b/docs/using-seerr/plex/watchlist-auto-request.md index 5c5e6bedae..b4563ab0cb 100644 --- a/docs/using-seerr/plex/watchlist-auto-request.md +++ b/docs/using-seerr/plex/watchlist-auto-request.md @@ -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 @@ -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 Not Accessible +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 prerequisite #1. +::: + ## How It Works Once properly configured, Seerr will: @@ -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 diff --git a/server/api/plextv.ts b/server/api/plextv.ts index ff66542284..5c0e70f426 100644 --- a/server/api/plextv.ts +++ b/server/api/plextv.ts @@ -390,7 +390,7 @@ class PlexTvAPI extends ExternalAPI { 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; diff --git a/server/lib/watchlistsync.ts b/server/lib/watchlistsync.ts index bbac2df942..3ef89fbf4c 100644 --- a/server/lib/watchlistsync.ts +++ b/server/lib/watchlistsync.ts @@ -46,16 +46,14 @@ class WatchlistSync { for (const rawUser of plexHomeUsersResponse.MediaContainer.User) { const account = rawUser.$; - if (account.email) { - const user = await userRepository - .createQueryBuilder('user') - .where('user.plexId = :id', { id: account.id }) - .getOne(); + const user = await userRepository + .createQueryBuilder('user') + .where('user.plexId = :id', { id: account.id }) + .getOne(); - if (user) { - user.plexUuid = account.uuid; - await userRepository.save(user); - } + if (user) { + user.plexUuid = account.uuid; + await userRepository.save(user); } } } diff --git a/server/routes/auth.ts b/server/routes/auth.ts index d7c8dc326c..dd04f386b5 100644 --- a/server/routes/auth.ts +++ b/server/routes/auth.ts @@ -132,6 +132,7 @@ authRoutes.post('/plex', async (req, res, next) => { email: user.email, userId: user.id, plexId: account.id, + plexUuid: account.uuid, plexUsername: account.username, } ); @@ -139,6 +140,7 @@ authRoutes.post('/plex', async (req, res, next) => { user.plexToken = body.authToken; user.plexId = account.id; + user.plexUuid = account.uuid; user.avatar = account.thumb; user.email = account.email; user.plexUsername = account.username; @@ -153,6 +155,7 @@ authRoutes.post('/plex', async (req, res, next) => { ip: req.ip, email: account.email, plexId: account.id, + plexUuid: account.uuid, plexUsername: account.username, } ); @@ -168,6 +171,7 @@ authRoutes.post('/plex', async (req, res, next) => { ip: req.ip, email: account.email, plexId: account.id, + plexUuid: account.uuid, plexUsername: account.username, } ); @@ -192,6 +196,7 @@ authRoutes.post('/plex', async (req, res, next) => { ip: req.ip, email: account.email, plexId: account.id, + plexUuid: account.uuid, plexUsername: account.username, } ); diff --git a/server/routes/user/index.ts b/server/routes/user/index.ts index 0dafcddb01..82ddaf28af 100644 --- a/server/routes/user/index.ts +++ b/server/routes/user/index.ts @@ -689,6 +689,7 @@ router.post( user.avatar = account.thumb; user.email = account.email; user.plexUsername = account.username; + user.plexUuid = account.uuid; // In case the user was previously a local account if (user.userType === UserType.LOCAL) { diff --git a/server/routes/user/usersettings.ts b/server/routes/user/usersettings.ts index 8c459f5466..35b1225489 100644 --- a/server/routes/user/usersettings.ts +++ b/server/routes/user/usersettings.ts @@ -150,7 +150,7 @@ userSettingsRoutes.post< for (const rawUser of plexHomeUsersResponse.MediaContainer.User) { const account = rawUser.$; - if (account.email && Number(account.id) === user.plexId) { + if (Number(account.id) === user.plexId) { user.plexUuid = account.uuid; await userRepository.save(user); break; diff --git a/server/utils/seedTestDb.ts b/server/utils/seedTestDb.ts index 412666b95e..84e8ab03e8 100644 --- a/server/utils/seedTestDb.ts +++ b/server/utils/seedTestDb.ts @@ -51,7 +51,7 @@ async function seedTestUsers(): Promise { })) ?? new User(); otherUser.plexId = admin?.plexId ?? 1; otherUser.plexToken = '1234'; - otherUser.plexUuid = 'a63j9ad8'; + otherUser.plexUuid = '09kdca49'; otherUser.plexUsername = 'friend'; otherUser.username = 'friend'; otherUser.email = 'friend@seerr.dev'; diff --git a/src/i18n/locale/en.json b/src/i18n/locale/en.json index 31ef033898..1c556965f3 100644 --- a/src/i18n/locale/en.json +++ b/src/i18n/locale/en.json @@ -1483,6 +1483,7 @@ "components.UserProfile.UserSettings.UserGeneralSettings.toastSettingsFailureEmail": "This email is already taken!", "components.UserProfile.UserSettings.UserGeneralSettings.toastSettingsFailureEmailEmpty": "Another user already has this username. You must set an email", "components.UserProfile.UserSettings.UserGeneralSettings.toastSettingsSuccess": "Settings saved successfully!", + "components.UserProfile.UserSettings.UserGeneralSettings.toastSettingsWatchlistAccessDenied": "User's watchlist is not accessible.", "components.UserProfile.UserSettings.UserGeneralSettings.user": "User", "components.UserProfile.UserSettings.UserGeneralSettings.validationDiscordId": "You must provide a valid Discord user ID", "components.UserProfile.UserSettings.UserGeneralSettings.validationemailformat": "Valid email required", From fa801fe6327c4606642ab38ab0d6ad6125db2762 Mon Sep 17 00:00:00 2001 From: xNul <894305+xNul@users.noreply.github.com> Date: Sun, 10 May 2026 06:42:10 +0000 Subject: [PATCH 6/6] fix: miscellaneous fixes and docs --- docs/using-seerr/plex/watchlist-auto-request.md | 4 ++-- server/api/plextv.ts | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/using-seerr/plex/watchlist-auto-request.md b/docs/using-seerr/plex/watchlist-auto-request.md index b4563ab0cb..83b57f7b76 100644 --- a/docs/using-seerr/plex/watchlist-auto-request.md +++ b/docs/using-seerr/plex/watchlist-auto-request.md @@ -60,8 +60,8 @@ 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 Not Accessible -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 prerequisite #1. +:::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 diff --git a/server/api/plextv.ts b/server/api/plextv.ts index 5c0e70f426..01baaf1d15 100644 --- a/server/api/plextv.ts +++ b/server/api/plextv.ts @@ -308,10 +308,10 @@ class PlexTvAPI extends ExternalAPI { { first = 20, after = undefined, - }: { first?: number; after?: number | undefined } = {} + }: { first?: number; after?: string | undefined } = {} ): Promise<{ first: number; - after: number | undefined; + after: string | undefined; endCursor: string | undefined; totalSize: number; items: PlexWatchlistItem[];