From 9b44106c8e18342329cd34cfc68ffdb5d7f61ccf Mon Sep 17 00:00:00 2001 From: Tom Wheeler Date: Wed, 12 Nov 2025 01:05:19 +1300 Subject: [PATCH] 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 --- agregarr-api.yml | 35 +++- server/api/imdb.ts | 62 -------- server/api/imdbRatings.ts | 149 ++++++++++++++++++ server/api/rottentomatoes.ts | 12 +- server/routes/ratings.ts | 44 +++++- .../PreviewCollectionModal/index.tsx | 27 ++-- 6 files changed, 245 insertions(+), 84 deletions(-) create mode 100644 server/api/imdbRatings.ts diff --git a/agregarr-api.yml b/agregarr-api.yml index 1c5508f..26355c5 100644 --- a/agregarr-api.yml +++ b/agregarr-api.yml @@ -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 diff --git a/server/api/imdb.ts b/server/api/imdb.ts index c400623..b9f013f 100644 --- a/server/api/imdb.ts +++ b/server/api/imdb.ts @@ -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 { - 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; diff --git a/server/api/imdbRatings.ts b/server/api/imdbRatings.ts new file mode 100644 index 0000000..0c56c9e --- /dev/null +++ b/server/api/imdbRatings.ts @@ -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 { + 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( + 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 { + 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; diff --git a/server/api/rottentomatoes.ts b/server/api/rottentomatoes.ts index 1cf9d6d..3469798 100644 --- a/server/api/rottentomatoes.ts +++ b/server/api/rottentomatoes.ts @@ -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}`, diff --git a/server/routes/ratings.ts b/server/routes/ratings.ts index d559def..24ae36b 100644 --- a/server/routes/ratings.ts +++ b/server/routes/ratings.ts @@ -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) { diff --git a/src/components/Collections/PreviewCollectionModal/index.tsx b/src/components/Collections/PreviewCollectionModal/index.tsx index 07c6e77..c82f3d0 100644 --- a/src/components/Collections/PreviewCollectionModal/index.tsx +++ b/src/components/Collections/PreviewCollectionModal/index.tsx @@ -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(null); const tooltipCloseTimer = useRef(null); + const tooltipRef = useRef(null); const [downloadingItems, setDownloadingItems] = useState>( 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 */
{ + // 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 = ({
)} - {ratingsCache[item.tmdbId]?.imdb && ( + {ratingsCache[item.tmdbId]?.imdb?.rating && (
{ ratingsCache[item.tmdbId]?.imdb - ?.criticsScore + ?.rating } /10