import * as childProcess from 'node:child_process'; import type { TmdbMediaType } from '../../../shared/media-kind'; import type { TmdbConfig } from '../../../types/integrations'; import type { StatsTmdbSearchResult } from '../../../types/stats-http-contract'; export const TMDB_API_BASE_URL = 'https://api.themoviedb.org/3'; const TMDB_POSTER_BASE_URL = 'https://image.tmdb.org/t/p/w500'; const REQUEST_TIMEOUT_MS = 8_000; const API_KEY_COMMAND_RETRY_MS = 30_000; const ANIMATION_GENRE_ID = 16; export type TmdbSearchResult = StatsTmdbSearchResult; export interface TmdbTitleDetails { tmdbId: number; tmdbType: TmdbMediaType; titleEnglish: string | null; titleNative: string | null; /** English synopsis, falling back to the Japanese one. */ description: string | null; posterUrl: string | null; episodesTotal: number | null; year: number | null; originalLanguage: string; isAnimation: boolean; /** Every name TMDB knows for the title, used for exact-title matching. */ allTitles: string[]; } export interface TmdbClient { search(query: string): Promise; getDetails(tmdbType: TmdbMediaType, tmdbId: number): Promise; } export class TmdbApiKeyMissingError extends Error { constructor() { super('TMDB API key not configured. Set tmdb.apiKey or tmdb.apiKeyCommand.'); this.name = 'TmdbApiKeyMissingError'; } } export class TmdbRequestError extends Error { constructor( message: string, readonly status: number, ) { super(message); this.name = 'TmdbRequestError'; } } function execCommand(command: string): Promise { return new Promise((resolve, reject) => { childProcess.exec(command, { timeout: 10_000 }, (err, stdout) => { if (err) { reject(err); return; } resolve(stdout); }); }); } /** * Resolves the key in priority order: the user's literal `apiKey`, then the * output of `apiKeyCommand`, then the key bundled into release builds. */ export async function resolveTmdbApiKey( config: TmdbConfig | undefined, bundledKey: string | null = null, ): Promise { const literal = config?.apiKey?.trim(); if (literal) return literal; const command = config?.apiKeyCommand?.trim(); if (command) { try { const key = (await execCommand(command)).trim(); if (key.length > 0) return key; } catch { /* fall through to the bundled key */ } } return bundledKey; } /** Cache successful command output until either credential setting changes. */ export function createTmdbApiKeyResolver( getConfig: () => TmdbConfig | undefined, getBundledKey: () => string | null = () => null, ): () => Promise { let state: | { apiKey: string | undefined; apiKeyCommand: string | undefined; pending: Promise | null; retryAfterMs: number; } | undefined; return async () => { const config = getConfig(); if ( !state || state.apiKey !== config?.apiKey || state.apiKeyCommand !== config?.apiKeyCommand ) { state = { apiKey: config?.apiKey, apiKeyCommand: config?.apiKeyCommand, pending: null, retryAfterMs: 0, }; } const current = state; const literal = current.apiKey?.trim(); if (literal) return literal; if (!current.apiKeyCommand?.trim()) return getBundledKey(); if (Date.now() < current.retryAfterMs) return getBundledKey(); current.pending ??= resolveTmdbApiKey(current).then((key) => { if (!key) { current.retryAfterMs = Date.now() + API_KEY_COMMAND_RETRY_MS; current.pending = null; } return key; }); const key = await current.pending; return key ?? getBundledKey(); }; } interface RawSearchItem { media_type?: string; id?: number; name?: string; original_name?: string; title?: string; original_title?: string; original_language?: string; overview?: string; poster_path?: string | null; first_air_date?: string; release_date?: string; genre_ids?: number[]; } interface RawTranslation { iso_639_1?: string; data?: { name?: string; title?: string; overview?: string }; } interface RawDetails extends RawSearchItem { number_of_episodes?: number; genres?: Array<{ id?: number }>; alternative_titles?: { results?: Array<{ title?: string }>; titles?: Array<{ title?: string }> }; translations?: { translations?: RawTranslation[] }; } function nonEmpty(value: unknown): string | null { return typeof value === 'string' && value.trim() ? value.trim() : null; } function yearOf(date: string | undefined): number | null { const year = Number.parseInt(date?.slice(0, 4) ?? '', 10); return Number.isFinite(year) && year > 0 ? year : null; } function posterUrlOf(path: string | null | undefined): string | null { return path ? `${TMDB_POSTER_BASE_URL}${path}` : null; } function mediaTypeOf(value: unknown): TmdbMediaType | null { return value === 'tv' || value === 'movie' ? value : null; } function normalizeSearchItem(item: RawSearchItem): TmdbSearchResult | null { const tmdbType = mediaTypeOf(item.media_type); if (!tmdbType || typeof item.id !== 'number') return null; const title = nonEmpty(item.name) ?? nonEmpty(item.title); const originalTitle = nonEmpty(item.original_name) ?? nonEmpty(item.original_title) ?? title; if (!title || !originalTitle) return null; return { tmdbId: item.id, tmdbType, title, originalTitle, originalLanguage: item.original_language ?? '', overview: nonEmpty(item.overview), posterUrl: posterUrlOf(item.poster_path), year: yearOf(item.first_air_date ?? item.release_date), isAnimation: (item.genre_ids ?? []).includes(ANIMATION_GENRE_ID), }; } function normalizeDetails(tmdbType: TmdbMediaType, raw: RawDetails): TmdbTitleDetails | null { if (typeof raw.id !== 'number') return null; const localizedTitle = nonEmpty(raw.name) ?? nonEmpty(raw.title); const originalTitle = nonEmpty(raw.original_name) ?? nonEmpty(raw.original_title); const originalLanguage = raw.original_language ?? ''; const translations = raw.translations?.translations ?? []; const translationFor = (language: string) => translations.find((entry) => entry.iso_639_1 === language)?.data; const english = translationFor('en'); const japanese = translationFor('ja'); const englishTitle = nonEmpty(english?.name) ?? nonEmpty(english?.title) ?? (localizedTitle && localizedTitle !== originalTitle ? localizedTitle : null); const nativeTitle = originalLanguage === 'ja' ? originalTitle : (nonEmpty(japanese?.name) ?? nonEmpty(japanese?.title)); const alternativeTitles = [ ...(raw.alternative_titles?.results ?? []), ...(raw.alternative_titles?.titles ?? []), ].map((entry) => nonEmpty(entry.title)); const translatedTitles = translations.map( (entry) => nonEmpty(entry.data?.name) ?? nonEmpty(entry.data?.title), ); const allTitles = [ ...new Set( [ localizedTitle, originalTitle, englishTitle, nativeTitle, ...translatedTitles, ...alternativeTitles, ].filter((title): title is string => Boolean(title)), ), ]; return { tmdbId: raw.id, tmdbType, titleEnglish: englishTitle, titleNative: nativeTitle, description: nonEmpty(raw.overview) ?? nonEmpty(english?.overview) ?? nonEmpty(japanese?.overview), posterUrl: posterUrlOf(raw.poster_path), episodesTotal: tmdbType === 'movie' ? 1 : typeof raw.number_of_episodes === 'number' && raw.number_of_episodes > 0 ? raw.number_of_episodes : null, year: yearOf(raw.first_air_date ?? raw.release_date), originalLanguage, isAnimation: (raw.genres ?? []).some((genre) => genre.id === ANIMATION_GENRE_ID), allTitles, }; } // TMDB issues two kinds of credential: a short v3 key that travels as a query // parameter and a long v4 read token (a JWT) that goes in the Authorization // header. Users paste whichever the settings page showed them. function isV4Token(apiKey: string): boolean { return apiKey.startsWith('eyJ'); } export function createTmdbClient(deps: { resolveApiKey: () => Promise; fetch?: typeof fetch; baseUrl?: string; }): TmdbClient { const fetchImpl = deps.fetch ?? fetch; const baseUrl = (deps.baseUrl ?? TMDB_API_BASE_URL).replace(/\/+$/, ''); async function request(path: string, params: Record): Promise { const apiKey = await deps.resolveApiKey(); if (!apiKey) throw new TmdbApiKeyMissingError(); const url = new URL(`${baseUrl}${path}`); for (const [key, value] of Object.entries(params)) url.searchParams.set(key, value); const headers: Record = { Accept: 'application/json' }; if (isV4Token(apiKey)) { headers.Authorization = `Bearer ${apiKey}`; } else { url.searchParams.set('api_key', apiKey); } const res = await fetchImpl(url, { headers, signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS) }); if (res.status === 404) return null; if (!res.ok) { throw new TmdbRequestError( `TMDB request failed: ${res.status} ${res.statusText}`, res.status, ); } return (await res.json()) as T; } return { async search(query) { const trimmed = query.trim(); if (!trimmed) return []; const payload = await request<{ results?: RawSearchItem[] }>('/search/multi', { query: trimmed, include_adult: 'false', language: 'en-US', page: '1', }); return (payload?.results ?? []) .map(normalizeSearchItem) .filter((item): item is TmdbSearchResult => item !== null); }, async getDetails(tmdbType, tmdbId) { const raw = await request(`/${tmdbType}/${tmdbId}`, { language: 'en-US', append_to_response: 'alternative_titles,translations', }); return raw ? normalizeDetails(tmdbType, raw) : null; }, }; }