From 928cd5b999d5f4567d7c801f44ad71859c0ce13e Mon Sep 17 00:00:00 2001 From: Ryan Cohen Date: Tue, 2 Aug 2022 06:02:40 +0000 Subject: [PATCH] feat: watchlist sync integration --- overseerr-api.yml | 25 +++++ server/api/plextv.ts | 103 +++++++++++++++++--- server/interfaces/api/discoverInterfaces.ts | 7 ++ server/lib/cache.ts | 7 +- server/routes/discover.ts | 34 ++++++- src/components/Discover/index.tsx | 32 ++++++ 6 files changed, 193 insertions(+), 15 deletions(-) diff --git a/overseerr-api.yml b/overseerr-api.yml index b6fb50fbb..25e262897 100644 --- a/overseerr-api.yml +++ b/overseerr-api.yml @@ -4403,6 +4403,31 @@ paths: name: type: string example: Genre Name + /discover/watchlist: + get: + summary: Get the Plex watchlist. + tags: + - search + responses: + '200': + description: Watchlist data returned + content: + application/json: + schema: + type: array + items: + type: object + properties: + id: + type: number + example: 1 + backdrops: + type: array + items: + type: string + name: + type: string + example: Genre Name /request: get: summary: Get all requests diff --git a/server/api/plextv.ts b/server/api/plextv.ts index a90957551..33a26e730 100644 --- a/server/api/plextv.ts +++ b/server/api/plextv.ts @@ -1,9 +1,9 @@ -import type { AxiosInstance } from 'axios'; -import axios from 'axios'; import xml2js from 'xml2js'; import type { PlexDevice } from '../interfaces/api/plexInterfaces'; +import cacheManager from '../lib/cache'; import { getSettings } from '../lib/settings'; import logger from '../logger'; +import ExternalAPI from './externalapi'; interface PlexAccountResponse { user: PlexUser; @@ -112,20 +112,52 @@ interface UsersResponse { }; } -class PlexTvAPI { +interface WatchlistResponse { + MediaContainer: { + Metadata: { + ratingKey: string; + }[]; + }; +} + +interface MetadataResponse { + MediaContainer: { + Metadata: { + ratingKey: string; + type: 'movie' | 'tv'; + title: string; + Guid: { + id: `imdb://tt${number}` | `tmdb://${number}` | `tvdb://${number}`; + }[]; + }[]; + }; +} + +export interface PlexWatchlistItem { + ratingKey: string; + tmdbId: number; + type: 'movie' | 'tv'; + title: string; +} + +class PlexTvAPI extends ExternalAPI { private authToken: string; - private axios: AxiosInstance; constructor(authToken: string) { + super( + 'https://plex.tv', + {}, + { + headers: { + 'X-Plex-Token': authToken, + 'Content-Type': 'application/json', + Accept: 'application/json', + }, + nodeCache: cacheManager.getCache('plextv').data, + } + ); + this.authToken = authToken; - this.axios = axios.create({ - baseURL: 'https://plex.tv', - headers: { - 'X-Plex-Token': this.authToken, - 'Content-Type': 'application/json', - Accept: 'application/json', - }, - }); } public async getDevices(): Promise { @@ -253,6 +285,53 @@ class PlexTvAPI { )) as UsersResponse; return parsedXml; } + + public async getWatchlist(): Promise { + try { + const response = await this.axios.get( + '/library/sections/watchlist/all', + { + baseURL: 'https://metadata.provider.plex.tv', + } + ); + + const watchlistDetails = await Promise.all( + response.data.MediaContainer.Metadata.map(async (watchlistItem) => { + const detailedResponse = await this.getRolling( + `/library/metadata/${watchlistItem.ratingKey}`, + { + baseURL: 'https://metadata.provider.plex.tv', + } + ); + + const metadata = detailedResponse.MediaContainer.Metadata[0]; + + const tmdbString = metadata.Guid.find((guid) => + guid.id.startsWith('tmdb') + ); + + return { + ratingKey: metadata.ratingKey, + // This should always be set? But I guess it also cannot be? + // We will filter out the 0's afterwards + tmdbId: tmdbString ? Number(tmdbString.id.split('//')[1]) : 0, + title: metadata.title, + type: metadata.type, + }; + }) + ); + + const filteredList = watchlistDetails.filter((detail) => detail.tmdbId); + + return filteredList; + } catch (e) { + logger.error('Failed to retrieve watchlist items', { + label: 'Plex.TV Metadata API', + errorMessage: e.message, + }); + return []; + } + } } export default PlexTvAPI; diff --git a/server/interfaces/api/discoverInterfaces.ts b/server/interfaces/api/discoverInterfaces.ts index db90e55d2..c91b9515c 100644 --- a/server/interfaces/api/discoverInterfaces.ts +++ b/server/interfaces/api/discoverInterfaces.ts @@ -3,3 +3,10 @@ export interface GenreSliderItem { name: string; backdrops: string[]; } + +export interface WatchlistItem { + ratingKey: string; + tmdbId: number; + type: 'movie' | 'tv'; + title: string; +} diff --git a/server/lib/cache.ts b/server/lib/cache.ts index 7782a05a8..e81466629 100644 --- a/server/lib/cache.ts +++ b/server/lib/cache.ts @@ -6,7 +6,8 @@ export type AvailableCacheIds = | 'sonarr' | 'rt' | 'github' - | 'plexguid'; + | 'plexguid' + | 'plextv'; const DEFAULT_TTL = 300; const DEFAULT_CHECK_PERIOD = 120; @@ -58,6 +59,10 @@ class CacheManager { stdTtl: 86400 * 7, // 1 week cache checkPeriod: 60 * 30, }), + plextv: new Cache('plextv', 'Plex TV', { + stdTtl: 86400 * 7, // 1 week cache + checkPeriod: 60, + }), }; public getCache(id: AvailableCacheIds): Cache { diff --git a/server/routes/discover.ts b/server/routes/discover.ts index e0b8f78ed..789ca844d 100644 --- a/server/routes/discover.ts +++ b/server/routes/discover.ts @@ -1,10 +1,15 @@ import { Router } from 'express'; import { sortBy } from 'lodash'; +import { getRepository } from 'typeorm'; +import PlexTvAPI from '../api/plextv'; import TheMovieDb from '../api/themoviedb'; import { MediaType } from '../constants/media'; import Media from '../entity/Media'; -import type { User } from '../entity/User'; -import type { GenreSliderItem } from '../interfaces/api/discoverInterfaces'; +import { User } from '../entity/User'; +import type { + GenreSliderItem, + WatchlistItem, +} from '../interfaces/api/discoverInterfaces'; import { getSettings } from '../lib/settings'; import logger from '../logger'; import { mapProductionCompany } from '../models/Movie'; @@ -704,4 +709,29 @@ discoverRoutes.get<{ language: string }, GenreSliderItem[]>( } ); +discoverRoutes.get( + '/watchlist', + async (req, res, next) => { + const userRepository = getRepository(User); + + const activeUser = await userRepository.findOne({ + where: { id: req.user?.id }, + select: ['id', 'plexToken'], + }); + + if (!activeUser?.plexToken) { + return next({ + status: 500, + message: 'Must be a Plex account to use watchlist feature.', + }); + } + + const plexTV = new PlexTvAPI(activeUser?.plexToken); + + const watchlist = await plexTV.getWatchlist(); + + return res.json(watchlist); + } +); + export default discoverRoutes; diff --git a/src/components/Discover/index.tsx b/src/components/Discover/index.tsx index 8e19bd0b3..6922cb849 100644 --- a/src/components/Discover/index.tsx +++ b/src/components/Discover/index.tsx @@ -2,6 +2,7 @@ import { ArrowCircleRightIcon } from '@heroicons/react/outline'; import Link from 'next/link'; import { defineMessages, useIntl } from 'react-intl'; import useSWR from 'swr'; +import { WatchlistItem } from '../../../server/interfaces/api/discoverInterfaces'; import type { MediaResultsResponse } from '../../../server/interfaces/api/mediaInterfaces'; import type { RequestResultsResponse } from '../../../server/interfaces/api/requestInterfaces'; import { Permission, useUser } from '../../hooks/useUser'; @@ -44,6 +45,12 @@ const Discover = () => { } ); + const { data: watchlistItems, error: watchlistError } = useSWR< + WatchlistItem[] + >('/api/v1/discover/watchlist', { + revalidateOnMount: true, + }); + return ( <> @@ -93,6 +100,31 @@ const Discover = () => { placeholder={} emptyMessage={intl.formatMessage(messages.noRequests)} /> +
+
+ Plex Watchlist +
+
+ {!( + !!watchlistItems && + !watchlistError && + watchlistItems.length === 0 + ) && ( + ( + + ))} + /> + )}