From d88f63d13c45ed6a97f41a72b1bede570d97034b Mon Sep 17 00:00:00 2001 From: Bimal Timilsina Date: Thu, 21 Aug 2025 18:02:45 +0545 Subject: [PATCH] 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 --- addon/lib/getAgeRating.js | 92 +++++++++++++++++++ addon/lib/getMeta.js | 55 +++++++---- addon/utils/parseProps.js | 33 +++++-- .../src/components/AgeRatingDisplayToggle.tsx | 18 ++++ configure/src/contexts/ConfigContext.tsx | 26 +++--- configure/src/contexts/config.ts | 2 + configure/src/lib/config.ts | 4 +- configure/src/pages/Others.tsx | 4 + 8 files changed, 200 insertions(+), 34 deletions(-) create mode 100644 addon/lib/getAgeRating.js create mode 100644 configure/src/components/AgeRatingDisplayToggle.tsx diff --git a/addon/lib/getAgeRating.js b/addon/lib/getAgeRating.js new file mode 100644 index 0000000..5cf42aa --- /dev/null +++ b/addon/lib/getAgeRating.js @@ -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 }; diff --git a/addon/lib/getMeta.js b/addon/lib/getMeta.js index 12c1ffd..7e37ccf 100644 --- a/addon/lib/getMeta.js +++ b/addon/lib/getMeta.js @@ -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)) ); diff --git a/addon/utils/parseProps.js b/addon/utils/parseProps.js index b1980be..98b3dc8 100644 --- a/addon/utils/parseProps.js +++ b/addon/utils/parseProps.js @@ -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, diff --git a/configure/src/components/AgeRatingDisplayToggle.tsx b/configure/src/components/AgeRatingDisplayToggle.tsx new file mode 100644 index 0000000..fca1c83 --- /dev/null +++ b/configure/src/components/AgeRatingDisplayToggle.tsx @@ -0,0 +1,18 @@ +import { Switch } from "@/components/ui/switch"; +import { useConfig } from "@/contexts/ConfigContext"; + +export function AgeRatingDisplayToggle() { + const { showAgeRatingInGenres, setShowAgeRatingInGenres } = useConfig(); + + return ( + <> +
+

Display Age Rating

+

+ Display age rating as first genre +

+
+ + + ); +} diff --git a/configure/src/contexts/ConfigContext.tsx b/configure/src/contexts/ConfigContext.tsx index 0525ddc..4f3f51c 100644 --- a/configure/src/contexts/ConfigContext.tsx +++ b/configure/src/contexts/ConfigContext.tsx @@ -1,9 +1,9 @@ import React, { createContext, useContext, useEffect, useState } from "react"; import { ConfigContext, type ConfigContextType, type CatalogConfig } from "./config"; -import { - baseCatalogs, - authCatalogs, - streamingCatalogs +import { + baseCatalogs, + authCatalogs, + streamingCatalogs } from "@/data/catalogs"; import { decompressFromEncodedURIComponent } from 'lz-string'; @@ -30,6 +30,7 @@ export function ConfigProvider({ children }: { children: React.ReactNode }) { const [searchEnabled, setSearchEnabled] = useState(true); const [hideInCinemaTag, setHideInCinemaTag] = useState(false); const [castCount, setCastCount] = useState(5); + const [showAgeRatingInGenres, setShowAgeRatingInGenres] = useState(true); const loadDefaultCatalogs = () => { const defaultCatalogs = baseCatalogs.map(catalog => ({ @@ -45,7 +46,7 @@ export function ConfigProvider({ children }: { children: React.ReactNode }) { const path = window.location.pathname.split('/')[1]; const decompressedConfig = decompressFromEncodedURIComponent(path); const config = JSON.parse(decompressedConfig); - + if (config.rpdbkey) setRpdbkey(config.rpdbkey); if (config.mdblistkey) setMdblistkey(config.mdblistkey); if (config.geminikey) setGeminiKey(config.geminikey); @@ -59,7 +60,8 @@ export function ConfigProvider({ children }: { children: React.ReactNode }) { if (config.language) setLanguage(config.language); if (config.hideInCinemaTag) setHideInCinemaTag(config.hideInCinemaTag === "true" || config.hideInCinemaTag === true); if (config.castCount !== undefined) setCastCount(config.castCount === "Unlimited" ? undefined : Number(config.castCount)); - + if (config.showAgeRatingInGenres !== undefined) setShowAgeRatingInGenres(config.showAgeRatingInGenres === "true" || config.showAgeRatingInGenres === true); + if (config.catalogs) { const catalogsWithNames = config.catalogs.map(catalog => { const existingCatalog = allCatalogs.find( @@ -68,7 +70,7 @@ export function ConfigProvider({ children }: { children: React.ReactNode }) { return { ...catalog, name: existingCatalog?.name || catalog.id, - enabled: catalog.enabled !== undefined ? catalog.enabled : true + enabled: catalog.enabled !== undefined ? catalog.enabled : true }; }); setCatalogs(catalogsWithNames); @@ -81,15 +83,15 @@ export function ConfigProvider({ children }: { children: React.ReactNode }) { setStreaming(Array.from(selectedStreamingServices) as string[]); } else { - loadDefaultCatalogs(); + loadDefaultCatalogs(); } - + if (config.searchEnabled) setSearchEnabled(config.searchEnabled === "true"); - + window.history.replaceState({}, '', '/configure'); } catch (error) { console.error('Error loading config from URL:', error); - loadDefaultCatalogs(); + loadDefaultCatalogs(); } }; @@ -119,6 +121,7 @@ export function ConfigProvider({ children }: { children: React.ReactNode }) { searchEnabled, hideInCinemaTag, castCount, + showAgeRatingInGenres, setRpdbkey, setGeminiKey, setMdblistkey, @@ -135,6 +138,7 @@ export function ConfigProvider({ children }: { children: React.ReactNode }) { setSearchEnabled, setHideInCinemaTag, setCastCount, + setShowAgeRatingInGenres, loadConfigFromUrl }; diff --git a/configure/src/contexts/config.ts b/configure/src/contexts/config.ts index c9305f2..9779d4f 100644 --- a/configure/src/contexts/config.ts +++ b/configure/src/contexts/config.ts @@ -25,6 +25,7 @@ export type ConfigContextType = { searchEnabled: boolean; hideInCinemaTag: boolean; castCount: number | undefined; + showAgeRatingInGenres: boolean; setRpdbkey: (rpdbkey: string) => void; setGeminiKey: (geminikey: string) => void; setMdblistkey: (mdblistkey: string) => void; @@ -41,6 +42,7 @@ export type ConfigContextType = { setSearchEnabled: (enabled: boolean) => void; setHideInCinemaTag: (hide: boolean) => void; setCastCount: (count: number | undefined) => void; + setShowAgeRatingInGenres: (show: boolean) => void; loadConfigFromUrl: () => void; }; diff --git a/configure/src/lib/config.ts b/configure/src/lib/config.ts index 158e09f..a62d62c 100644 --- a/configure/src/lib/config.ts +++ b/configure/src/lib/config.ts @@ -21,6 +21,7 @@ interface AddonConfig { }>; hideInCinemaTag?: boolean; castCount?: number; + showAgeRatingInGenres?: boolean; } export function generateAddonUrl(config: AddonConfig): string { @@ -46,6 +47,7 @@ export function generateAddonUrl(config: AddonConfig): string { searchEnabled: config.searchEnabled === false ? "false" : undefined, hideInCinemaTag: config.hideInCinemaTag === true ? "true" : undefined, castCount: typeof config.castCount === "number" ? config.castCount : undefined, + showAgeRatingInGenres: config.showAgeRatingInGenres === true ? "true" : undefined, }; const cleanConfig = Object.fromEntries( @@ -53,6 +55,6 @@ export function generateAddonUrl(config: AddonConfig): string { ); const compressed = compressToEncodedURIComponent(JSON.stringify(cleanConfig)); - + return `${window.location.origin}/${compressed}/manifest.json`; } \ No newline at end of file diff --git a/configure/src/pages/Others.tsx b/configure/src/pages/Others.tsx index f8bcab0..de2194c 100644 --- a/configure/src/pages/Others.tsx +++ b/configure/src/pages/Others.tsx @@ -2,6 +2,7 @@ import { Card } from "@/components/ui/card"; import { Switch } from "@/components/ui/switch"; import { useConfig } from "@/contexts/ConfigContext"; import { AgeRatingSelect } from "@/components/AgeRatingSelect"; +import { AgeRatingDisplayToggle } from "@/components/AgeRatingDisplayToggle"; import { SearchToggle } from "@/components/SearchToggle"; const Others = () => { @@ -106,6 +107,9 @@ const Others = () => { + + + );