diff --git a/overseerr-api.yml b/overseerr-api.yml index 32413d65..2c35f490 100644 --- a/overseerr-api.yml +++ b/overseerr-api.yml @@ -1837,6 +1837,48 @@ paths: type: array items: $ref: '#/components/schemas/MovieResult' + /movie/{movieId}/ratings: + get: + summary: Get ratings for the provided movie id + description: Returns ratings based on provided movie ID in JSON format + 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: + 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'] /tv/{tvId}: get: summary: Request tv details @@ -1983,6 +2025,42 @@ paths: type: array items: $ref: '#/components/schemas/TvResult' + /tv/{tvId}/ratings: + get: + summary: Get ratings for the provided tv id + description: Returns ratings based on provided tv ID in JSON format + tags: + - tv + parameters: + - in: path + name: tvId + required: true + schema: + type: number + example: 76479 + responses: + '200': + description: Ratings returned + content: + application/json: + schema: + type: object + properties: + title: + type: string + example: The Boys + year: + type: number + example: 2019 + url: + type: string + example: "http://www.rottentomatoes.com/m/mulan_2020/" + criticsScore: + type: number + example: 85 + criticsRating: + type: string + enum: ['Rotten', 'Fresh'] /media: get: summary: Return all media diff --git a/server/api/rottentomatoes.ts b/server/api/rottentomatoes.ts new file mode 100644 index 00000000..c75f203b --- /dev/null +++ b/server/api/rottentomatoes.ts @@ -0,0 +1,150 @@ +import axios, { AxiosInstance } from 'axios'; + +interface RTMovieOldSearchResult { + id: number; + title: string; + year: number; + ratings: { + critics_rating: 'Certified Fresh' | 'Fresh' | 'Rotten'; + critics_score: number; + audience_rating: 'Upright' | 'Spilled'; + audience_score: number; + }; + links: { + self: string; + alternate: string; + }; +} + +interface RTTvSearchResult { + title: string; + meterClass: 'fresh' | 'rotten'; + meterScore: number; + url: string; + startYear: number; + endYear: number; +} + +interface RTMovieSearchResponse { + total: number; + movies: RTMovieOldSearchResult[]; +} + +interface RTMultiSearchResponse { + tvCount: number; + tvSeries: RTTvSearchResult[]; +} + +export interface RTRating { + title: string; + year: number; + criticsRating: 'Certified Fresh' | 'Fresh' | 'Rotten'; + criticsScore: number; + audienceRating?: 'Upright' | 'Spilled'; + audienceScore?: number; + url: string; +} + +/** + * This is a best-effort API. The Rotten Tomatoes API is technically + * private and getting access costs money/requires approval. + * + * They do, however, have a "public" api that they use to request the + * data on their own site. We use this to get ratings for movies/tv shows. + * + * Unfortunately, we need to do it by searching for the movie name, so it's + * not always accurate. + */ +class RottenTomatoes { + private axios: AxiosInstance; + + constructor() { + this.axios = axios.create({ + baseURL: 'https://www.rottentomatoes.com/api/private', + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json', + }, + }); + } + + /** + * Search the 1.0 api for the movie title + * + * We compare the release date to make sure its the correct + * match. But it's not guaranteed to have results. + * + * We use the 1.0 API here because the 2.0 search api does + * not return audience ratings. + * + * @param name Movie name + * @param year Release Year + */ + public async getMovieRatings( + name: string, + year: number + ): Promise { + try { + const response = await this.axios.get( + '/v1.0/movies', + { + params: { q: name }, + } + ); + + const movie = response.data.movies.find((movie) => movie.year === year); + + if (!movie) { + return null; + } + + return { + title: movie.title, + url: movie.links.alternate, + criticsRating: movie.ratings.critics_rating, + criticsScore: movie.ratings.critics_score, + audienceRating: movie.ratings.audience_rating, + audienceScore: movie.ratings.audience_score, + year: movie.year, + }; + } catch (e) { + throw new Error( + `[RT API] Failed to retrieve movie ratings: ${e.message}` + ); + } + } + + public async getTVRatings( + name: string, + year: number + ): Promise { + try { + const response = await this.axios.get( + '/v2.0/search/', + { + params: { q: name, limit: 10 }, + } + ); + + const tvshow = response.data.tvSeries.find( + (series) => series.startYear === year + ); + + if (!tvshow) { + return null; + } + + return { + title: tvshow.title, + url: `https://www.rottentomatoes.com${tvshow.url}`, + criticsRating: tvshow.meterClass === 'fresh' ? 'Fresh' : 'Rotten', + criticsScore: tvshow.meterScore, + year: tvshow.startYear, + }; + } catch (e) { + throw new Error(`[RT API] Failed to retrieve tv ratings: ${e.message}`); + } + } +} + +export default RottenTomatoes; diff --git a/server/routes/movie.ts b/server/routes/movie.ts index b16e523d..7e6f6fb7 100644 --- a/server/routes/movie.ts +++ b/server/routes/movie.ts @@ -4,6 +4,7 @@ import { mapMovieDetails } from '../models/Movie'; import { MediaRequest } from '../entity/MediaRequest'; import { mapMovieResult } from '../models/Search'; import Media from '../entity/Media'; +import RottenTomatoes from '../api/rottentomatoes'; const movieRoutes = Router(); @@ -72,4 +73,28 @@ movieRoutes.get('/:id/similar', async (req, res) => { }); }); +movieRoutes.get('/:id/ratings', async (req, res, next) => { + const tmdb = new TheMovieDb(); + const rtapi = new RottenTomatoes(); + + const movie = await tmdb.getMovie({ + movieId: Number(req.params.id), + }); + + if (!movie) { + return next({ status: 404, message: 'Movie does not exist' }); + } + + const rtratings = await rtapi.getMovieRatings( + movie.title, + Number(movie.release_date.slice(0, 4)) + ); + + if (!rtratings) { + return next({ status: 404, message: 'Unable to retrieve ratings' }); + } + + return res.status(200).json(rtratings); +}); + export default movieRoutes; diff --git a/server/routes/tv.ts b/server/routes/tv.ts index 43034a5e..88acea0b 100644 --- a/server/routes/tv.ts +++ b/server/routes/tv.ts @@ -4,6 +4,7 @@ import { MediaRequest } from '../entity/MediaRequest'; import { mapTvDetails, mapSeasonWithEpisodes } from '../models/Tv'; import { mapTvResult } from '../models/Search'; import Media from '../entity/Media'; +import RottenTomatoes from '../api/rottentomatoes'; const tvRoutes = Router(); @@ -84,4 +85,28 @@ tvRoutes.get('/:id/similar', async (req, res) => { }); }); +tvRoutes.get('/:id/ratings', async (req, res, next) => { + const tmdb = new TheMovieDb(); + const rtapi = new RottenTomatoes(); + + const tv = await tmdb.getTvShow({ + tvId: Number(req.params.id), + }); + + if (!tv) { + return next({ status: 404, message: 'TV Show does not exist' }); + } + + const rtratings = await rtapi.getTVRatings( + tv.name, + Number(tv.first_air_date.slice(0, 4)) + ); + + if (!rtratings) { + return next({ status: 404, message: 'Unable to retrieve ratings' }); + } + + return res.status(200).json(rtratings); +}); + export default tvRoutes; diff --git a/src/assets/rt_aud_fresh.svg b/src/assets/rt_aud_fresh.svg new file mode 100644 index 00000000..ecc9b5b0 --- /dev/null +++ b/src/assets/rt_aud_fresh.svg @@ -0,0 +1 @@ + diff --git a/src/assets/rt_aud_rotten.svg b/src/assets/rt_aud_rotten.svg new file mode 100644 index 00000000..c97f1f65 --- /dev/null +++ b/src/assets/rt_aud_rotten.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/src/assets/rt_fresh.svg b/src/assets/rt_fresh.svg new file mode 100644 index 00000000..ff792bcf --- /dev/null +++ b/src/assets/rt_fresh.svg @@ -0,0 +1 @@ + diff --git a/src/assets/rt_rotten.svg b/src/assets/rt_rotten.svg new file mode 100644 index 00000000..283ea5b6 --- /dev/null +++ b/src/assets/rt_rotten.svg @@ -0,0 +1 @@ + diff --git a/src/assets/tmdb_logo.svg b/src/assets/tmdb_logo.svg new file mode 100644 index 00000000..e98e4ab2 --- /dev/null +++ b/src/assets/tmdb_logo.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/components/MovieDetails/index.tsx b/src/components/MovieDetails/index.tsx index bec6167a..c7957d55 100644 --- a/src/components/MovieDetails/index.tsx +++ b/src/components/MovieDetails/index.tsx @@ -28,6 +28,13 @@ import ButtonWithDropdown from '../Common/ButtonWithDropdown'; import axios from 'axios'; import SlideOver from '../Common/SlideOver'; import RequestBlock from '../RequestBlock'; +import TmdbLogo from '../../assets/tmdb_logo.svg'; +import RTFresh from '../../assets/rt_fresh.svg'; +import RTRotten from '../../assets/rt_rotten.svg'; +import RTAudFresh from '../../assets/rt_aud_fresh.svg'; +import RTAudRotten from '../../assets/rt_aud_rotten.svg'; +import type { RTRating } from '../../../server/api/rottentomatoes'; +import Error from '../../pages/_error'; const messages = defineMessages({ releasedate: 'Release Date', @@ -80,13 +87,16 @@ const MovieDetails: React.FC = ({ movie }) => { const { data: similar, error: similarError } = useSWR( `/api/v1/movie/${router.query.movieId}/similar?language=${locale}` ); + const { data: ratingData } = useSWR( + `/api/v1/movie/${router.query.movieId}/ratings` + ); if (!data && !error) { return ; } if (!data) { - return
Broken?
; + return ; } const activeRequest = data?.mediaInfo?.requests?.find( @@ -341,14 +351,46 @@ const MovieDetails: React.FC = ({ movie }) => {
- {data.voteCount > 0 && ( -
- - - - - {data.voteAverage}/10 - + {(data.voteCount > 0 || ratingData) && ( +
+ {ratingData?.criticsRating && ( + <> + + {ratingData.criticsRating === 'Rotten' ? ( + + ) : ( + + )} + + + {ratingData.criticsScore}% + + + )} + {ratingData?.audienceRating && ( + <> + + {ratingData.audienceRating === 'Spilled' ? ( + + ) : ( + + )} + + + {ratingData.audienceScore}% + + + )} + {data.voteCount > 0 && ( + <> + + + + + {data.voteAverage}/10 + + + )}
)}
diff --git a/src/components/TvDetails/index.tsx b/src/components/TvDetails/index.tsx index 8d21ae5e..11dfde4b 100644 --- a/src/components/TvDetails/index.tsx +++ b/src/components/TvDetails/index.tsx @@ -20,6 +20,12 @@ import axios from 'axios'; import SlideOver from '../Common/SlideOver'; import RequestBlock from '../RequestBlock'; import Error from '../../pages/_error'; +import TmdbLogo from '../../assets/tmdb_logo.svg'; +import RTFresh from '../../assets/rt_fresh.svg'; +import RTRotten from '../../assets/rt_rotten.svg'; +import RTAudFresh from '../../assets/rt_aud_fresh.svg'; +import RTAudRotten from '../../assets/rt_aud_rotten.svg'; +import type { RTRating } from '../../../server/api/rottentomatoes'; const messages = defineMessages({ userrating: 'User Rating', @@ -80,6 +86,10 @@ const TvDetails: React.FC = ({ tv }) => { `/api/v1/tv/${router.query.tvId}/similar?language=${locale}` ); + const { data: ratingData } = useSWR( + `/api/v1/tv/${router.query.tvId}/ratings` + ); + if (!data && !error) { return ; } @@ -331,14 +341,46 @@ const TvDetails: React.FC = ({ tv }) => {
- {data.voteCount > 0 && ( -
- - - - - {data.voteAverage}/10 - + {(data.voteCount > 0 || ratingData) && ( +
+ {ratingData?.criticsRating && ( + <> + + {ratingData.criticsRating === 'Rotten' ? ( + + ) : ( + + )} + + + {ratingData.criticsScore}% + + + )} + {ratingData?.audienceRating && ( + <> + + {ratingData.audienceRating === 'Spilled' ? ( + + ) : ( + + )} + + + {ratingData.audienceScore}% + + + )} + {data.voteCount > 0 && ( + <> + + + + + {data.voteAverage}/10 + + + )}
)}