From b4191f9c65b7ff08764e61d18e7a75bc8d4b3325 Mon Sep 17 00:00:00 2001 From: Marco Faggian Date: Fri, 28 Jul 2023 13:51:19 +0200 Subject: [PATCH] feat(rating): added IMDB Radarr proxy (#3496) * feat(rating): added imdb radarr proxy Signed-off-by: marcofaggian * refactor(rating/imdb): rm export unused interfaces Signed-off-by: marcofaggian * docs(rating/imdb): rt to imdb Signed-off-by: marcofaggian * refactor(rating/imdb): specified error message Signed-off-by: marcofaggian * refactor(rating/imdb): rm line break Signed-off-by: marcofaggian * refactor(rating): conform to types patter Signed-off-by: marcofaggian * chore(rating/imdb): added line to translation file Signed-off-by: marcofaggian * feat(rating/imdb): ratings to ratingscombined Signed-off-by: marcofaggian * fix(rating/imdb): reinstating ratings route Signed-off-by: marcofaggian * docs(ratings): openapi ratings Signed-off-by: marcofaggian * chore(ratings): undo openapi ratings apex Signed-off-by: marcofaggian --------- Signed-off-by: marcofaggian --- overseerr-api.yml | 57 +++++++ server/api/rating/imdbRadarrProxy.ts | 195 ++++++++++++++++++++++ server/api/{ => rating}/rottentomatoes.ts | 8 +- server/api/ratings.ts | 7 + server/lib/cache.ts | 5 + server/routes/movie.ts | 56 ++++++- server/routes/tv.ts | 2 +- src/components/MovieDetails/index.tsx | 90 ++++++---- src/components/TvDetails/index.tsx | 2 +- src/i18n/locale/en.json | 1 + 10 files changed, 384 insertions(+), 39 deletions(-) create mode 100644 server/api/rating/imdbRadarrProxy.ts rename server/api/{ => rating}/rottentomatoes.ts (93%) create mode 100644 server/api/ratings.ts diff --git a/overseerr-api.yml b/overseerr-api.yml index c8b52885..f3a1cc74 100644 --- a/overseerr-api.yml +++ b/overseerr-api.yml @@ -5338,6 +5338,63 @@ paths: audienceRating: type: string enum: ['Spilled', 'Upright'] + /movie/{movieId}/ratingscombined: + get: + summary: Get RT and IMDB movie ratings combined + description: Returns ratings from RottenTomatoes and IMDB based on the provided movieId in a JSON object. + tags: + - movies + parameters: + - in: path + name: movieId + required: true + schema: + type: number + example: 337401 + responses: + '200': + description: Ratings returned + content: + application/json: + schema: + type: object + properties: + rt: + type: object + properties: + title: + type: string + example: Mulan + year: + type: number + example: 2020 + url: + type: string + example: 'http://www.rottentomatoes.com/m/mulan_2020/' + criticsScore: + type: number + example: 85 + criticsRating: + type: string + enum: ['Rotten', 'Fresh', 'Certified Fresh'] + audienceScore: + type: number + example: 65 + audienceRating: + type: string + enum: ['Spilled', 'Upright'] + imdb: + type: object + properties: + title: + type: string + example: I am Legend + url: + type: string + example: 'https://www.imdb.com/title/tt0480249' + criticsScore: + type: number + example: 6.5 /tv/{tvId}: get: summary: Get TV details diff --git a/server/api/rating/imdbRadarrProxy.ts b/server/api/rating/imdbRadarrProxy.ts new file mode 100644 index 00000000..0d8ec79f --- /dev/null +++ b/server/api/rating/imdbRadarrProxy.ts @@ -0,0 +1,195 @@ +import ExternalAPI from '@server/api/externalapi'; +import cacheManager from '@server/lib/cache'; + +type IMDBRadarrProxyResponse = IMDBMovie[]; + +interface IMDBMovie { + ImdbId: string; + Overview: string; + Title: string; + OriginalTitle: string; + TitleSlug: string; + Ratings: Rating[]; + MovieRatings: MovieRatings; + Runtime: number; + Images: Image[]; + Genres: string[]; + Popularity: number; + Premier: string; + InCinema: string; + PhysicalRelease: any; + DigitalRelease: string; + Year: number; + AlternativeTitles: AlternativeTitle[]; + Translations: Translation[]; + Recommendations: Recommendation[]; + Credits: Credits; + Studio: string; + YoutubeTrailerId: string; + Certifications: Certification[]; + Status: any; + Collection: Collection; + OriginalLanguage: string; + Homepage: string; + TmdbId: number; +} + +interface Rating { + Count: number; + Value: number; + Origin: string; + Type: string; +} + +interface MovieRatings { + Tmdb: Tmdb; + Imdb: Imdb; + Metacritic: Metacritic; + RottenTomatoes: RottenTomatoes; +} + +interface Tmdb { + Count: number; + Value: number; + Type: string; +} + +interface Imdb { + Count: number; + Value: number; + Type: string; +} + +interface Metacritic { + Count: number; + Value: number; + Type: string; +} + +interface RottenTomatoes { + Count: number; + Value: number; + Type: string; +} + +interface Image { + CoverType: string; + Url: string; +} + +interface AlternativeTitle { + Title: string; + Type: string; + Language: string; +} + +interface Translation { + Title: string; + Overview: string; + Language: string; +} + +interface Recommendation { + TmdbId: number; + Title: string; +} + +interface Credits { + Cast: Cast[]; + Crew: Crew[]; +} + +interface Cast { + Name: string; + Order: number; + Character: string; + TmdbId: number; + CreditId: string; + Images: Image2[]; +} + +interface Image2 { + CoverType: string; + Url: string; +} + +interface Crew { + Name: string; + Job: string; + Department: string; + TmdbId: number; + CreditId: string; + Images: Image3[]; +} + +interface Image3 { + CoverType: string; + Url: string; +} + +interface Certification { + Country: string; + Certification: string; +} + +interface Collection { + Name: string; + Images: any; + Overview: any; + Translations: any; + Parts: any; + TmdbId: number; +} + +export interface IMDBRating { + title: string; + url: string; + criticsScore: number; +} + +/** + * This is a best-effort API. The IMDB API is technically + * private and getting access costs money/requires approval. + * + * Radarr hosts a public proxy that's in use by all Radarr instances. + */ +class IMDBRadarrProxy extends ExternalAPI { + constructor() { + super('https://api.radarr.video/v1', { + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json', + }, + nodeCache: cacheManager.getCache('imdb').data, + }); + } + + /** + * Ask the Radarr IMDB Proxy for the movie + * + * @param IMDBid Id of IMDB movie + */ + public async getMovieRatings(IMDBid: string): Promise { + try { + const data = await this.get( + `/movie/imdb/${IMDBid}` + ); + + if (!data?.length || data[0].ImdbId !== IMDBid) { + return null; + } + + return { + title: data[0].Title, + url: `https://www.imdb.com/title/${data[0].ImdbId}`, + criticsScore: data[0].MovieRatings.Imdb.Value, + }; + } catch (e) { + throw new Error( + `[IMDB RADARR PROXY API] Failed to retrieve movie ratings: ${e.message}` + ); + } + } +} + +export default IMDBRadarrProxy; diff --git a/server/api/rottentomatoes.ts b/server/api/rating/rottentomatoes.ts similarity index 93% rename from server/api/rottentomatoes.ts rename to server/api/rating/rottentomatoes.ts index 99a74eb1..1cf9d6d8 100644 --- a/server/api/rottentomatoes.ts +++ b/server/api/rating/rottentomatoes.ts @@ -1,6 +1,6 @@ +import ExternalAPI from '@server/api/externalapi'; import cacheManager from '@server/lib/cache'; import { getSettings } from '@server/lib/settings'; -import ExternalAPI from './externalapi'; interface RTAlgoliaSearchResponse { results: { @@ -144,6 +144,9 @@ class RottenTomatoes extends ExternalAPI { ? 'Fresh' : 'Rotten', criticsScore: movie.rottenTomatoes.criticsScore, + audienceRating: + movie.rottenTomatoes.audienceScore >= 60 ? 'Upright' : 'Spilled', + audienceScore: movie.rottenTomatoes.audienceScore, year: Number(movie.releaseYear), }; } catch (e) { @@ -192,6 +195,9 @@ class RottenTomatoes extends ExternalAPI { criticsRating: tvshow.rottenTomatoes.criticsScore >= 60 ? 'Fresh' : 'Rotten', criticsScore: tvshow.rottenTomatoes.criticsScore, + audienceRating: + tvshow.rottenTomatoes.audienceScore >= 60 ? 'Upright' : 'Spilled', + audienceScore: tvshow.rottenTomatoes.audienceScore, year: Number(tvshow.releaseYear), }; } catch (e) { diff --git a/server/api/ratings.ts b/server/api/ratings.ts new file mode 100644 index 00000000..1fe1354c --- /dev/null +++ b/server/api/ratings.ts @@ -0,0 +1,7 @@ +import { type IMDBRating } from '@server/api/rating/imdbRadarrProxy'; +import { type RTRating } from '@server/api/rating/rottentomatoes'; + +export interface RatingResponse { + rt?: RTRating; + imdb?: IMDBRating; +} diff --git a/server/lib/cache.ts b/server/lib/cache.ts index e8146662..011205e7 100644 --- a/server/lib/cache.ts +++ b/server/lib/cache.ts @@ -5,6 +5,7 @@ export type AvailableCacheIds = | 'radarr' | 'sonarr' | 'rt' + | 'imdb' | 'github' | 'plexguid' | 'plextv'; @@ -51,6 +52,10 @@ class CacheManager { stdTtl: 43200, checkPeriod: 60 * 30, }), + imdb: new Cache('imdb', 'IMDB Radarr Proxy', { + stdTtl: 43200, + checkPeriod: 60 * 30, + }), github: new Cache('github', 'GitHub API', { stdTtl: 21600, checkPeriod: 60 * 30, diff --git a/server/routes/movie.ts b/server/routes/movie.ts index f11cead8..e39e2e86 100644 --- a/server/routes/movie.ts +++ b/server/routes/movie.ts @@ -1,4 +1,6 @@ -import RottenTomatoes from '@server/api/rottentomatoes'; +import IMDBRadarrProxy from '@server/api/rating/imdbRadarrProxy'; +import RottenTomatoes from '@server/api/rating/rottentomatoes'; +import { type RatingResponse } from '@server/api/ratings'; import TheMovieDb from '@server/api/themoviedb'; import { MediaType } from '@server/constants/media'; import Media from '@server/entity/Media'; @@ -116,6 +118,9 @@ movieRoutes.get('/:id/similar', async (req, res, next) => { } }); +/** + * Endpoint backed by RottenTomatoes + */ movieRoutes.get('/:id/ratings', async (req, res, next) => { const tmdb = new TheMovieDb(); const rtapi = new RottenTomatoes(); @@ -151,4 +156,53 @@ movieRoutes.get('/:id/ratings', async (req, res, next) => { } }); +/** + * Endpoint combining RottenTomatoes and IMDB + */ +movieRoutes.get('/:id/ratingscombined', async (req, res, next) => { + const tmdb = new TheMovieDb(); + const rtapi = new RottenTomatoes(); + const imdbApi = new IMDBRadarrProxy(); + + try { + const movie = await tmdb.getMovie({ + movieId: Number(req.params.id), + }); + + const rtratings = await rtapi.getMovieRatings( + movie.title, + Number(movie.release_date.slice(0, 4)) + ); + + let imdbRatings; + if (movie.imdb_id) { + imdbRatings = await imdbApi.getMovieRatings(movie.imdb_id); + } + + if (!rtratings && !imdbRatings) { + return next({ + status: 404, + message: 'No ratings found.', + }); + } + + const ratings: RatingResponse = { + ...(rtratings ? { rt: rtratings } : {}), + ...(imdbRatings ? { imdb: imdbRatings } : {}), + }; + + return res.status(200).json(ratings); + } catch (e) { + logger.debug('Something went wrong retrieving movie ratings', { + label: 'API', + errorMessage: e.message, + movieId: req.params.id, + }); + return next({ + status: 500, + message: 'Unable to retrieve movie ratings.', + }); + } +}); + export default movieRoutes; diff --git a/server/routes/tv.ts b/server/routes/tv.ts index d45e4062..95c8dc11 100644 --- a/server/routes/tv.ts +++ b/server/routes/tv.ts @@ -1,4 +1,4 @@ -import RottenTomatoes from '@server/api/rottentomatoes'; +import RottenTomatoes from '@server/api/rating/rottentomatoes'; import TheMovieDb from '@server/api/themoviedb'; import { MediaType } from '@server/constants/media'; import Media from '@server/entity/Media'; diff --git a/src/components/MovieDetails/index.tsx b/src/components/MovieDetails/index.tsx index 1b142d4d..eaaa902e 100644 --- a/src/components/MovieDetails/index.tsx +++ b/src/components/MovieDetails/index.tsx @@ -2,6 +2,7 @@ import RTAudFresh from '@app/assets/rt_aud_fresh.svg'; import RTAudRotten from '@app/assets/rt_aud_rotten.svg'; import RTFresh from '@app/assets/rt_fresh.svg'; import RTRotten from '@app/assets/rt_rotten.svg'; +import ImdbLogo from '@app/assets/services/imdb.svg'; import TmdbLogo from '@app/assets/tmdb_logo.svg'; import Button from '@app/components/Common/Button'; import CachedImage from '@app/components/Common/CachedImage'; @@ -40,7 +41,7 @@ import { ChevronDoubleDownIcon, ChevronDoubleUpIcon, } from '@heroicons/react/24/solid'; -import type { RTRating } from '@server/api/rottentomatoes'; +import { type RatingResponse } from '@server/api/ratings'; import { IssueStatus } from '@server/constants/issue'; import { MediaStatus } from '@server/constants/media'; import type { MovieDetails as MovieDetailsType } from '@server/models/Movie'; @@ -86,6 +87,7 @@ const messages = defineMessages({ rtcriticsscore: 'Rotten Tomatoes Tomatometer', rtaudiencescore: 'Rotten Tomatoes Audience Score', tmdbuserscore: 'TMDB User Score', + imdbuserscore: 'IMDB User Score', }); interface MovieDetailsProps { @@ -120,8 +122,8 @@ const MovieDetails = ({ movie }: MovieDetailsProps) => { ), }); - const { data: ratingData } = useSWR( - `/api/v1/movie/${router.query.movieId}/ratings` + const { data: ratingData } = useSWR( + `/api/v1/movie/${router.query.movieId}/ratingscombined` ); const sortedCrew = useMemo( @@ -511,44 +513,62 @@ const MovieDetails = ({ movie }: MovieDetailsProps) => { )}
{(!!data.voteCount || - (ratingData?.criticsRating && !!ratingData?.criticsScore) || - (ratingData?.audienceRating && !!ratingData?.audienceScore)) && ( + (ratingData?.rt?.criticsRating && + !!ratingData?.rt?.criticsScore) || + (ratingData?.rt?.audienceRating && + !!ratingData?.rt?.audienceScore) || + ratingData?.imdb?.criticsScore) && (
- {ratingData?.criticsRating && !!ratingData?.criticsScore && ( - - - {ratingData.criticsRating === 'Rotten' ? ( - - ) : ( - - )} - {ratingData.criticsScore}% - - - )} - {ratingData?.audienceRating && !!ratingData?.audienceScore && ( - + + {ratingData.rt.criticsRating === 'Rotten' ? ( + + ) : ( + + )} + {ratingData.rt.criticsScore}% + + + )} + {ratingData?.rt?.audienceRating && + !!ratingData?.rt?.audienceScore && ( + + + {ratingData.rt.audienceRating === 'Spilled' ? ( + + ) : ( + + )} + {ratingData.rt.audienceScore}% + + + )} + {ratingData?.imdb?.criticsScore && ( + - {ratingData.audienceRating === 'Spilled' ? ( - - ) : ( - - )} - {ratingData.audienceScore}% + + {ratingData.imdb.criticsScore} )} @@ -797,7 +817,7 @@ const MovieDetails = ({ movie }: MovieDetailsProps) => { tmdbId={data.id} tvdbId={data.externalIds.tvdbId} imdbId={data.externalIds.imdbId} - rtUrl={ratingData?.url} + rtUrl={ratingData?.rt?.url} plexUrl={data.mediaInfo?.plexUrl ?? data.mediaInfo?.plexUrl4k} />
diff --git a/src/components/TvDetails/index.tsx b/src/components/TvDetails/index.tsx index c450ef4a..2f44a7d8 100644 --- a/src/components/TvDetails/index.tsx +++ b/src/components/TvDetails/index.tsx @@ -40,7 +40,7 @@ import { PlayIcon, } from '@heroicons/react/24/outline'; import { ChevronDownIcon } from '@heroicons/react/24/solid'; -import type { RTRating } from '@server/api/rottentomatoes'; +import type { RTRating } from '@server/api/rating/rottentomatoes'; import { ANIME_KEYWORD_ID } from '@server/api/themoviedb/constants'; import { IssueStatus } from '@server/constants/issue'; import { MediaRequestStatus, MediaStatus } from '@server/constants/media'; diff --git a/src/i18n/locale/en.json b/src/i18n/locale/en.json index 24a537a0..83d3d842 100644 --- a/src/i18n/locale/en.json +++ b/src/i18n/locale/en.json @@ -256,6 +256,7 @@ "components.MovieDetails.budget": "Budget", "components.MovieDetails.cast": "Cast", "components.MovieDetails.digitalrelease": "Digital Release", + "components.MovieDetails.imdbuserscore": "IMDB User Score", "components.MovieDetails.managemovie": "Manage Movie", "components.MovieDetails.mark4kavailable": "Mark as Available in 4K", "components.MovieDetails.markavailable": "Mark as Available",