fix(ratings): iMDb TV show ratings added, RT fallback added

Adds our own external IMDb ratings proxy api to enable TV show ratings in preview collections. Adds
proper handling of RT items without rating.

re #130
This commit is contained in:
Tom Wheeler
2025-11-12 01:05:19 +13:00
parent 6f8c07d58e
commit 9b44106c8e
6 changed files with 245 additions and 84 deletions

View File

@@ -6259,12 +6259,17 @@ paths:
type: object
nullable: true
properties:
title:
imdbId:
type: string
url:
type: string
criticsScore:
example: tt1234567
rating:
type: number
nullable: true
example: 8.5
votes:
type: number
nullable: true
example: 123456
rt:
type: object
nullable: true
@@ -6292,7 +6297,7 @@ paths:
/ratings/tv/{tmdbId}:
get:
summary: Get TV show ratings
description: Returns Rotten Tomatoes ratings for a TV show
description: Returns IMDB and Rotten Tomatoes ratings for a TV show
tags:
- other
parameters:
@@ -6312,6 +6317,11 @@ paths:
schema:
type: integer
description: First air date year (optional for RT rating lookup)
- in: query
name: imdbId
schema:
type: string
description: IMDB ID (e.g. tt1234567) for IMDB rating lookup
responses:
'200':
description: TV show ratings
@@ -6320,6 +6330,21 @@ paths:
schema:
type: object
properties:
imdb:
type: object
nullable: true
properties:
imdbId:
type: string
example: tt1234567
rating:
type: number
nullable: true
example: 8.5
votes:
type: number
nullable: true
example: 123456
rt:
type: object
nullable: true

View File

@@ -31,15 +31,6 @@ export interface ImdbList {
totalItems: number;
}
/**
* IMDb Rating interface (from Radarr proxy)
*/
export interface ImdbRating {
title: string;
url: string;
criticsScore: number;
}
/**
* IMDb Top Lists enum for predefined lists
*/
@@ -359,59 +350,6 @@ class ImdbAPI extends ExternalAPI {
return 'IMDb List';
}
}
/**
* Get movie ratings from Radarr IMDB proxy API
*
* This uses the Radarr-hosted public IMDB proxy that aggregates ratings data.
* This is a best-effort API as IMDB's official API requires paid access.
*
* @param imdbId - IMDB ID (e.g., "tt1234567")
* @returns Rating data including title, URL, and critics score, or null if not found
*/
public async getMovieRatings(imdbId: string): Promise<ImdbRating | null> {
try {
const response = await this.axios.get<
{
ImdbId: string;
Title: string;
MovieRatings: {
Imdb: {
Count: number;
Value: number;
Type: string;
};
};
}[]
>(`https://api.radarr.video/v1/movie/imdb/${imdbId}`, {
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
},
});
if (!response.data?.length || response.data[0].ImdbId !== imdbId) {
return null;
}
return {
title: response.data[0].Title,
url: `https://www.imdb.com/title/${response.data[0].ImdbId}`,
criticsScore: response.data[0].MovieRatings.Imdb.Value,
};
} catch (error) {
logger.error(`Failed to fetch IMDB ratings for ${imdbId}:`, {
error: error instanceof Error ? error.message : 'Unknown error',
imdbId,
stack: error instanceof Error ? error.stack : undefined,
});
throw new Error(
`Failed to retrieve movie ratings: ${
error instanceof Error ? error.message : 'Unknown error'
}`
);
}
}
}
export default ImdbAPI;

149
server/api/imdbRatings.ts Normal file
View File

