mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-10-27 11:35:23 -07:00
feat(stats): add TMDB metadata for live-action dramas in the Library (#252)
This commit is contained in:
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 });
|
||||
}
|
||||
});
|
||||
@@ -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));
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user