diff --git a/server/api/ratings.ts b/server/api/ratings.ts index 1fe1354cfa..dd939b239d 100644 --- a/server/api/ratings.ts +++ b/server/api/ratings.ts @@ -5,3 +5,29 @@ export interface RatingResponse { rt?: RTRating; imdb?: IMDBRating; } + +export const combineMovieRatingResults = ( + rtResult: PromiseSettledResult, + imdbResult: PromiseSettledResult, + imdbAttempted: boolean +): { + ratings: RatingResponse; + allProvidersFailed: boolean; +} => { + const ratings: RatingResponse = { + ...(rtResult.status === 'fulfilled' && rtResult.value + ? { rt: rtResult.value } + : {}), + ...(imdbResult.status === 'fulfilled' && imdbResult.value + ? { imdb: imdbResult.value } + : {}), + }; + const providerResults = imdbAttempted ? [rtResult, imdbResult] : [rtResult]; + + return { + ratings, + allProvidersFailed: providerResults.every( + (result) => result.status === 'rejected' + ), + }; +}; diff --git a/server/routes/movie.ts b/server/routes/movie.ts index bd86e447a9..d594c40e9b 100644 --- a/server/routes/movie.ts +++ b/server/routes/movie.ts @@ -1,6 +1,6 @@ import IMDBRadarrProxy from '@server/api/rating/imdbRadarrProxy'; import RottenTomatoes from '@server/api/rating/rottentomatoes'; -import { type RatingResponse } from '@server/api/ratings'; +import { combineMovieRatingResults } from '@server/api/ratings'; import TheMovieDb from '@server/api/themoviedb'; import { MediaType } from '@server/constants/media'; import { getRepository } from '@server/datasource'; @@ -197,28 +197,51 @@ movieRoutes.get('/:id/ratingscombined', async (req, res, next) => { movieId: Number(req.params.id), }); - const rtratings = await rtapi.getMovieRatings( - movie.title, - Number(movie.release_date.slice(0, 4)) + const [rtResult, imdbResult] = await Promise.allSettled([ + rtapi.getMovieRatings( + movie.title, + Number(movie.release_date.slice(0, 4)) + ), + movie.imdb_id + ? imdbApi.getMovieRatings(movie.imdb_id) + : Promise.resolve(null), + ]); + + const providerResults = movie.imdb_id ? [rtResult, imdbResult] : [rtResult]; + + providerResults.forEach((result) => { + if (result.status === 'rejected') { + logger.debug('A movie ratings provider request failed', { + label: 'API', + errorMessage: + result.reason instanceof Error + ? result.reason.message + : String(result.reason), + movieId: req.params.id, + }); + } + }); + + const { ratings, allProvidersFailed } = combineMovieRatingResults( + rtResult, + imdbResult, + Boolean(movie.imdb_id) ); - let imdbRatings; - if (movie.imdb_id) { - imdbRatings = await imdbApi.getMovieRatings(movie.imdb_id); - } + if (!ratings.rt && !ratings.imdb) { + if (allProvidersFailed) { + return next({ + status: 500, + message: 'Unable to retrieve movie ratings.', + }); + } - 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', { diff --git a/src/components/TitleCard/TitleCardRatings.tsx b/src/components/TitleCard/TitleCardRatings.tsx new file mode 100644 index 0000000000..edbe64bf97 --- /dev/null +++ b/src/components/TitleCard/TitleCardRatings.tsx @@ -0,0 +1,167 @@ +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/services/tmdb.svg'; +import defineMessages from '@app/utils/defineMessages'; +import type { RTRating } from '@server/api/rating/rottentomatoes'; +import type { RatingResponse } from '@server/api/ratings'; +import type { MediaType } from '@server/models/Search'; +import { useEffect, useState } from 'react'; +import { useIntl } from 'react-intl'; +import useSWR from 'swr'; + +interface TitleCardRatingsProps { + id: number; + mediaType: MediaType; + userScore?: number; + visible: boolean; +} + +const RATINGS_REQUEST_DELAY_MS = 300; + +const messages = defineMessages('components.TitleCard.TitleCardRatings', { + ratings: 'Ratings', + rottenTomatoesAudienceScore: 'Rotten Tomatoes Audience Score', + rottenTomatoesCriticsScore: 'Rotten Tomatoes Critics Score', + imdbUserScore: 'IMDb User Score', + tmdbUserScore: 'TMDB User Score', +}); + +const TitleCardRatings = ({ + id, + mediaType, + userScore, + visible, +}: TitleCardRatingsProps) => { + const intl = useIntl(); + const [ratingsRequested, setRatingsRequested] = useState(false); + + useEffect(() => { + if (!visible || ratingsRequested) { + return; + } + + const timeout = setTimeout(() => { + setRatingsRequested(true); + }, RATINGS_REQUEST_DELAY_MS); + + return () => clearTimeout(timeout); + }, [ratingsRequested, visible]); + + const { data: movieRatings } = useSWR( + ratingsRequested && mediaType === 'movie' + ? `/api/v1/movie/${id}/ratingscombined` + : null, + { + revalidateOnFocus: false, + shouldRetryOnError: false, + } + ); + const { data: tvRatings } = useSWR( + ratingsRequested && mediaType === 'tv' ? `/api/v1/tv/${id}/ratings` : null, + { + revalidateOnFocus: false, + shouldRetryOnError: false, + } + ); + + const rtRating = + mediaType === 'movie' + ? movieRatings?.rt + : mediaType === 'tv' + ? tvRatings + : undefined; + const imdbRating = movieRatings?.imdb; + const hasRtAudienceScore = + typeof rtRating?.audienceScore === 'number' && + rtRating.audienceRating !== undefined; + const hasRtCriticsScore = + !hasRtAudienceScore && + typeof rtRating?.criticsScore === 'number' && + rtRating.criticsRating !== undefined; + const hasImdbScore = typeof imdbRating?.criticsScore === 'number'; + const hasTmdbScore = typeof userScore === 'number' && userScore > 0; + + if ( + !hasRtAudienceScore && + !hasRtCriticsScore && + !hasImdbScore && + !hasTmdbScore + ) { + return null; + } + + return ( +
+ {hasRtAudienceScore && ( + + + {intl.formatMessage(messages.rottenTomatoesAudienceScore)}:{' '} + + {rtRating.audienceRating === 'Spilled' ? ( + + )} + {hasRtCriticsScore && ( + + + {intl.formatMessage(messages.rottenTomatoesCriticsScore)}:{' '} + + {rtRating.criticsRating === 'Rotten' ? ( + + )} + {hasImdbScore && ( + + + {intl.formatMessage(messages.imdbUserScore)}:{' '} + + + )} + {hasTmdbScore && ( + + + {intl.formatMessage(messages.tmdbUserScore)}:{' '} + + + )} +
+ ); +}; + +export default TitleCardRatings; diff --git a/src/components/TitleCard/index.tsx b/src/components/TitleCard/index.tsx index 7f420047ff..4a635af171 100644 --- a/src/components/TitleCard/index.tsx +++ b/src/components/TitleCard/index.tsx @@ -7,6 +7,7 @@ import Tooltip from '@app/components/Common/Tooltip'; import RequestModal from '@app/components/RequestModal'; import ErrorCard from '@app/components/TitleCard/ErrorCard'; import Placeholder from '@app/components/TitleCard/Placeholder'; +import TitleCardRatings from '@app/components/TitleCard/TitleCardRatings'; import { useIsTouch } from '@app/hooks/useIsTouch'; import useToasts from '@app/hooks/useToasts'; import { Permission, UserType, useUser } from '@app/hooks/useUser'; @@ -62,6 +63,7 @@ const TitleCard = ({ year, title, status, + userScore, mediaType, isAddedToWatchlist = false, inProgress = false, @@ -522,7 +524,7 @@ const TitleCard = ({ {year &&
{year}
}

{summary} + {typeof userScore === 'number' && userScore > 0 && ( + + )} diff --git a/src/i18n/locale/en.json b/src/i18n/locale/en.json index ba57d83b99..f4f53d9e16 100644 --- a/src/i18n/locale/en.json +++ b/src/i18n/locale/en.json @@ -1330,6 +1330,11 @@ "components.StatusChecker.reloadApp": "Reload {applicationTitle}", "components.StatusChecker.restartRequired": "Server Restart Required", "components.StatusChecker.restartRequiredDescription": "Please restart the server to apply the updated settings.", + "components.TitleCard.TitleCardRatings.imdbUserScore": "IMDb User Score", + "components.TitleCard.TitleCardRatings.ratings": "Ratings", + "components.TitleCard.TitleCardRatings.rottenTomatoesAudienceScore": "Rotten Tomatoes Audience Score", + "components.TitleCard.TitleCardRatings.rottenTomatoesCriticsScore": "Rotten Tomatoes Critics Score", + "components.TitleCard.TitleCardRatings.tmdbUserScore": "TMDB User Score", "components.TitleCard.addToWatchList": "Add to watchlist", "components.TitleCard.cleardata": "Clear Data", "components.TitleCard.mediaerror": "{mediaType} Not Found",