@@ -0,0 +1,149 @@
import ExternalAPI from '@server/api/externalapi';
import cacheManager from '@server/lib/cache';
import logger from '@server/logger';
/**
* IMDb Rating Response from Agregarr API
*/
export interface ImdbRatingResponse {
imdbId: string;
rating: number | null;
votes: number | null;
}
/**
* IMDb Ratings API client for fetching ratings from Agregarr's IMDb proxy
*
* This API supports both Movies and TV Shows.
* API Documentation: https://api.agregarr.org
*/
class ImdbRatingsAPI extends ExternalAPI {
constructor() {
super('https://api.agregarr.org', {
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
},
nodeCache: cacheManager.getCache('imdb').data,
});
}
/**
* Get ratings for one or more IMDb IDs
*
* @param imdbIds - Single IMDb ID or array of IMDb IDs (max 100 per request)
* @returns Array of rating responses
*/
public async getRatings(
imdbIds: string | string[]
): Promise<ImdbRatingResponse[]> {
try {
const ids = Array.isArray(imdbIds) ? imdbIds : [imdbIds];
if (ids.length === 0) {
return [];
}
if (ids.length > 100) {
logger.warn(
`Requested ${ids.length} IMDb ratings, but API supports max 100 per request. Splitting into batches.`,
{
label: 'IMDb Ratings API',
requestedCount: ids.length,
}
);
// Split into batches of 100
const batches: string[][] = [];
for (let i = 0; i < ids.length; i += 100) {
batches.push(ids.slice(i, i + 100));
}
// Fetch all batches in parallel
const results = await Promise.all(
batches.map((batch) => this.getRatings(batch))
);
// Flatten results
return results.flat();
}
// Build query string with multiple id parameters
const queryParams = ids.map((id) => `id=${encodeURIComponent(id)}`);
const url = `/api/ratings?${queryParams.join('&')}`;
const response = await this.get<ImdbRatingResponse[]>(
url,
undefined,
30000
);
logger.debug(`Fetched ${response.length} IMDb ratings`, {
label: 'IMDb Ratings API',
requestedCount: ids.length,
receivedCount: response.length,
});
return response;
} catch (error) {
logger.error('Failed to fetch IMDb ratings:', {
error: error instanceof Error ? error.message : 'Unknown error',
imdbIds: Array.isArray(imdbIds) ? imdbIds.length : 1,
stack: error instanceof Error ? error.stack : undefined,
});
throw new Error(
`Failed to retrieve IMDb ratings: ${
error instanceof Error ? error.message : 'Unknown error'
}`
);
}
}
/**
* Get rating for a single IMDb ID
*
* @param imdbId - IMDb ID (e.g., "tt0111161")
* @returns Rating response or null if not found
*/
public async getRating(imdbId: string): Promise<ImdbRatingResponse | null> {
try {
const results = await this.getRatings(imdbId);
return results.length > 0 ? results[0] : null;
} catch (error) {
logger.error(`Failed to fetch IMDb rating for ${imdbId}:`, {
error: error instanceof Error ? error.message : 'Unknown error',
imdbId,
stack: error instanceof Error ? error.stack : undefined,
});
throw error;
}
}
/**
* Check API health status
*
* @returns Health status information
*/
public async getHealth(): Promise<{
status: string;
lastUpdate: string;
totalRatings: number;
uptime: number;
}> {
try {
return await this.get('/api/health', undefined, 10000);
} catch (error) {
logger.error('Failed to fetch IMDb Ratings API health:', {
error: error instanceof Error ? error.message : 'Unknown error',
stack: error instanceof Error ? error.stack : undefined,
});
throw new Error(
`Failed to check API health: ${
error instanceof Error ? error.message : 'Unknown error'
}`
);
}
}
}
export default ImdbRatingsAPI;

View File

