mirror of
https://github.com/fallenbagel/jellyseerr.git
synced 2025-12-24 02:39:18 -05:00
feat(rating): added IMDB Radarr proxy (#3496)
* feat(rating): added imdb radarr proxy Signed-off-by: marcofaggian <m@marcofaggian.com> * refactor(rating/imdb): rm export unused interfaces Signed-off-by: marcofaggian <m@marcofaggian.com> * docs(rating/imdb): rt to imdb Signed-off-by: marcofaggian <m@marcofaggian.com> * refactor(rating/imdb): specified error message Signed-off-by: marcofaggian <m@marcofaggian.com> * refactor(rating/imdb): rm line break Signed-off-by: marcofaggian <m@marcofaggian.com> * refactor(rating): conform to types patter Signed-off-by: marcofaggian <m@marcofaggian.com> * chore(rating/imdb): added line to translation file Signed-off-by: marcofaggian <m@marcofaggian.com> * feat(rating/imdb): ratings to ratingscombined Signed-off-by: marcofaggian <m@marcofaggian.com> * fix(rating/imdb): reinstating ratings route Signed-off-by: marcofaggian <m@marcofaggian.com> * docs(ratings): openapi ratings Signed-off-by: marcofaggian <m@marcofaggian.com> * chore(ratings): undo openapi ratings apex Signed-off-by: marcofaggian <m@marcofaggian.com> --------- Signed-off-by: marcofaggian <m@marcofaggian.com>
This commit is contained in:
@@ -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
|
||||
|
||||
195
server/api/rating/imdbRadarrProxy.ts
Normal file
195
server/api/rating/imdbRadarrProxy.ts
Normal file
@@ -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<IMDBRating | null> {
|
||||
try {
|
||||
const data = await this.get<IMDBRadarrProxyResponse>(
|
||||
`/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;
|
||||
@@ -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) {
|
||||
7
server/api/ratings.ts
Normal file
7
server/api/ratings.ts
Normal file
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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<RTRating>(
|
||||
`/api/v1/movie/${router.query.movieId}/ratings`
|
||||
const { data: ratingData } = useSWR<RatingResponse>(
|
||||
`/api/v1/movie/${router.query.movieId}/ratingscombined`
|
||||
);
|
||||
|
||||
const sortedCrew = useMemo(
|
||||
@@ -511,44 +513,62 @@ const MovieDetails = ({ movie }: MovieDetailsProps) => {
|
||||
)}
|
||||
<div className="media-facts">
|
||||
{(!!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) && (
|
||||
<div className="media-ratings">
|
||||
{ratingData?.criticsRating && !!ratingData?.criticsScore && (
|
||||
<Tooltip
|
||||
content={intl.formatMessage(messages.rtcriticsscore)}
|
||||
>
|
||||
{ratingData?.rt?.criticsRating &&
|
||||
!!ratingData?.rt?.criticsScore && (
|
||||
<Tooltip
|
||||
content={intl.formatMessage(messages.rtcriticsscore)}
|
||||
>
|
||||
<a
|
||||
href={ratingData.rt.url}
|
||||
className="media-rating"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
{ratingData.rt.criticsRating === 'Rotten' ? (
|
||||
<RTRotten className="w-6" />
|
||||
) : (
|
||||
<RTFresh className="w-6" />
|
||||
)}
|
||||
<span>{ratingData.rt.criticsScore}%</span>
|
||||
</a>
|
||||
</Tooltip>
|
||||
)}
|
||||
{ratingData?.rt?.audienceRating &&
|
||||
!!ratingData?.rt?.audienceScore && (
|
||||
<Tooltip
|
||||
content={intl.formatMessage(messages.rtaudiencescore)}
|
||||
>
|
||||
<a
|
||||
href={ratingData.rt.url}
|
||||
className="media-rating"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
{ratingData.rt.audienceRating === 'Spilled' ? (
|
||||
<RTAudRotten className="w-6" />
|
||||
) : (
|
||||
<RTAudFresh className="w-6" />
|
||||
)}
|
||||
<span>{ratingData.rt.audienceScore}%</span>
|
||||
</a>
|
||||
</Tooltip>
|
||||
)}
|
||||
{ratingData?.imdb?.criticsScore && (
|
||||
<Tooltip content={intl.formatMessage(messages.imdbuserscore)}>
|
||||
<a
|
||||
href={ratingData.url}
|
||||
href={ratingData.imdb.url}
|
||||
className="media-rating"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
{ratingData.criticsRating === 'Rotten' ? (
|
||||
<RTRotten className="w-6" />
|
||||
) : (
|
||||
<RTFresh className="w-6" />
|
||||
)}
|
||||
<span>{ratingData.criticsScore}%</span>
|
||||
</a>
|
||||
</Tooltip>
|
||||
)}
|
||||
{ratingData?.audienceRating && !!ratingData?.audienceScore && (
|
||||
<Tooltip
|
||||
content={intl.formatMessage(messages.rtaudiencescore)}
|
||||
>
|
||||
<a
|
||||
href={ratingData.url}
|
||||
className="media-rating"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
{ratingData.audienceRating === 'Spilled' ? (
|
||||
<RTAudRotten className="w-6" />
|
||||
) : (
|
||||
<RTAudFresh className="w-6" />
|
||||
)}
|
||||
<span>{ratingData.audienceScore}%</span>
|
||||
<ImdbLogo className="mr-1 w-6" />
|
||||
<span>{ratingData.imdb.criticsScore}</span>
|
||||
</a>
|
||||
</Tooltip>
|
||||
)}
|
||||
@@ -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}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user