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

Anime covers come from AniList, which has no live-action titles, so dramas
and movies showed blank cards with no description and split across season
folders. Library entries now carry a media kind plus a TMDB link, and the
cover-art fetcher falls back to TMDB when AniList has no match, accepting
only a Japanese non-animated title whose TMDB names match the parsed title
exactly. Entries that resolve to the same TMDB title merge into one card
regardless of season, and a Link to TMDB action in the detail view covers
anything the automatic match missed.

Release builds bundle a project-owned TMDB key injected from the
SUBMINER_TMDB_API_KEY secret at build time; tmdb.apiKey/apiKeyCommand
override it and are required when running from source.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
2026-09-18 23:31:35 -07:00
co-authored by Claude Fable 5.1
parent dd76782d30
commit 2b33d32879
68 changed files with 2339 additions and 68 deletions
+2
View File
@@ -42,6 +42,7 @@ const {
ankiConnect,
jimaku,
tsukihime,
tmdb,
anilist,
mpv,
yomitan,
@@ -76,6 +77,7 @@ export const DEFAULT_CONFIG: ResolvedConfig = {
auto_start_overlay,
jimaku,
tsukihime,
tmdb,
anilist,
mpv,
yomitan,
@@ -6,6 +6,7 @@ export const INTEGRATIONS_DEFAULT_CONFIG: Pick<
| 'ankiConnect'
| 'jimaku'
| 'tsukihime'
| 'tmdb'
| 'anilist'
| 'mpv'
| 'yomitan'
@@ -112,6 +113,10 @@ export const INTEGRATIONS_DEFAULT_CONFIG: Pick<
apiBaseUrl: 'https://api.tsukihime.org/v1',
maxSearchResults: 10,
},
tmdb: {
apiKey: '',
apiKeyCommand: '',
},
mpv: {
executablePath: '',
launchMode: 'normal',
@@ -472,6 +472,20 @@ export function buildIntegrationConfigOptionRegistry(
defaultValue: defaultConfig.tsukihime.maxSearchResults,
description: 'Maximum TsukiHime search results returned.',
},
{
path: 'tmdb.apiKey',
kind: 'string',
defaultValue: defaultConfig.tmdb.apiKey,
description:
'Your own TMDB API key or read access token for live-action posters and synopses in the stats Library. Release builds bundle a project key, so set this only to use your own quota or when running from source (free under Settings > API on themoviedb.org).',
},
{
path: 'tmdb.apiKeyCommand',
kind: 'string',
defaultValue: defaultConfig.tmdb.apiKeyCommand,
description:
'Shell command that prints the TMDB API key to stdout. Used instead of apiKey to avoid storing the key in plain text.',
},
{
path: 'anilist.enabled',
kind: 'boolean',
@@ -164,6 +164,14 @@ const INTEGRATION_TEMPLATE_SECTIONS: ConfigTemplateSection[] = [
notes: ['Hot-reload: TsukiHime changes apply to the next TsukiHime request.'],
key: 'tsukihime',
},
{
title: 'TMDB',
description: [
'TMDB (The Movie Database) metadata for live-action dramas and movies in the stats Library: posters, synopses, and grouping by show.',
],
notes: ['Hot-reload: TMDB changes apply to the next TMDB request.'],
key: 'tmdb',
},
{
title: 'YouTube Playback Settings',
description: [
+13
View File
@@ -58,6 +58,19 @@ export function applyIntegrationConfig(context: ResolveContext): void {
warn('ai', src.ai, resolved.ai, 'Expected object.');
}
if (isObject(src.tmdb)) {
for (const key of ['apiKey', 'apiKeyCommand'] as const) {
const value = asString(src.tmdb[key]);
if (value !== undefined) {
resolved.tmdb[key] = value;
} else if (src.tmdb[key] !== undefined) {
warn(`tmdb.${key}`, src.tmdb[key], resolved.tmdb[key], 'Expected string.');
}
}
} else if (src.tmdb !== undefined) {
warn('tmdb', src.tmdb, resolved.tmdb, 'Expected object.');
}
if (isObject(src.anilist)) {
const enabled = asBoolean(src.anilist.enabled);
if (enabled !== undefined) {
+3
View File
@@ -275,6 +275,9 @@ test('settings registry routes playback-related integrations into integrations',
assert.equal(field('subsync.replace').section, 'Subtitle Sync');
assert.equal(field('tsukihime.apiBaseUrl').category, 'integrations');
assert.equal(field('tsukihime.apiBaseUrl').section, 'TsukiHime');
assert.equal(field('tmdb.apiKey').category, 'integrations');
assert.equal(field('tmdb.apiKey').section, 'TMDB');
assert.equal(field('tmdb.apiKey').secret, true);
});
test('settings registry puts feature toggles first, then other toggles alphabetically', () => {
+11 -2
View File
@@ -93,7 +93,12 @@ const JSON_OBJECT_FIELDS = new Set([
'subtitleSidebar.css',
]);
export const SECRET_PATHS = new Set(['ai.apiKey', 'jimaku.apiKey', 'anilist.accessToken']);
export const SECRET_PATHS = new Set([
'ai.apiKey',
'jimaku.apiKey',
'tmdb.apiKey',
'anilist.accessToken',
]);
const COLOR_SUFFIXES = new Set(['Color', 'color', 'backgroundColor', 'singleColor']);
const SUBTITLE_CSS_MANAGED_CONFIG_PATHS = new Set([
@@ -135,6 +140,7 @@ const SECTION_ORDER = new Map<string, number>(
'Anki AI',
'AnkiConnect Proxy',
'Jimaku',
'TMDB',
'Subtitle Sync',
'MPV Keybindings',
'Overlay Shortcuts',
@@ -327,6 +333,7 @@ function humanizePath(path: string): string {
.replace(/\bmpv\b/i, 'mpv')
.replace(/\byomitan\b/i, 'Yomitan')
.replace(/\bjimaku\b/i, 'Jimaku')
.replace(/\btmdb\b/i, 'TMDB')
.replace(/\banilist\b/i, 'AniList')
.replace(/\banki\b/i, 'Anki');
return spaced.charAt(0).toUpperCase() + spaced.slice(1);
@@ -442,7 +449,7 @@ function categoryAndSection(path: string): { category: ConfigSettingsCategory; s
if (path.startsWith('mpv.') || path.startsWith('youtube.')) {
return { category: 'behavior', section: topSection(path) };
}
if (path.startsWith('jimaku.') || path.startsWith('tsukihime.')) {
if (path.startsWith('jimaku.') || path.startsWith('tsukihime.') || path.startsWith('tmdb.')) {
return { category: 'integrations', section: topSection(path) };
}
if (path.startsWith('subsync.')) {
@@ -510,6 +517,7 @@ function topSection(path: string): string {
subsync: 'Subtitle Sync',
texthooker: 'Texthooker',
tsukihime: 'TsukiHime',
tmdb: 'TMDB',
updates: 'Updates',
websocket: 'WebSocket server',
yomitan: 'Yomitan',
@@ -732,6 +740,7 @@ function restartBehaviorForPath(path: string): ConfigSettingsRestartBehavior {
pathStartsWith(path, 'notifications') ||
path === 'youtube.primarySubLanguages' ||
pathStartsWith(path, 'jimaku') ||
pathStartsWith(path, 'tmdb') ||
pathStartsWith(path, 'subsync') ||
pathStartsWith(path, 'subtitleGeneration')
) {