@@ -26,7 +26,7 @@ interface RTAlgoliaHit {
vanity: string;
aka: string[];
posterImageUrl: string;
rottenTomatoes: {
rottenTomatoes?: {
audienceScore: number;
criticsIconUrl: string;
wantToSeeCount: number;
@@ -135,6 +135,11 @@ class RottenTomatoes extends ExternalAPI {
return null;
}
// Check if RT ratings data exists
if (!movie.rottenTomatoes) {
return null;
}
return {
title: movie.title,
url: `https://www.rottentomatoes.com/m/${movie.vanity}`,
@@ -189,6 +194,11 @@ class RottenTomatoes extends ExternalAPI {
return null;
}
// Check if RT ratings data exists
if (!tvshow.rottenTomatoes) {
return null;
}
return {
title: tvshow.title,
url: `https://www.rottentomatoes.com/tv/${tvshow.vanity}`,

View File

@@ -1,5 +1,5 @@
import type { ImdbRating } from '@server/api/imdb';
import ImdbAPI from '@server/api/imdb';
import type { ImdbRatingResponse } from '@server/api/imdbRatings';
import ImdbRatingsAPI from '@server/api/imdbRatings';
import type { RTRating } from '@server/api/rottentomatoes';
import RottenTomatoes from '@server/api/rottentomatoes';
import logger from '@server/logger';
@@ -23,21 +23,27 @@ ratingsRoutes.get('/movie/:tmdbId', isAuthenticated(), async (req, res) => {
return res.status(400).json({ error: 'tmdbId is required' });
}
const imdbClient = new ImdbAPI();
const imdbClient = new ImdbRatingsAPI();
const rtClient = new RottenTomatoes();
let imdbRating: ImdbRating | null = null;
let imdbRating: ImdbRatingResponse | null = null;
let rtRating: RTRating | null = null;
// Fetch IMDB rating if we have an IMDB ID
if (imdbId) {
try {
imdbRating = await imdbClient.getMovieRatings(imdbId);
imdbRating = await imdbClient.getRating(imdbId);
logger.debug('IMDb rating fetched successfully', {
label: 'Ratings API',
imdbId,
rating: imdbRating?.rating,
});
} catch (error) {
logger.debug('Failed to fetch IMDB rating', {
logger.error('Failed to fetch IMDB rating', {
label: 'Ratings API',
imdbId,
error: error instanceof Error ? error.message : String(error),
stack: error instanceof Error ? error.stack : undefined,
});
}
}
@@ -74,21 +80,44 @@ ratingsRoutes.get('/movie/:tmdbId', isAuthenticated(), async (req, res) => {
/**
* GET /api/v1/ratings/tv/:tmdbId
* Get ratings for a TV show (RT only, IMDB proxy doesn't support TV)
* Get ratings for a TV show (IMDB + RT)
*/
ratingsRoutes.get('/tv/:tmdbId', isAuthenticated(), async (req, res) => {
try {
const tmdbId = Number(req.params.tmdbId);
const title = req.query.title as string;
const year = req.query.year ? Number(req.query.year) : undefined;
const imdbId = req.query.imdbId as string | undefined;
if (!tmdbId) {
return res.status(400).json({ error: 'tmdbId is required' });
}
const imdbClient = new ImdbRatingsAPI();
const rtClient = new RottenTomatoes();
let imdbRating: ImdbRatingResponse | null = null;
let rtRating: RTRating | null = null;
// Fetch IMDB rating if we have an IMDB ID
if (imdbId) {
try {
imdbRating = await imdbClient.getRating(imdbId);
logger.debug('IMDb rating fetched successfully', {
label: 'Ratings API',
imdbId,
rating: imdbRating?.rating,
});
} catch (error) {
logger.error('Failed to fetch IMDB rating', {
label: 'Ratings API',
imdbId,
error: error instanceof Error ? error.message : String(error),
stack: error instanceof Error ? error.stack : undefined,
});
}
}
// Fetch RT rating if we have title
if (title) {
try {
@@ -104,6 +133,7 @@ ratingsRoutes.get('/tv/:tmdbId', isAuthenticated(), async (req, res) => {
}
return res.status(200).json({
imdb: imdbRating,
rt: rtRating,
});
} catch (error) {

View File

@@ -58,9 +58,9 @@ interface PreviewItem {
interface ItemRatings {
imdb?: {
title: string;
url: string;
criticsScore: number;
imdbId: string;
rating: number | null;
votes: number | null;
} | null;
rt?: {
title: string;
@@ -156,6 +156,7 @@ const PreviewCollectionModal = ({
} | null>(null);
const iconRef = useRef<HTMLDivElement>(null);
const tooltipCloseTimer = useRef<NodeJS.Timeout | null>(null);
const tooltipRef = useRef<HTMLDivElement>(null);
const [downloadingItems, setDownloadingItems] = useState<Set<number>>(
new Set()
);
@@ -839,6 +840,7 @@ const PreviewCollectionModal = ({
: `/api/v1/ratings/tv/${item.tmdbId}`;
// Build query string with proper encoding
// Note: encodeURIComponent is necessary for OpenAPI validation
const queryParams = new URLSearchParams();
if (item.title)
queryParams.append(
@@ -850,10 +852,7 @@ const PreviewCollectionModal = ({
'year',
item.year.toString()
);
if (
item.imdbId &&
item.mediaType === 'movie'
)
if (item.imdbId)
queryParams.append('imdbId', item.imdbId);
const response = await axios.get(
@@ -895,13 +894,23 @@ const PreviewCollectionModal = ({
tooltipPosition &&
typeof window !== 'undefined'
? createPortal(
/* eslint-disable-next-line jsx-a11y/no-static-element-interactions */
<div
ref={tooltipRef}
className="fixed z-[9999] w-80 overflow-y-auto rounded-lg border border-gray-700 bg-gray-900 p-4 shadow-xl"
style={{
top: `${tooltipPosition.top}px`,
left: `${tooltipPosition.left}px`,
maxHeight: `${tooltipPosition.maxHeight}px`,
}}
onMouseDown={(e) => {
// Prevent modal backdrop from detecting this as an outside click
e.stopPropagation();
}}
onMouseUp={(e) => {
// Prevent modal backdrop from detecting this as an outside click
e.stopPropagation();
}}
onMouseEnter={() => {
// Cancel close when hovering tooltip
if (tooltipCloseTimer.current) {
@@ -939,7 +948,7 @@ const PreviewCollectionModal = ({
</span>
</div>
)}
{ratingsCache[item.tmdbId]?.imdb && (
{ratingsCache[item.tmdbId]?.imdb?.rating && (
<div className="flex items-center gap-2.5">
<img
src="/services/imdb.svg"
@@ -949,7 +958,7 @@ const PreviewCollectionModal = ({
<span className="text-base font-medium text-white">
{
ratingsCache[item.tmdbId]?.imdb
?.criticsScore
?.rating
}
/10
</span>