From 52b9cb3faaa9f3e390cce9892976d70f82ceebee Mon Sep 17 00:00:00 2001 From: u61d Date: Fri, 12 Jun 2026 02:28:16 +0200 Subject: [PATCH 1/5] feat(title-card): display ratings on media cards Fixes #1269 --- src/components/TitleCard/TitleCardRatings.tsx | 160 ++++++++++++++++++ src/components/TitleCard/index.tsx | 10 +- src/i18n/locale/en.json | 5 + 3 files changed, 174 insertions(+), 1 deletion(-) create mode 100644 src/components/TitleCard/TitleCardRatings.tsx diff --git a/src/components/TitleCard/TitleCardRatings.tsx b/src/components/TitleCard/TitleCardRatings.tsx new file mode 100644 index 0000000000..4ede4f5ce7 --- /dev/null +++ b/src/components/TitleCard/TitleCardRatings.tsx @@ -0,0 +1,160 @@ +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 messages = defineMessages('components.TitleCard.TitleCardRatings', { + ratings: 'Ratings', + rottenTomatoesAudienceScore: 'Rotten Tomatoes Audience Score: {score}%', + rottenTomatoesCriticsScore: 'Rotten Tomatoes Critics Score: {score}%', + imdbUserScore: 'IMDb User Score: {score}', + tmdbUserScore: 'TMDB User Score: {score}%', +}); + +const TitleCardRatings = ({ + id, + mediaType, + userScore, + visible, +}: TitleCardRatingsProps) => { + const intl = useIntl(); + const [ratingsRequested, setRatingsRequested] = useState(visible); + + useEffect(() => { + if (visible) { + setRatingsRequested(true); + } + }, [visible]); + + const { data: movieRatings } = useSWR( + ratingsRequested && mediaType === 'movie' + ? `/api/v1/movie/${id}/ratingscombined` + : null, + { + shouldRetryOnError: false, + } + ); + const { data: tvRatings } = useSWR( + ratingsRequested && mediaType === 'tv' ? `/api/v1/tv/${id}/ratings` : null, + { + shouldRetryOnError: false, + } + ); + + const rtRating = mediaType === 'movie' ? movieRatings?.rt : tvRatings; + 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 && ( + + {rtRating.audienceRating === 'Spilled' ? ( + + )} + {hasRtCriticsScore && ( + + {rtRating.criticsRating === 'Rotten' ? ( + + )} + {hasImdbScore && ( + + + )} + {hasTmdbScore && ( + + + )} +
+ ); +}; + +export default TitleCardRatings; diff --git a/src/components/TitleCard/index.tsx b/src/components/TitleCard/index.tsx index 7f420047ff..18f1f5130e 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} + diff --git a/src/i18n/locale/en.json b/src/i18n/locale/en.json index ba57d83b99..48aebe55a5 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: {score}", + "components.TitleCard.TitleCardRatings.ratings": "Ratings", + "components.TitleCard.TitleCardRatings.rottenTomatoesAudienceScore": "Rotten Tomatoes Audience Score: {score}%", + "components.TitleCard.TitleCardRatings.rottenTomatoesCriticsScore": "Rotten Tomatoes Critics Score: {score}%", + "components.TitleCard.TitleCardRatings.tmdbUserScore": "TMDB User Score: {score}%", "components.TitleCard.addToWatchList": "Add to watchlist", "components.TitleCard.cleardata": "Clear Data", "components.TitleCard.mediaerror": "{mediaType} Not Found", From 0c53a3a5dbbcaa5a7f8a84eee21c61fa373f574d Mon Sep 17 00:00:00 2001 From: u61d Date: Fri, 12 Jun 2026 02:50:39 +0200 Subject: [PATCH 2/5] fix(ratings): handle provider failures and reduce hover requests --- server/api/ratings.ts | 26 ++++++++++ server/routes/movie.ts | 51 ++++++++++++++----- src/components/TitleCard/TitleCardRatings.tsx | 18 +++++-- 3 files changed, 77 insertions(+), 18 deletions(-) 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 index 4ede4f5ce7..60bafc7d75 100644 --- a/src/components/TitleCard/TitleCardRatings.tsx +++ b/src/components/TitleCard/TitleCardRatings.tsx @@ -19,6 +19,8 @@ interface TitleCardRatingsProps { visible: boolean; } +const RATINGS_REQUEST_DELAY_MS = 300; + const messages = defineMessages('components.TitleCard.TitleCardRatings', { ratings: 'Ratings', rottenTomatoesAudienceScore: 'Rotten Tomatoes Audience Score: {score}%', @@ -34,25 +36,33 @@ const TitleCardRatings = ({ visible, }: TitleCardRatingsProps) => { const intl = useIntl(); - const [ratingsRequested, setRatingsRequested] = useState(visible); + const [ratingsRequested, setRatingsRequested] = useState(false); useEffect(() => { - if (visible) { - setRatingsRequested(true); + if (!visible || ratingsRequested) { + return; } - }, [visible]); + + 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, } ); From 0f6bd810fcf0b63230f32b9ade5de6c19b1ad6f3 Mon Sep 17 00:00:00 2001 From: u61d Date: Fri, 12 Jun 2026 03:14:27 +0200 Subject: [PATCH 3/5] fix(title-card): limit ratings to movies and series --- src/components/TitleCard/TitleCardRatings.tsx | 2 +- src/components/TitleCard/index.tsx | 14 ++++++++------ 2 files changed, 9 insertions(+), 7 deletions(-) diff --git a/src/components/TitleCard/TitleCardRatings.tsx b/src/components/TitleCard/TitleCardRatings.tsx index 60bafc7d75..36ac3d9f2b 100644 --- a/src/components/TitleCard/TitleCardRatings.tsx +++ b/src/components/TitleCard/TitleCardRatings.tsx @@ -14,7 +14,7 @@ import useSWR from 'swr'; interface TitleCardRatingsProps { id: number; - mediaType: MediaType; + mediaType: Extract; userScore?: number; visible: boolean; } diff --git a/src/components/TitleCard/index.tsx b/src/components/TitleCard/index.tsx index 18f1f5130e..997699004d 100644 --- a/src/components/TitleCard/index.tsx +++ b/src/components/TitleCard/index.tsx @@ -554,12 +554,14 @@ const TitleCard = ({ > {summary} - + {(mediaType === 'movie' || mediaType === 'tv') && ( + + )} From 4e50292f80fe6a49134392c8d965a1e4dc00ce07 Mon Sep 17 00:00:00 2001 From: u61d Date: Fri, 12 Jun 2026 18:20:43 +0200 Subject: [PATCH 4/5] fix(title-card): address rating review feedback --- src/components/TitleCard/TitleCardRatings.tsx | 9 +++++++-- src/components/TitleCard/index.tsx | 2 +- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/src/components/TitleCard/TitleCardRatings.tsx b/src/components/TitleCard/TitleCardRatings.tsx index 36ac3d9f2b..8fffe23b5a 100644 --- a/src/components/TitleCard/TitleCardRatings.tsx +++ b/src/components/TitleCard/TitleCardRatings.tsx @@ -14,7 +14,7 @@ import useSWR from 'swr'; interface TitleCardRatingsProps { id: number; - mediaType: Extract; + mediaType: MediaType; userScore?: number; visible: boolean; } @@ -67,7 +67,12 @@ const TitleCardRatings = ({ } ); - const rtRating = mediaType === 'movie' ? movieRatings?.rt : tvRatings; + const rtRating = + mediaType === 'movie' + ? movieRatings?.rt + : mediaType === 'tv' + ? tvRatings + : undefined; const imdbRating = movieRatings?.imdb; const hasRtAudienceScore = typeof rtRating?.audienceScore === 'number' && diff --git a/src/components/TitleCard/index.tsx b/src/components/TitleCard/index.tsx index 997699004d..4a635af171 100644 --- a/src/components/TitleCard/index.tsx +++ b/src/components/TitleCard/index.tsx @@ -554,7 +554,7 @@ const TitleCard = ({ > {summary} - {(mediaType === 'movie' || mediaType === 'tv') && ( + {typeof userScore === 'number' && userScore > 0 && ( Date: Fri, 12 Jun 2026 21:34:29 +0200 Subject: [PATCH 5/5] fix(title-card): avoid duplicate rating announcements --- src/components/TitleCard/TitleCardRatings.tsx | 48 ++++++++----------- src/i18n/locale/en.json | 8 ++-- 2 files changed, 24 insertions(+), 32 deletions(-) diff --git a/src/components/TitleCard/TitleCardRatings.tsx b/src/components/TitleCard/TitleCardRatings.tsx index 8fffe23b5a..edbe64bf97 100644 --- a/src/components/TitleCard/TitleCardRatings.tsx +++ b/src/components/TitleCard/TitleCardRatings.tsx @@ -23,10 +23,10 @@ const RATINGS_REQUEST_DELAY_MS = 300; const messages = defineMessages('components.TitleCard.TitleCardRatings', { ratings: 'Ratings', - rottenTomatoesAudienceScore: 'Rotten Tomatoes Audience Score: {score}%', - rottenTomatoesCriticsScore: 'Rotten Tomatoes Critics Score: {score}%', - imdbUserScore: 'IMDb User Score: {score}', - tmdbUserScore: 'TMDB User Score: {score}%', + rottenTomatoesAudienceScore: 'Rotten Tomatoes Audience Score', + rottenTomatoesCriticsScore: 'Rotten Tomatoes Critics Score', + imdbUserScore: 'IMDb User Score', + tmdbUserScore: 'TMDB User Score', }); const TitleCardRatings = ({ @@ -99,12 +99,10 @@ const TitleCardRatings = ({ aria-label={intl.formatMessage(messages.ratings)} > {hasRtAudienceScore && ( - + + + {intl.formatMessage(messages.rottenTomatoesAudienceScore)}:{' '} + {rtRating.audienceRating === 'Spilled' ? ( )} {hasRtCriticsScore && ( - + + + {intl.formatMessage(messages.rottenTomatoesCriticsScore)}:{' '} + {rtRating.criticsRating === 'Rotten' ? ( )} {hasImdbScore && ( - + + + {intl.formatMessage(messages.imdbUserScore)}:{' '} +