Skip to content
Open
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions server/api/ratings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,29 @@ export interface RatingResponse {
rt?: RTRating;
imdb?: IMDBRating;
}

export const combineMovieRatingResults = (
rtResult: PromiseSettledResult<RTRating | null>,
imdbResult: PromiseSettledResult<IMDBRating | null>,
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'
),
};
};
51 changes: 37 additions & 14 deletions server/routes/movie.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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', {
Expand Down
175 changes: 175 additions & 0 deletions src/components/TitleCard/TitleCardRatings.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,175 @@
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: {score}%',

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why use the score inside the aria-label ?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The provider logos are hidden from assistive technologies, so the score in the label lets a screen reader announce the provider and its value together. Without it, the visible number would not have useful context.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, but the note is already inside the tag that’s why I’m asking. It seems a bit redundant 😅

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You are right, the visible score is already announced. I will remove the parent aria-label and add visually hidden provider text before the visible value instead, so the score is only announced once while still having provider context.

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(false);

useEffect(() => {
if (!visible || ratingsRequested) {
return;
}

const timeout = setTimeout(() => {
setRatingsRequested(true);
}, RATINGS_REQUEST_DELAY_MS);

return () => clearTimeout(timeout);
}, [ratingsRequested, visible]);

const { data: movieRatings } = useSWR<RatingResponse>(
ratingsRequested && mediaType === 'movie'
? `/api/v1/movie/${id}/ratingscombined`
: null,
{
revalidateOnFocus: false,
shouldRetryOnError: false,
}
);
const { data: tvRatings } = useSWR<RTRating>(
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;
Comment thread
u61d marked this conversation as resolved.
const hasImdbScore = typeof imdbRating?.criticsScore === 'number';
const hasTmdbScore = typeof userScore === 'number' && userScore > 0;

if (
!hasRtAudienceScore &&
!hasRtCriticsScore &&
!hasImdbScore &&
!hasTmdbScore
) {
return null;
}

return (
<div
className="mt-1 flex min-h-4 min-w-0 items-center gap-1 whitespace-nowrap text-[10px] font-medium md:gap-2 md:text-xs"
aria-label={intl.formatMessage(messages.ratings)}
>
{hasRtAudienceScore && (
<span
className="flex min-w-0 items-center gap-0.5 md:gap-1"
aria-label={intl.formatMessage(messages.rottenTomatoesAudienceScore, {
score: rtRating.audienceScore,
})}
>
{rtRating.audienceRating === 'Spilled' ? (
<RTAudRotten
className="h-3.5 w-3.5 shrink-0 md:h-4 md:w-4"
aria-hidden="true"
/>
) : (
<RTAudFresh
className="h-3.5 w-3.5 shrink-0 md:h-4 md:w-4"
aria-hidden="true"
/>
)}
<span>{rtRating.audienceScore}%</span>
</span>
)}
{hasRtCriticsScore && (
<span
className="flex min-w-0 items-center gap-0.5 md:gap-1"
aria-label={intl.formatMessage(messages.rottenTomatoesCriticsScore, {
score: rtRating.criticsScore,
})}
>
{rtRating.criticsRating === 'Rotten' ? (
<RTRotten
className="h-3.5 w-3.5 shrink-0 md:h-4 md:w-4"
aria-hidden="true"
/>
) : (
<RTFresh
className="h-3.5 w-3.5 shrink-0 md:h-4 md:w-4"
aria-hidden="true"
/>
)}
<span>{rtRating.criticsScore}%</span>
</span>
)}
{hasImdbScore && (
<span
className="flex min-w-0 items-center gap-0.5 md:gap-1"
aria-label={intl.formatMessage(messages.imdbUserScore, {
score: imdbRating.criticsScore,
})}
>
<ImdbLogo
className="h-2.5 w-4 shrink-0 md:h-3 md:w-5"
aria-hidden="true"
/>
<span>{imdbRating.criticsScore}</span>
</span>
)}
{hasTmdbScore && (
<span
className="flex min-w-0 items-center gap-0.5 md:gap-1"
aria-label={intl.formatMessage(messages.tmdbUserScore, {
score: Math.round(userScore * 10),

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we keep the original rating scale (I prefer this, as users will see the same value as on the external website) or standardize everything to a 0–100% scale?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We keep the original rating scale. We just display the score from the websites, we're not customizing / modifying it on Seerr.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Understood. I will keep the original provider scales and display the TMDB score on its 0-10 scale.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Correction after checking the provider display: TMDB presents its User Score as a percentage, including in the approved Design A examples. I will therefore keep RT and TMDB as percentages and IMDb on its 0-10 scale.

})}
>
<TmdbLogo
className="h-3.5 w-4 shrink-0 md:h-4 md:w-5"
aria-hidden="true"
/>
<span>{Math.round(userScore * 10)}%</span>
</span>
)}
</div>
);
};

export default TitleCardRatings;
12 changes: 11 additions & 1 deletion src/components/TitleCard/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -62,6 +63,7 @@ const TitleCard = ({
year,
title,
status,
userScore,
mediaType,
isAddedToWatchlist = false,
inProgress = false,
Expand Down Expand Up @@ -522,7 +524,7 @@ const TitleCard = ({
{year && <div className="text-sm font-medium">{year}</div>}

<h1
className="whitespace-normal text-xl font-bold leading-tight"
className="whitespace-normal text-lg font-bold leading-tight"
style={{
WebkitLineClamp: 3,
display: '-webkit-box',
Expand Down Expand Up @@ -552,6 +554,14 @@ const TitleCard = ({
>
{summary}
</div>
{typeof userScore === 'number' && userScore > 0 && (
<TitleCardRatings
id={id}
mediaType={mediaType}
userScore={userScore}
visible={showDetail}
/>
)}
</div>
</div>
</Link>
Expand Down
5 changes: 5 additions & 0 deletions src/i18n/locale/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down