feat: Add age rating as first genre with IMDb parental guide link

- Extract age ratings from TMDB API with regional fallback (US)
- Add age rating as first genre in metadata response
- Include IMDb parental guide link as first genre link
- Add configurable toggle for age rating display
- Implement caching for age rating data (1 hour TTL)
- Support both movies and TV shows with proper error handling
This commit is contained in:
Bimal Timilsina
2025-08-21 18:02:45 +05:45
parent 77ef89c894
commit d88f63d13c
8 changed files with 200 additions and 34 deletions
+92
View File
@@ -0,0 +1,92 @@
require("dotenv").config();
const { TMDBClient } = require("../utils/tmdbClient");
const Utils = require("../utils/parseProps");
const moviedb = new TMDBClient(process.env.TMDB_API);
// Cache for age ratings
const ageRatingCache = new Map();
const CACHE_TTL = 1000 * 60 * 60; // 1 hour
/**
* Get age rating for a movie from TMDB
*/
async function getMovieAgeRating(tmdbId, language) {
try {
const releaseDates = await moviedb.movieReleaseDates({ id: tmdbId });
const userRegion = language.split("-")[1] || "US";
// Try user's region first
let ageRating = Utils.parseCertification(releaseDates, language);
// If no rating found for user's region, fallback to US
if (!ageRating && userRegion !== "US") {
ageRating = Utils.parseCertification(releaseDates, "en-US");
}
return ageRating || null;
} catch (error) {
console.error(`Error fetching age rating for movie ${tmdbId}:`, error.message);
return null;
}
}
/**
* Get age rating for a TV show from TMDB
*/
async function getTvAgeRating(tmdbId, language) {
try {
const contentRatings = await moviedb.tvContentRatings({ id: tmdbId });
const userRegion = language.split("-")[1] || "US";
// Find rating for user's region
let ageRating = null;
const userRegionRating = contentRatings.results.find(
rating => rating.iso_3166_1 === userRegion
);
if (userRegionRating && userRegionRating.rating) {
ageRating = userRegionRating.rating;
} else {
// Fallback to US rating
const usRating = contentRatings.results.find(
rating => rating.iso_3166_1 === "US"
);
if (usRating && usRating.rating) {
ageRating = usRating.rating;
}
}
return ageRating || null;
} catch (error) {
console.error(`Error fetching age rating for TV show ${tmdbId}:`, error.message);
return null;
}
}
/**
* Get cached age rating
*/
async function getCachedAgeRating(tmdbId, type, language) {
if (!tmdbId) return null;
const cacheKey = `${type}-${tmdbId}-${language}`;
const cached = ageRatingCache.get(cacheKey);
if (cached && (Date.now() - cached.timestamp) < CACHE_TTL) {
return cached.rating;
}
try {
const rating = type === "movie"
? await getMovieAgeRating(tmdbId, language)
: await getTvAgeRating(tmdbId, language);
ageRatingCache.set(cacheKey, { rating, timestamp: Date.now() });
return rating;
} catch (err) {
console.error(`Error fetching age rating for ${type} ${tmdbId}:`, err.message);
return null;
}
}
module.exports = { getCachedAgeRating };
+39 -16
View File
@@ -5,11 +5,12 @@ const moviedb = new TMDBClient(process.env.TMDB_API);
const { getEpisodes } = require("./getEpisodes");
const { getLogo, getTvLogo } = require("./getLogo");
const { getImdbRating } = require("./getImdbRating");
const { getCachedAgeRating } = require("./getAgeRating");
const { checkSeasonsAndReport } = require("../utils/checkSeasons");
// Configuration
const CACHE_TTL = 1000 * 60 * 60; // 1 hour
const blacklistLogoUrls = [ "https://assets.fanart.tv/fanart/tv/0/hdtvlogo/-60a02798b7eea.png" ];
const blacklistLogoUrls = ["https://assets.fanart.tv/fanart/tv/0/hdtvlogo/-60a02798b7eea.png"];
// Cache
const cache = new Map();
@@ -28,21 +29,27 @@ async function getCachedImdbRating(imdbId, type) {
}
// Helper functions
const getCacheKey = (type, language, tmdbId, rpdbkey) =>
`${type}-${language}-${tmdbId}-${rpdbkey}`;
const getCacheKey = (type, language, tmdbId, rpdbkey, showAgeRatingInGenres = true) =>
`${type}-${language}-${tmdbId}-${rpdbkey}-ageRating:${showAgeRatingInGenres}`;
const processLogo = (logo) => {
if (!logo || blacklistLogoUrls.includes(logo)) return null;
return logo.replace("http://", "https://");
};
const buildLinks = (imdbRating, imdbId, title, type, genres, credits, language, castCount) => [
const buildLinks = (imdbRating, imdbId, title, type, genres, credits, language, castCount, ageRating = null, showAgeRatingInGenres = true) => [
Utils.parseImdbLink(imdbRating, imdbId),
Utils.parseShareLink(title, imdbId, type),
...Utils.parseGenreLink(genres, type, language),
...Utils.parseGenreLink(genres, type, language, imdbId, ageRating, showAgeRatingInGenres),
...Utils.parseCreditsLink(credits, castCount)
];
// Helper function to add age rating to genres
const addAgeRatingToGenres = (ageRating, genres, showAgeRatingInGenres = true) => {
if (!ageRating || !showAgeRatingInGenres) return genres;
return [ageRating, ...genres];
};
// Movie specific functions
const fetchMovieData = async (tmdbId, language) => {
return await moviedb.movieInfo({
@@ -53,13 +60,17 @@ const fetchMovieData = async (tmdbId, language) => {
};
const buildMovieResponse = async (res, type, language, tmdbId, rpdbkey, config = {}) => {
const [poster, logo, imdbRatingRaw] = await Promise.all([
const [poster, logo, imdbRatingRaw, ageRating] = await Promise.all([
Utils.parsePoster(type, tmdbId, res.poster_path, language, rpdbkey),
getLogo(tmdbId, language, res.original_language).catch(e => {
console.warn(`Error fetching logo for movie ${tmdbId}:`, e.message);
return null;
}),
getCachedImdbRating(res.external_ids?.imdb_id, type),
getCachedAgeRating(tmdbId, type, language).catch(e => {
console.warn(`Error fetching age rating for movie ${tmdbId}:`, e.message);
return null;
}),
]);
const imdbRating = imdbRatingRaw || res.vote_average?.toFixed(1) || "N/A";
@@ -67,12 +78,15 @@ const buildMovieResponse = async (res, type, language, tmdbId, rpdbkey, config =
const returnImdbId = config.returnImdbId === true || config.returnImdbId === "true";
const hideInCinemaTag = config.hideInCinemaTag === true || config.hideInCinemaTag === "true";
const parsedGenres = Utils.parseGenres(res.genres);
const showAgeRatingInGenres = config.showAgeRatingInGenres !== false; // Default to true
const response = {
imdb_id: res.imdb_id,
country: Utils.parseCoutry(res.production_countries),
description: res.overview,
director: Utils.parseDirector(res.credits),
genre: Utils.parseGenres(res.genres),
genre: addAgeRatingToGenres(ageRating, parsedGenres, showAgeRatingInGenres),
imdbRating,
name: res.title,
released: new Date(res.release_date),
@@ -85,10 +99,11 @@ const buildMovieResponse = async (res, type, language, tmdbId, rpdbkey, config =
poster,
runtime: Utils.parseRunTime(res.runtime),
id: returnImdbId ? res.imdb_id : `tmdb:${tmdbId}`,
genres: Utils.parseGenres(res.genres),
genres: addAgeRatingToGenres(ageRating, parsedGenres, showAgeRatingInGenres),
ageRating,
releaseInfo: res.release_date ? res.release_date.substr(0, 4) : "",
trailerStreams: Utils.parseTrailerStream(res.videos),
links: buildLinks(imdbRating, res.imdb_id, res.title, type, res.genres, res.credits, language, castCount),
links: buildLinks(imdbRating, res.imdb_id, res.title, type, res.genres, res.credits, language, castCount, ageRating, showAgeRatingInGenres),
behaviorHints: {
defaultVideoId: res.imdb_id ? res.imdb_id : `tmdb:${res.id}`,
hasScheduledVideos: false
@@ -114,7 +129,7 @@ const fetchTvData = async (tmdbId, language) => {
const buildTvResponse = async (res, type, language, tmdbId, rpdbkey, config = {}) => {
const runtime = res.episode_run_time?.[0] ?? res.last_episode_to_air?.runtime ?? res.next_episode_to_air?.runtime ?? null;
const [poster, logo, imdbRatingRaw, episodes] = await Promise.all([
const [poster, logo, imdbRatingRaw, episodes, ageRating] = await Promise.all([
Utils.parsePoster(type, tmdbId, res.poster_path, language, rpdbkey),
getTvLogo(res.external_ids?.tvdb_id, res.id, language, res.original_language).catch(e => {
console.warn(`Error fetching logo for series ${tmdbId}:`, e.message);
@@ -126,6 +141,10 @@ const buildTvResponse = async (res, type, language, tmdbId, rpdbkey, config = {}
}).catch(e => {
console.warn(`Error fetching episodes for series ${tmdbId}:`, e.message);
return [];
}),
getCachedAgeRating(tmdbId, type, language).catch(e => {
console.warn(`Error fetching age rating for series ${tmdbId}:`, e.message);
return null;
})
]);
@@ -133,11 +152,13 @@ const buildTvResponse = async (res, type, language, tmdbId, rpdbkey, config = {}
const castCount = config.castCount
const returnImdbId = config.returnImdbId === true || config.returnImdbId === "true";
const hideInCinemaTag = config.hideInCinemaTag === true || config.hideInCinemaTag === "true";
const parsedGenres = Utils.parseGenres(res.genres);
const showAgeRatingInGenres = config.showAgeRatingInGenres !== false; // Default to true
const response = {
country: Utils.parseCoutry(res.production_countries),
description: res.overview,
genre: Utils.parseGenres(res.genres),
genre: addAgeRatingToGenres(ageRating, parsedGenres, showAgeRatingInGenres),
imdbRating,
imdb_id: res.external_ids.imdb_id,
name: res.name,
@@ -151,10 +172,11 @@ const buildTvResponse = async (res, type, language, tmdbId, rpdbkey, config = {}
background: `https://image.tmdb.org/t/p/original${res.backdrop_path}`,
slug: Utils.parseSlug(type, res.name, res.external_ids.imdb_id),
id: returnImdbId ? res.imdb_id : `tmdb:${tmdbId}`,
genres: Utils.parseGenres(res.genres),
genres: addAgeRatingToGenres(ageRating, parsedGenres, showAgeRatingInGenres),
ageRating,
releaseInfo: Utils.parseYear(res.status, res.first_air_date, res.last_air_date),
videos: episodes || [],
links: buildLinks(imdbRating, res.external_ids.imdb_id, res.name, type, res.genres, res.credits, language, castCount),
links: buildLinks(imdbRating, res.external_ids.imdb_id, res.name, type, res.genres, res.credits, language, castCount, ageRating, showAgeRatingInGenres),
trailers: Utils.parseTrailers(res.videos),
trailerStreams: Utils.parseTrailerStream(res.videos),
behaviorHints: {
@@ -184,15 +206,16 @@ const buildTvResponse = async (res, type, language, tmdbId, rpdbkey, config = {}
// Main function
async function getMeta(type, language, tmdbId, rpdbkey, config = {}) {
const cacheKey = getCacheKey(type, language, tmdbId, rpdbkey);
const showAgeRatingInGenres = config.showAgeRatingInGenres !== false; // Default to true
const cacheKey = getCacheKey(type, language, tmdbId, rpdbkey, showAgeRatingInGenres);
const cachedData = cache.get(cacheKey);
if (cachedData && (Date.now() - cachedData.timestamp) < CACHE_TTL) {
return Promise.resolve({ meta: cachedData.data });
}
try {
const meta = await (type === "movie" ?
const meta = await (type === "movie" ?
fetchMovieData(tmdbId, language).then(res => buildMovieResponse(res, type, language, tmdbId, rpdbkey, config)) :
fetchTvData(tmdbId, language).then(res => buildTvResponse(res, type, language, tmdbId, rpdbkey, config))
);
+27 -6
View File
@@ -87,8 +87,18 @@ function parseShareLink(title, imdb_id, type) {
};
}
function parseGenreLink(genres, type, language) {
return genres.map((genre) => {
function parseImdbParentalGuideLink(imdbId, ageRating) {
if (!imdbId || !ageRating) return null;
return {
name: ageRating,
category: "Genres",
url: `https://www.imdb.com/title/${imdbId}/parentalguide`
};
}
function parseGenreLink(genres, type, language, imdbId = null, ageRating = null, showAgeRatingInGenres = true) {
const genreLinks = genres.map((genre) => {
return {
name: genre.name,
category: "Genres",
@@ -99,6 +109,16 @@ function parseGenreLink(genres, type, language) {
)}`,
};
});
// Add IMDb parental guide link as first genre if available and enabled
if (showAgeRatingInGenres) {
const parentalGuideLink = parseImdbParentalGuideLink(imdbId, ageRating);
if (parentalGuideLink) {
return [parentalGuideLink, ...genreLinks];
}
}
return genreLinks;
}
function parseCreditsLink(credits, castCount) {
@@ -151,7 +171,7 @@ function parseRunTime(runtime) {
if (runtime === 0 || !runtime) {
return "";
}
const hours = Math.floor(runtime / 60);
const minutes = runtime % 60;
@@ -168,12 +188,12 @@ function parseCreatedBy(created_by) {
function parseConfig(catalogChoices) {
let config = {};
// If catalogChoices is null, undefined or empty, return empty object
if (!catalogChoices) {
return config;
}
try {
// Try to decompress with lz-string
const decoded = decompressFromEncodedURIComponent(catalogChoices);
@@ -200,7 +220,7 @@ async function parsePoster(type, id, poster, language, rpdbkey) {
}
function parseMedia(el, type, genreList = []) {
const genres = Array.isArray(el.genre_ids)
const genres = Array.isArray(el.genre_ids)
? el.genre_ids.map(genre => genreList.find((x) => x.id === genre)?.name || 'Unknown')
: [];
@@ -249,6 +269,7 @@ module.exports = {
parseTrailerStream,
parseImdbLink,
parseShareLink,
parseImdbParentalGuideLink,
parseGenreLink,
parseCreditsLink,
parseCoutry,