diff --git a/docs/using-seerr/plex/watchlist-auto-request.md b/docs/using-seerr/plex/watchlist-auto-request.md index 5c5e6bedae..83b57f7b76 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 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: @@ -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 b8f3c9a47b..01baaf1d15 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; + }; + }; + }; + } | null; +} + 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?: string | undefined } = {} + ): Promise<{ + first: number; + after: string | 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; @@ -340,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; @@ -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/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/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..3ef89fbf4c 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,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, }); @@ -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, 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..dd04f386b5 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, @@ -131,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, } ); @@ -138,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; @@ -152,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, } ); @@ -167,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, } ); @@ -174,6 +179,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, @@ -190,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/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..82ddaf28af 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) { @@ -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) { @@ -703,6 +704,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 +955,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 +988,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..35b1225489 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 (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, @@ -304,6 +351,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); diff --git a/server/utils/seedTestDb.ts b/server/utils/seedTestDb.ts index 266169d45c..84e8ab03e8 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 = '09kdca49'; otherUser.plexUsername = 'friend'; otherUser.username = 'friend'; otherUser.email = 'friend@seerr.dev'; 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, 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",