mirror of
https://github.com/agregarr/agregarr.git
synced 2026-01-06 02:19:40 -06:00
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:
@@ -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
|
||||
|
||||
@@ -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
149
server/api/imdbRatings.ts
Normal 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;
|
||||
@@ -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}`,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user