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