feat(stats): add TMDB metadata for live-action dramas in the Library (#252)

This commit is contained in:
sudacode authored and GitHub committed 2026-09-21 00:10:00 -07:00
1 parent 4dd30f44d1
commit 1508863dbb
76 files changed
+3595 -238

No files matched your search

@@ -0,0 +1,22 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import test from 'node:test';
import { BUNDLED_INTEGRATION_KEYS_FILENAME, readBundledTmdbApiKey } from './bundled-api-key.js';
test('readBundledTmdbApiKey reads the staged key and tolerates a missing or malformed file', () => {
const distDir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-bundled-key-'));
const filePath = path.join(distDir, BUNDLED_INTEGRATION_KEYS_FILENAME);
try {
assert.equal(readBundledTmdbApiKey(distDir), null);
fs.writeFileSync(filePath, '{"tmdbApiKey":" abc "}');
assert.equal(readBundledTmdbApiKey(distDir), 'abc');
fs.writeFileSync(filePath, '{"tmdbApiKey":""}');
assert.equal(readBundledTmdbApiKey(distDir), null);
fs.writeFileSync(filePath, 'not json');
assert.equal(readBundledTmdbApiKey(distDir), null);
} finally {
fs.rmSync(distDir, { recursive: true, force: true });
}
});
+20
View File
@@ -0,0 +1,20 @@
import fs from 'node:fs';
import path from 'node:path';
/**
* Release builds stage a project-owned TMDB key into dist/ (see
* scripts/bundled-integration-keys.mjs). Source checkouts and CI builds have no
* such file, and TMDB lookups then depend on the user's own `tmdb.apiKey`.
*/
export const BUNDLED_INTEGRATION_KEYS_FILENAME = 'bundled-integration-keys.json';
export function readBundledTmdbApiKey(distDir: string): string | null {
try {
const raw = fs.readFileSync(path.join(distDir, BUNDLED_INTEGRATION_KEYS_FILENAME), 'utf8');
const parsed = JSON.parse(raw) as { tmdbApiKey?: unknown };
const key = typeof parsed.tmdbApiKey === 'string' ? parsed.tmdbApiKey.trim() : '';
return key.length > 0 ? key : null;
} catch {
return null;
}
}
@@ -0,0 +1,108 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { createLiveActionMetadataResolver, titlesMatch } from './live-action-resolver.js';
import {
TmdbApiKeyMissingError,
type TmdbClient,
type TmdbSearchResult,
type TmdbTitleDetails,
} from './tmdb-client.js';
const silentLogger = { info: () => {}, warn: () => {} };
function searchResult(over: Partial<TmdbSearchResult> & { tmdbId: number }): TmdbSearchResult {
return {
tmdbType: 'tv',
title: 'Title',
originalTitle: 'Title',
originalLanguage: 'ja',
overview: null,
posterUrl: null,
year: null,
isAnimation: false,
...over,
};
}
function details(over: Partial<TmdbTitleDetails> & { tmdbId: number }): TmdbTitleDetails {
return {
tmdbType: 'tv',
titleEnglish: null,
titleNative: null,
description: null,
posterUrl: null,
episodesTotal: null,
year: null,
originalLanguage: 'ja',
isAnimation: false,
allTitles: [],
...over,
};
}
test('titlesMatch ignores case, width, and punctuation but not extra words', () => {
assert.equal(titlesMatch('Hanzawa Naoki', ['HANZAWA NAOKI!']), true);
assert.equal(titlesMatch('半沢直樹', ['半沢直樹']), true);
assert.equal(titlesMatch('Hanzawa Naoki', ['Hanzawa Naoki Season 2']), false);
assert.equal(titlesMatch('', ['']), false);
});
test('resolveByTitle only accepts a Japanese non-animated result whose known titles match exactly', async () => {
const detailCalls: number[] = [];
const client: TmdbClient = {
async search() {
return [
searchResult({ tmdbId: 1, title: 'Hanzawa Naoki', originalLanguage: 'ko' }),
searchResult({ tmdbId: 2, title: 'Hanzawa Naoki', isAnimation: true }),
searchResult({ tmdbId: 3, title: 'Hanzawa Naoki: The Movie' }),
searchResult({ tmdbId: 4, title: 'Hanzawa Naoki' }),
];
},
async getDetails(_type, tmdbId) {
detailCalls.push(tmdbId);
if (tmdbId === 3) return details({ tmdbId: 3, allTitles: ['Hanzawa Naoki: The Movie'] });
if (tmdbId === 4) return details({ tmdbId: 4, allTitles: ['Hanzawa Naoki', '半沢直樹'] });
return null;
},
};
const resolver = createLiveActionMetadataResolver(client, silentLogger);
const resolved = await resolver.resolveByTitle('hanzawa naoki');
assert.equal(resolved?.tmdbId, 4);
assert.deepEqual(detailCalls, [3, 4]);
});
test('resolveByTitle returns null when nothing matches or the key is missing', async () => {
const noMatch: TmdbClient = {
async search() {
return [searchResult({ tmdbId: 1, title: 'Something Else' })];
},
async getDetails() {
return details({ tmdbId: 1, allTitles: ['Something Else'] });
},
};
assert.equal(
await createLiveActionMetadataResolver(noMatch, silentLogger).resolveByTitle('Hanzawa Naoki'),
null,
);
let infoCount = 0;
const noKey: TmdbClient = {
async search() {
throw new TmdbApiKeyMissingError();
},
async getDetails() {
throw new TmdbApiKeyMissingError();
},
};
const resolver = createLiveActionMetadataResolver(noKey, {
info: () => {
infoCount += 1;
},
warn: () => {},
});
assert.equal(await resolver.resolveByTitle('Hanzawa Naoki'), null);
assert.equal(await resolver.resolveById('tv', 1), null);
assert.equal(infoCount, 1);
});
@@ -0,0 +1,75 @@
import { normalizeTitleIdentity } from '../../utils/title-normalization';
import type { TmdbMediaType } from '../../../shared/media-kind';
import { TmdbApiKeyMissingError, type TmdbClient, type TmdbTitleDetails } from './tmdb-client';
const MAX_DETAIL_LOOKUPS = 3;
/**
* Resolves live-action titles for the automatic cover-art path. Anime is
* AniList's job, so only non-animated Japanese-language results qualify, and
* a candidate must match the parsed title exactly under one of the names TMDB
* knows for it. Fuzzy search hits are never trusted on their own: a stray
* filename would otherwise pin the wrong show to a library entry.
*/
export interface LiveActionMetadataResolver {
resolveByTitle(title: string): Promise<TmdbTitleDetails | null>;
resolveById(tmdbType: TmdbMediaType, tmdbId: number): Promise<TmdbTitleDetails | null>;
}
interface Logger {
info(msg: string, ...args: unknown[]): void;
warn(msg: string, ...args: unknown[]): void;
}
export function titlesMatch(candidate: string, knownTitles: Iterable<string>): boolean {
const key = normalizeTitleIdentity(candidate);
if (!key) return false;
for (const known of knownTitles) {
if (normalizeTitleIdentity(known) === key) return true;
}
return false;
}
export function createLiveActionMetadataResolver(
client: TmdbClient,
logger: Logger,
): LiveActionMetadataResolver {
let warnedMissingKey = false;
const guard = async <T>(work: () => Promise<T>): Promise<T | null> => {
try {
return await work();
} catch (err) {
if (err instanceof TmdbApiKeyMissingError) {
if (!warnedMissingKey) {
warnedMissingKey = true;
logger.info('tmdb: no API key configured, skipping live-action metadata lookups');
}
return null;
}
logger.warn('tmdb: lookup failed: %s', err instanceof Error ? err.message : String(err));
return null;
}
};
return {
resolveByTitle(title) {
return guard(async () => {
const results = await client.search(title);
const candidates = results
.filter((result) => result.originalLanguage === 'ja' && !result.isAnimation)
.slice(0, MAX_DETAIL_LOOKUPS);
for (const candidate of candidates) {
const details = await client.getDetails(candidate.tmdbType, candidate.tmdbId);
if (details && !details.isAnimation && titlesMatch(title, details.allTitles)) {
return details;
}
}
return null;
});
},
resolveById(tmdbType, tmdbId) {
return guard(() => client.getDetails(tmdbType, tmdbId));
},
};
}
+335
View File
@@ -0,0 +1,335 @@
import assert from 'node:assert/strict';
import test, { type TestContext } from 'node:test';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import type { TmdbConfig } from '../../../types/integrations';
import {
TmdbApiKeyMissingError,
createTmdbClient,
createTmdbApiKeyResolver,
resolveTmdbApiKey,
} from './tmdb-client.js';
function commandFixture(t: TestContext) {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer tmdb command-'));
t.after(() => fs.rmSync(dir, { recursive: true, force: true }));
let nextId = 0;
const quotePath = (value: string) =>
`"${process.platform === 'win32' ? value : value.replace(/["\\$`]/g, '\\$&')}"`;
return {
dir,
command(source: string): string {
const script = path.join(dir, `credential-${nextId++}.cjs`);
fs.writeFileSync(script, source);
return `${quotePath(process.execPath)} ${quotePath(script)}`;
},
};
}
function jsonResponse(payload: unknown, status = 200): Response {
return new Response(JSON.stringify(payload), {
status,
headers: { 'content-type': 'application/json' },
});
}
function captureFetch(handler: (url: URL, init?: RequestInit) => Response) {
const calls: Array<{ url: URL; init?: RequestInit }> = [];
const fetchImpl = (async (input: RequestInfo | URL, init?: RequestInit) => {
const url = new URL(String(input));
calls.push({ url, init });
return handler(url, init);
}) as typeof fetch;
return { calls, fetchImpl };
}
test('resolveTmdbApiKey prefers the literal key and trims it', async (t) => {
const { command } = commandFixture(t);
assert.equal(
await resolveTmdbApiKey({ apiKey: ' abc ', apiKeyCommand: command('process.exit(3)') }),
'abc',
);
assert.equal(await resolveTmdbApiKey({ apiKey: '', apiKeyCommand: '' }), null);
assert.equal(await resolveTmdbApiKey(undefined), null);
});
test('resolveTmdbApiKey runs apiKeyCommand when no literal key is set', async (t) => {
const { command } = commandFixture(t);
assert.equal(
await resolveTmdbApiKey({ apiKeyCommand: command('process.stdout.write(" from-cmd ")') }),
'from-cmd',
);
assert.equal(await resolveTmdbApiKey({ apiKeyCommand: command('process.exit(3)') }), null);
});
test('resolveTmdbApiKey falls back to the bundled key only when the user set nothing usable', async (t) => {
const { command } = commandFixture(t);
assert.equal(await resolveTmdbApiKey({}, 'bundled'), 'bundled');
assert.equal(await resolveTmdbApiKey({ apiKey: 'mine' }, 'bundled'), 'mine');
assert.equal(
await resolveTmdbApiKey({ apiKeyCommand: command('process.stdout.write("mine")') }, 'bundled'),
'mine',
);
assert.equal(
await resolveTmdbApiKey({ apiKeyCommand: command('process.exit(3)') }, 'bundled'),
'bundled',
);
});
test('search rejects without a key and never touches the network', async () => {
const { calls, fetchImpl } = captureFetch(() => jsonResponse({ results: [] }));
const client = createTmdbClient({ resolveApiKey: async () => null, fetch: fetchImpl });
await assert.rejects(client.search('半沢直樹'), TmdbApiKeyMissingError);
assert.equal(calls.length, 0);
});
test('search sends a v3 key as a query parameter and drops people from multi results', async () => {
const { calls, fetchImpl } = captureFetch(() =>
jsonResponse({
results: [
{ media_type: 'person', id: 1, name: 'Sakai Masato' },
{
media_type: 'tv',
id: 61222,
name: 'Hanzawa Naoki',
original_name: '半沢直樹',
original_language: 'ja',
overview: 'A banker fights back.',
poster_path: '/hanzawa.jpg',
first_air_date: '2013-07-07',
genre_ids: [18],
},
{
media_type: 'movie',
id: 9,
title: 'Anime Film',
original_title: 'アニメ映画',
original_language: 'ja',
release_date: '2020-01-01',
genre_ids: [16],
},
],
}),
);
const client = createTmdbClient({ resolveApiKey: async () => 'v3key', fetch: fetchImpl });
const results = await client.search(' 半沢直樹 ');
assert.equal(calls.length, 1);
const url = calls[0]!.url;
assert.equal(url.pathname, '/3/search/multi');
assert.equal(url.searchParams.get('query'), '半沢直樹');
assert.equal(url.searchParams.get('api_key'), 'v3key');
assert.equal((calls[0]!.init?.headers as Record<string, string>).Authorization, undefined);
assert.deepEqual(results, [
{
tmdbId: 61222,
tmdbType: 'tv',
title: 'Hanzawa Naoki',
originalTitle: '半沢直樹',
originalLanguage: 'ja',
overview: 'A banker fights back.',
posterUrl: 'https://image.tmdb.org/t/p/w500/hanzawa.jpg',
year: 2013,
isAnimation: false,
},
{
tmdbId: 9,
tmdbType: 'movie',
title: 'Anime Film',
originalTitle: 'アニメ映画',
originalLanguage: 'ja',
overview: null,
posterUrl: null,
year: 2020,
isAnimation: true,
},
]);
});
test('a v4 read token travels as a bearer header instead of api_key', async () => {
const v4Token = ['eyJ', 'test-header', '.payload', '.sig'].join('');
const { calls, fetchImpl } = captureFetch(() => jsonResponse({ results: [] }));
const client = createTmdbClient({
resolveApiKey: async () => v4Token,
fetch: fetchImpl,
});
await client.search('x');
assert.equal(calls[0]!.url.searchParams.has('api_key'), false);
assert.equal(
(calls[0]!.init?.headers as Record<string, string>).Authorization,
`Bearer ${v4Token}`,
);
});
test('getDetails folds translations and alternative titles into the normalized shape', async () => {
const { calls, fetchImpl } = captureFetch(() =>
jsonResponse({
id: 61222,
name: 'Hanzawa Naoki',
original_name: '半沢直樹',
original_language: 'ja',
overview: 'A banker fights back.',
poster_path: '/hanzawa.jpg',
first_air_date: '2013-07-07',
number_of_episodes: 10,
genres: [{ id: 18, name: 'Drama' }],
alternative_titles: { results: [{ iso_3166_1: 'JP', title: 'Hanzawa Naoki Season 1' }] },
translations: {
translations: [
{ iso_639_1: 'en', data: { name: 'Hanzawa Naoki', overview: 'A banker fights back.' } },
{ iso_639_1: 'ja', data: { name: '半沢直樹', overview: '銀行員の物語' } },
],
},
}),
);
const client = createTmdbClient({ resolveApiKey: async () => 'k', fetch: fetchImpl });
const details = await client.getDetails('tv', 61222);
assert.equal(calls[0]!.url.pathname, '/3/tv/61222');
assert.equal(
calls[0]!.url.searchParams.get('append_to_response'),
'alternative_titles,translations',
);
assert.deepEqual(details, {
tmdbId: 61222,
tmdbType: 'tv',
titleEnglish: 'Hanzawa Naoki',
titleNative: '半沢直樹',
description: 'A banker fights back.',
posterUrl: 'https://image.tmdb.org/t/p/w500/hanzawa.jpg',
episodesTotal: 10,
year: 2013,
originalLanguage: 'ja',
isAnimation: false,
allTitles: ['Hanzawa Naoki', '半沢直樹', 'Hanzawa Naoki Season 1'],
});
});
test('getDetails falls back to the Japanese overview and counts a movie as one episode', async () => {
const { fetchImpl } = captureFetch(() =>
jsonResponse({
id: 5,
title: '半沢直樹',
original_title: '半沢直樹',
original_language: 'ja',
overview: '',
release_date: '2019-03-01',
translations: {
translations: [{ iso_639_1: 'ja', data: { title: '半沢直樹', overview: 'あらすじ' } }],
},
}),
);
const client = createTmdbClient({ resolveApiKey: async () => 'k', fetch: fetchImpl });
const details = await client.getDetails('movie', 5);
assert.equal(details?.titleEnglish, null);
assert.equal(details?.titleNative, '半沢直樹');
assert.equal(details?.description, 'あらすじ');
assert.equal(details?.episodesTotal, 1);
});
test('getDetails returns null for an unknown id', async () => {
const { fetchImpl } = captureFetch(() => jsonResponse({ status_message: 'nope' }, 404));
const client = createTmdbClient({ resolveApiKey: async () => 'k', fetch: fetchImpl });
assert.equal(await client.getDetails('tv', 1), null);
});
test('client reuses command output across requests and invalidates it when either setting changes', async (t) => {
const fixture = commandFixture(t);
const counter = path.join(fixture.dir, 'calls');
const createCommand = (key: string) =>
fixture.command(`
const fs = require('node:fs');
const path = require('node:path');
fs.appendFileSync(path.join(__dirname, 'calls'), 'x');
process.stdout.write(${JSON.stringify(key)});
`);
let config: TmdbConfig = { apiKeyCommand: createCommand('command-key') };
const { calls, fetchImpl } = captureFetch(() => jsonResponse({ results: [] }));
const client = createTmdbClient({
resolveApiKey: createTmdbApiKeyResolver(
() => config,
() => 'bundled',
),
fetch: fetchImpl,
});
await Promise.all([client.search('a'), client.search('b')]);
await client.getDetails('tv', 1);
assert.equal(fs.readFileSync(counter, 'utf8'), 'x');
assert.ok(calls.every(({ url }) => url.searchParams.get('api_key') === 'command-key'));
config = { ...config, apiKey: 'literal' };
await client.search('c');
assert.equal(calls.at(-1)?.url.searchParams.get('api_key'), 'literal');
config = { ...config, apiKey: '' };
await client.search('d');
assert.equal(fs.readFileSync(counter, 'utf8'), 'xx');
config = { apiKeyCommand: createCommand('new-key') };
await client.search('e');
assert.equal(fs.readFileSync(counter, 'utf8'), 'xxx');
assert.equal(calls.at(-1)?.url.searchParams.get('api_key'), 'new-key');
});
for (const failure of ['error', 'empty'] as const) {
test(`${failure} command output uses a bounded cooldown before retrying`, async (t) => {
const fixture = commandFixture(t);
const counter = path.join(fixture.dir, 'calls');
const command = fixture.command(`
const fs = require('node:fs');
const path = require('node:path');
const counter = path.join(__dirname, 'calls');
fs.appendFileSync(counter, 'x');
if (fs.readFileSync(counter, 'utf8').length === 1) process.exit(${failure === 'error' ? 1 : 0});
process.stdout.write('recovered');
`);
let now = 1000;
const originalNow = Date.now;
Date.now = () => now;
t.after(() => {
Date.now = originalNow;
});
let bundledKey: string | null = 'bundled';
const resolve = createTmdbApiKeyResolver(
() => ({ apiKeyCommand: command }),
() => bundledKey,
);
assert.deepEqual(await Promise.all([resolve(), resolve()]), ['bundled', 'bundled']);
now += 29_999;
assert.equal(await resolve(), 'bundled');
bundledKey = null;
assert.equal(await resolve(), null);
assert.equal(fs.readFileSync(counter, 'utf8'), 'x');
now += 1;
assert.equal(await resolve(), 'recovered');
assert.equal(await resolve(), 'recovered');
assert.equal(fs.readFileSync(counter, 'utf8'), 'xx');
});
}
for (const setting of ['apiKey', 'apiKeyCommand'] as const) {
test(`changing ${setting} clears a failed command cooldown`, async (t) => {
const fixture = commandFixture(t);
const counter = path.join(fixture.dir, 'calls');
const source = `
const fs = require('node:fs');
const path = require('node:path');
fs.appendFileSync(path.join(__dirname, 'calls'), 'x');
process.exit(1);
`;
let config: TmdbConfig = { apiKeyCommand: fixture.command(source) };
const resolve = createTmdbApiKeyResolver(
() => config,
() => 'bundled',
);
assert.equal(await resolve(), 'bundled');
assert.equal(await resolve(), 'bundled');
assert.equal(fs.readFileSync(counter, 'utf8'), 'x');
config =
setting === 'apiKey'
? { ...config, apiKey: ' ' }
: { apiKeyCommand: fixture.command(source) };
assert.equal(await resolve(), 'bundled');
assert.equal(fs.readFileSync(counter, 'utf8'), 'xx');
});
}
+311
View File
@@ -0,0 +1,311 @@
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<TmdbSearchResult[]>;
getDetails(tmdbType: TmdbMediaType, tmdbId: number): Promise<TmdbTitleDetails | null>;
}
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<string> {
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<string | null> {
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<string | null> {
let state:
| {
apiKey: string | undefined;
apiKeyCommand: string | undefined;
pending: Promise<string | null> | 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<string | null>;
fetch?: typeof fetch;
baseUrl?: string;
}): TmdbClient {
const fetchImpl = deps.fetch ?? fetch;
const baseUrl = (deps.baseUrl ?? TMDB_API_BASE_URL).replace(/\/+$/, '');
async function request<T>(path: string, params: Record<string, string>): Promise<T | null> {
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<string, string> = { 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<RawDetails>(`/${tmdbType}/${tmdbId}`, {
language: 'en-US',
append_to_response: 'alternative_titles,translations',
});
return raw ? normalizeDetails(tmdbType, raw) : null;
},
};
}