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
@@ -540,3 +540,186 @@ test('fetchIfMissing re-resolves an unresolved season once AniList publishes the
cleanupDbPath(dbPath);
}
});
test('fetchIfMissing falls back to TMDB when AniList has no match and stores the poster', async () => {
const dbPath = makeDbPath();
const db = new Database(dbPath);
ensureSchema(db);
const videoId = getOrCreateVideoRecord(db, 'local:/tmp/hanzawa-01.mkv', {
canonicalTitle: 'Hanzawa Naoki - 01.mkv',
sourcePath: '/tmp/hanzawa-01.mkv',
sourceUrl: null,
sourceType: SOURCE_TYPE_LOCAL,
});
const animeId = getOrCreateAnimeRecord(db, {
parsedTitle: 'Hanzawa Naoki',
canonicalTitle: 'Hanzawa Naoki',
anilistId: null,
titleRomaji: null,
titleEnglish: null,
titleNative: null,
metadataJson: null,
});
linkVideoToAnimeRecord(db, videoId, {
animeId,
parsedBasename: null,
parsedTitle: 'Hanzawa Naoki',
parsedSeason: null,
parsedEpisode: 1,
parserSource: 'fallback',
parserConfidence: 1,
parseMetadataJson: null,
});
const fetchCalls: string[] = [];
const originalFetch = globalThis.fetch;
globalThis.fetch = (async (input: RequestInfo | URL) => {
const url = String(input);
fetchCalls.push(url);
if (url.startsWith('https://graphql.anilist.co')) {
return createJsonResponse({ data: { Page: { media: [] } } });
}
assert.equal(url, 'https://image.tmdb.org/t/p/w500/hanzawa.jpg');
return new Response(new Uint8Array([5, 6, 7]), {
status: 200,
headers: { 'Content-Type': 'image/jpeg' },
});
}) as typeof fetch;
const resolvedTitles: string[] = [];
try {
const fetcher = createCoverArtFetcher(
{ acquire: async () => {}, recordResponse: () => {} },
console,
{
runGuessit: async () => {
throw new Error('guessit unavailable');
},
liveAction: {
async resolveByTitle(title) {
resolvedTitles.push(title);
if (title !== 'Hanzawa Naoki') return null;
return {
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', '半沢直樹'],
};
},
async resolveById() {
return null;
},
},
},
);
const fetched = await fetcher.fetchIfMissing(db, videoId, 'Hanzawa Naoki - 01.mkv');
const stored = getCoverArt(db, videoId);
const anime = db
.prepare(
'SELECT media_kind AS mediaKind, tmdb_id AS tmdbId, description FROM imm_anime WHERE anime_id = ?',
)
.get(animeId) as { mediaKind: string; tmdbId: number | null; description: string | null };
assert.equal(fetched, true);
// The raw fallback-parser title is tried first, then the tag-stripped one.
assert.deepEqual(resolvedTitles, ['Hanzawa Naoki - 01', 'Hanzawa Naoki']);
assert.equal(stored?.anilistId, null);
assert.equal(stored?.coverUrl, 'https://image.tmdb.org/t/p/w500/hanzawa.jpg');
assert.equal(Buffer.from(stored?.coverBlob ?? []).toString('hex'), '050607');
assert.equal(anime.mediaKind, 'live_action');
assert.equal(anime.tmdbId, 61222);
assert.equal(anime.description, 'A banker fights back.');
assert.ok(fetchCalls.some((url) => url.startsWith('https://graphql.anilist.co')));
} finally {
globalThis.fetch = originalFetch;
db.close();
cleanupDbPath(dbPath);
}
});
test('fetchIfMissing skips AniList for an entry already linked to TMDB', async () => {
const dbPath = makeDbPath();
const db = new Database(dbPath);
ensureSchema(db);
const videoId = getOrCreateVideoRecord(db, 'local:/tmp/hanzawa-02.mkv', {
canonicalTitle: 'Hanzawa Naoki - 02.mkv',
sourcePath: '/tmp/hanzawa-02.mkv',
sourceUrl: null,
sourceType: SOURCE_TYPE_LOCAL,
});
const animeId = getOrCreateAnimeRecord(db, {
parsedTitle: 'Hanzawa Naoki',
canonicalTitle: 'Hanzawa Naoki',
anilistId: null,
titleRomaji: null,
titleEnglish: null,
titleNative: null,
metadataJson: null,
});
linkVideoToAnimeRecord(db, videoId, {
animeId,
parsedBasename: null,
parsedTitle: 'Hanzawa Naoki',
parsedSeason: null,
parsedEpisode: 2,
parserSource: 'fallback',
parserConfidence: 1,
parseMetadataJson: null,
});
db.prepare(
"UPDATE imm_anime SET media_kind = 'live_action', tmdb_id = 61222, tmdb_type = 'tv' WHERE anime_id = ?",
).run(animeId);
const originalFetch = globalThis.fetch;
globalThis.fetch = (async (input: RequestInfo | URL) => {
assert.equal(String(input), 'https://image.tmdb.org/t/p/w500/hanzawa.jpg');
return new Response(new Uint8Array([1]), { status: 200 });
}) as typeof fetch;
const byIdCalls: Array<[string, number]> = [];
try {
const fetcher = createCoverArtFetcher(
{ acquire: async () => {}, recordResponse: () => {} },
console,
{
liveAction: {
async resolveByTitle() {
throw new Error('title search must not run for a linked entry');
},
async resolveById(tmdbType, tmdbId) {
byIdCalls.push([tmdbType, tmdbId]);
return {
tmdbId,
tmdbType,
titleEnglish: 'Hanzawa Naoki',
titleNative: null,
description: null,
posterUrl: 'https://image.tmdb.org/t/p/w500/hanzawa.jpg',
episodesTotal: 10,
year: null,
originalLanguage: 'ja',
isAnimation: false,
allTitles: [],
};
},
},
},
);
assert.equal(await fetcher.fetchIfMissing(db, videoId, 'Hanzawa Naoki - 02.mkv'), true);
assert.deepEqual(byIdCalls, [['tv', 61222]]);
assert.equal(getCoverArt(db, videoId)?.coverBlob?.length, 1);
} finally {
globalThis.fetch = originalFetch;
db.close();
cleanupDbPath(dbPath);
}
});
+77 -17
View File
@@ -16,6 +16,9 @@ import {
type AnilistQueryExecutor,
type AnilistSeasonResolution,
} from './season-resolver';
import { getVideoTmdbLink, linkAnimeToTmdbTitle } from '../immersion-tracker/live-action-link';
import type { LiveActionMetadataResolver } from '../tmdb/live-action-resolver';
import type { TmdbTitleDetails } from '../tmdb/tmdb-client';
const ANILIST_GRAPHQL_URL = 'https://graphql.anilist.co';
const NO_MATCH_RETRY_MS = 5 * 60 * 1000;
@@ -39,6 +42,8 @@ interface CoverArtCandidate {
interface CoverArtFetcherOptions {
runGuessit?: GuessAnilistMediaInfoDeps['runGuessit'];
/** Live-action fallback consulted when AniList has no match for a title. */
liveAction?: LiveActionMetadataResolver;
}
export function stripFilenameTags(raw: string): string {
@@ -152,6 +157,55 @@ export function createCoverArtFetcher(
return true;
};
const cacheNoMatch = (db: DatabaseSync, videoId: number): void => {
upsertCoverArt(db, videoId, {
anilistId: null,
coverUrl: null,
coverBlob: null,
titleRomaji: null,
titleEnglish: null,
episodesTotal: null,
});
};
// Links the video's library entry to the TMDB title and stores its poster.
const storeLiveActionArt = async (
db: DatabaseSync,
videoId: number,
details: TmdbTitleDetails,
): Promise<boolean> => {
const row = db
.prepare('SELECT anime_id AS animeId FROM imm_videos WHERE video_id = ?')
.get(videoId) as { animeId: number | null } | undefined;
if (row?.animeId) {
const link = linkAnimeToTmdbTitle(db, row.animeId, details, { mode: 'auto' });
if (link.mergedAnimeIds.length > 0) {
logger.info(
'cover-art: folded library entries %s into %d (same TMDB title)',
link.mergedAnimeIds.join(','),
link.animeId,
);
}
}
const coverBlob = details.posterUrl ? await downloadImage(details.posterUrl) : null;
upsertCoverArt(db, videoId, {
anilistId: null,
coverUrl: details.posterUrl,
coverBlob,
titleRomaji: null,
titleEnglish: details.titleEnglish,
episodesTotal: details.episodesTotal,
});
logger.info(
'cover-art: linked videoId=%d to TMDB %s/%d "%s"',
videoId,
details.tmdbType,
details.tmdbId,
details.titleEnglish ?? details.titleNative ?? '',
);
return coverBlob !== null;
};
const resolveCanonicalTitle = (
db: DatabaseSync,
videoId: number,
@@ -225,18 +279,23 @@ export function createCoverArtFetcher(
return false;
}
// A live-action entry already knows its TMDB title; AniList has nothing
// to add and would only produce a spurious anime match.
const tmdbLink = getVideoTmdbLink(db, videoId);
if (tmdbLink) {
const details = await options.liveAction?.resolveById(tmdbLink.tmdbType, tmdbLink.tmdbId);
if (details) {
return storeLiveActionArt(db, videoId, details);
}
cacheNoMatch(db, videoId);
return false;
}
const effectiveTitle = resolveCanonicalTitle(db, videoId, canonicalTitle);
const cleaned = stripFilenameTags(effectiveTitle);
if (!cleaned) {
logger.warn('cover-art: empty title after stripping tags for videoId=%d', videoId);
upsertCoverArt(db, videoId, {
anilistId: null,
coverUrl: null,
coverBlob: null,
titleRomaji: null,
titleEnglish: null,
episodesTotal: null,
});
cacheNoMatch(db, videoId);
return false;
}
@@ -294,15 +353,16 @@ export function createCoverArtFetcher(
const selected = resolution?.media ?? null;
if (!selected) {
logger.info('cover-art: no Anilist results for "%s", caching no-match', searchBase);
upsertCoverArt(db, videoId, {
anilistId: null,
coverUrl: null,
coverBlob: null,
titleRomaji: null,
titleEnglish: null,
episodesTotal: null,
});
if (options.liveAction) {
for (const searchTitle of searchTitles) {
const details = await options.liveAction.resolveByTitle(searchTitle);
if (details) {
return storeLiveActionArt(db, videoId, details);
}
}
}
logger.info('cover-art: no Anilist or TMDB results for "%s", caching no-match', searchBase);
cacheNoMatch(db, videoId);
return false;
}
+77 -22
View File
@@ -90,6 +90,7 @@ import {
} from './immersion-tracker/query-library';
import {
cleanupVocabularyStats,
clearAnimeCoverArt,
getVideoDurationMs,
markVideoWatched,
upsertCoverArt,
@@ -124,6 +125,11 @@ import {
type AnimeMergeSummary,
type VideoMoveSummary,
} from './immersion-tracker/anime-merge';
import {
linkAnimeToTmdbTitle,
type LiveActionLinkResult,
type LiveActionTitleInput,
} from './immersion-tracker/live-action-link';
import {
buildVideoKey,
deriveCanonicalTitle,
@@ -1029,6 +1035,9 @@ export class ImmersionTrackerService {
`
UPDATE imm_anime
SET anilist_id = ?,
media_kind = 'anime',
tmdb_id = NULL,
tmdb_type = NULL,
title_romaji = COALESCE(?, title_romaji),
title_english = COALESCE(?, title_english),
title_native = COALESCE(?, title_native),
@@ -1061,30 +1070,76 @@ export class ImmersionTrackerService {
recomputeLifetimeGlobalFromSummaries(this.db);
}
// Update cover art for all videos in this anime
if (info.coverUrl) {
const videos = this.db
.prepare('SELECT video_id FROM imm_videos WHERE anime_id = ?')
.all(animeId) as Array<{ video_id: number }>;
let coverBlob: Buffer | null = null;
try {
const res = await fetch(info.coverUrl);
if (res.ok) {
coverBlob = Buffer.from(await res.arrayBuffer());
}
} catch {
/* ignore */
}
for (const v of videos) {
upsertCoverArt(this.db, v.video_id, {
anilistId: info.anilistId,
coverUrl: info.coverUrl,
coverBlob,
titleRomaji: info.titleRomaji ?? null,
titleEnglish: info.titleEnglish ?? null,
episodesTotal: info.episodesTotal ?? null,
});
await this.applyCoverArtToAnimeVideos(animeId, {
anilistId: info.anilistId,
coverUrl: info.coverUrl,
titleRomaji: info.titleRomaji ?? null,
titleEnglish: info.titleEnglish ?? null,
episodesTotal: info.episodesTotal ?? null,
});
}
}
/**
* Link a library entry to a TMDB title chosen in the dashboard. Every other
* entry pointing at the same title is folded into this one, and its poster
* replaces the art of every episode.
*/
async reassignAnimeTmdb(
animeId: number,
details: LiveActionTitleInput & { posterUrl: string | null },
): Promise<LiveActionLinkResult> {
this.requireWriteQueueDrained('linking a TMDB title');
const result = linkAnimeToTmdbTitle(this.db, animeId, details, { mode: 'manual' });
if (details.posterUrl) {
await this.applyCoverArtToAnimeVideos(result.animeId, {
anilistId: null,
coverUrl: details.posterUrl,
titleRomaji: null,
titleEnglish: details.titleEnglish,
episodesTotal: details.episodesTotal,
});
} else {
// The user chose this title deliberately, so art from the previous link
// must not keep standing in for it.
clearAnimeCoverArt(this.db, result.animeId);
}
return result;
}
/** Downloads one cover and stores it against every episode of the entry. */
private async applyCoverArtToAnimeVideos(
animeId: number,
art: {
anilistId: number | null;
coverUrl: string;
titleRomaji: string | null;
titleEnglish: string | null;
episodesTotal: number | null;
},
): Promise<void> {
const videos = this.db
.prepare('SELECT video_id FROM imm_videos WHERE anime_id = ?')
.all(animeId) as Array<{ video_id: number }>;
let coverBlob: Buffer | null = null;
try {
const res = await fetch(art.coverUrl);
if (res.ok) {
coverBlob = Buffer.from(await res.arrayBuffer());
}
} catch {
/* ignore */
}
for (const v of videos) {
upsertCoverArt(this.db, v.video_id, {
anilistId: art.anilistId,
coverUrl: art.coverUrl,
coverBlob,
titleRomaji: art.titleRomaji,
titleEnglish: art.titleEnglish,
episodesTotal: art.episodesTotal,
});
}
}
@@ -0,0 +1,236 @@
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 { Database } from '../sqlite.js';
import type { DatabaseSync } from '../sqlite.js';
import { applyPragmas, ensureSchema, getOrCreateAnimeRecord } from '../storage.js';
import { repairLegacySeasonlessAnimeRows } from '../anime-season-repair.js';
import { getVideoTmdbLink, linkAnimeToTmdbTitle } from '../live-action-link.js';
import { getAnimeCoverArt, getCoverArt } from '../query-library.js';
import { clearAnimeCoverArt, upsertCoverArt } from '../query-maintenance.js';
const BASE_MS = 1_700_000_000_000;
function withDb(work: (db: DatabaseSync) => void): void {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-live-action-link-'));
const db = new Database(path.join(dir, 'immersion.sqlite'));
try {
applyPragmas(db);
ensureSchema(db);
work(db);
} finally {
db.close();
fs.rmSync(dir, { recursive: true, force: true });
}
}
function insertAnime(
db: DatabaseSync,
animeId: number,
title: string,
anilistId: number | null = null,
) {
db.prepare(
`INSERT INTO imm_anime(anime_id, normalized_title_key, canonical_title, anilist_id, CREATED_DATE, LAST_UPDATE_DATE)
VALUES (?, ?, ?, ?, ?, ?)`,
).run(animeId, title.toLowerCase(), title, anilistId, BASE_MS, BASE_MS);
}
function insertEpisode(db: DatabaseSync, videoId: number, animeId: number, season: number | null) {
db.prepare(
`INSERT INTO imm_videos(video_id, video_key, anime_id, canonical_title, source_type, parsed_title, parsed_season, parsed_episode, watched, duration_ms, CREATED_DATE, LAST_UPDATE_DATE)
VALUES (?, ?, ?, ?, 1, 'Hanzawa Naoki', ?, ?, 1, 1440000, ?, ?)`,
).run(
videoId,
`local:/tmp/${videoId}.mkv`,
animeId,
`Ep ${videoId}`,
season,
videoId,
BASE_MS,
BASE_MS,
);
db.prepare(
`INSERT INTO imm_lifetime_media(video_id, total_sessions, total_active_ms, total_cards, completed, first_watched_ms, last_watched_ms, CREATED_DATE, LAST_UPDATE_DATE)
VALUES (?, 1, 1000, 0, 1, ?, ?, ?, ?)`,
).run(videoId, String(BASE_MS), String(BASE_MS + 1000), BASE_MS, BASE_MS);
}
interface AnimeRowView {
mediaKind: string;
tmdbId: number | null;
tmdbType: string | null;
anilistId: number | null;
titleEnglish: string | null;
titleNative: string | null;
description: string | null;
}
// Copies the selected columns so the driver's row metadata does not leak into
// deep-equality assertions.
function animeRow(db: DatabaseSync, animeId: number): AnimeRowView | undefined {
const row = db
.prepare(
`SELECT media_kind AS mediaKind, tmdb_id AS tmdbId, tmdb_type AS tmdbType, anilist_id AS anilistId,
title_english AS titleEnglish, title_native AS titleNative, description
FROM imm_anime WHERE anime_id = ?`,
)
.get(animeId) as AnimeRowView | undefined;
if (!row) return undefined;
const { mediaKind, tmdbId, tmdbType, anilistId, titleEnglish, titleNative, description } = row;
return { mediaKind, tmdbId, tmdbType, anilistId, titleEnglish, titleNative, description };
}
function animeCount(db: DatabaseSync): number {
return (db.prepare('SELECT COUNT(*) AS n FROM imm_anime').get() as { n: number }).n;
}
function videoOwner(db: DatabaseSync, videoId: number): number | null {
return (
db.prepare('SELECT anime_id AS animeId FROM imm_videos WHERE video_id = ?').get(videoId) as {
animeId: number | null;
}
).animeId;
}
const HANZAWA = {
tmdbId: 61222,
tmdbType: 'tv' as const,
titleEnglish: 'Hanzawa Naoki',
titleNative: '半沢直樹',
description: 'A banker fights back.',
episodesTotal: 10,
};
test('a manual link overwrites metadata, drops the AniList link, and folds other holders in', () => {
withDb((db) => {
insertAnime(db, 1, 'Hanzawa Naoki', 4242);
insertAnime(db, 2, 'Hanzawa Naoki Season 2');
insertEpisode(db, 1, 1, 1);
insertEpisode(db, 2, 2, 2);
db.prepare(
`UPDATE imm_anime SET media_kind = 'live_action', tmdb_id = ?, tmdb_type = 'tv', description = 'old' WHERE anime_id = 2`,
).run(HANZAWA.tmdbId);
const result = linkAnimeToTmdbTitle(db, 1, HANZAWA, { mode: 'manual' });
assert.deepEqual(result, { animeId: 1, mergedAnimeIds: [2] });
assert.deepEqual(animeRow(db, 1), {
mediaKind: 'live_action',
tmdbId: 61222,
tmdbType: 'tv',
anilistId: null,
titleEnglish: 'Hanzawa Naoki',
titleNative: '半沢直樹',
description: 'A banker fights back.',
});
assert.equal(animeRow(db, 2), undefined);
assert.equal(animeCount(db), 1);
assert.equal(videoOwner(db, 2), 1);
assert.deepEqual(getVideoTmdbLink(db, 2), { animeId: 1, tmdbId: 61222, tmdbType: 'tv' });
});
});
test('an automatic link joins the entry that already owns the title and only fills gaps', () => {
withDb((db) => {
insertAnime(db, 1, 'Hanzawa Naoki');
insertEpisode(db, 1, 1, 1);
linkAnimeToTmdbTitle(db, 1, { ...HANZAWA, description: 'kept' }, { mode: 'manual' });
const newcomer = getOrCreateAnimeRecord(db, {
parsedTitle: 'Hanzawa Naoki',
canonicalTitle: 'Hanzawa Naoki',
seasonScope: 2,
anilistId: null,
titleRomaji: null,
titleEnglish: null,
titleNative: null,
metadataJson: null,
});
insertEpisode(db, 2, newcomer, 2);
const result = linkAnimeToTmdbTitle(db, newcomer, HANZAWA, { mode: 'auto' });
assert.deepEqual(result, { animeId: 1, mergedAnimeIds: [newcomer] });
assert.equal(animeRow(db, 1)?.description, 'kept');
assert.equal(animeCount(db), 1);
assert.equal(videoOwner(db, 2), 1);
// The merged-away season title is remembered, so the next episode of that
// season lands on the survivor without a detour through a new row.
const again = getOrCreateAnimeRecord(db, {
parsedTitle: 'Hanzawa Naoki',
canonicalTitle: 'Hanzawa Naoki',
seasonScope: 2,
anilistId: null,
titleRomaji: null,
titleEnglish: null,
titleNative: null,
metadataJson: null,
});
assert.equal(again, 1);
});
});
test('startup season repair leaves multi-season live-action entries alone', () => {
withDb((db) => {
insertAnime(db, 1, 'Hanzawa Naoki');
insertEpisode(db, 1, 1, 1);
insertEpisode(db, 2, 1, 2);
linkAnimeToTmdbTitle(db, 1, HANZAWA, { mode: 'manual' });
repairLegacySeasonlessAnimeRows(db);
assert.equal(animeCount(db), 1);
assert.equal(videoOwner(db, 1), 1);
assert.equal(videoOwner(db, 2), 1);
assert.equal(getVideoTmdbLink(db, 2)?.animeId, 1);
});
});
test('getVideoTmdbLink is null for anime entries and unlinked videos', () => {
withDb((db) => {
insertAnime(db, 1, 'Some Anime', 77);
insertEpisode(db, 1, 1, 1);
assert.equal(getVideoTmdbLink(db, 1), null);
assert.equal(getVideoTmdbLink(db, 99), null);
});
});
test('clearAnimeCoverArt drops every episode cover of the entry and its orphaned blob', () => {
withDb((db) => {
insertAnime(db, 1, 'Hanzawa Naoki');
insertAnime(db, 2, 'Other Show');
insertEpisode(db, 1, 1, 1);
insertEpisode(db, 2, 1, 1);
insertEpisode(db, 3, 2, 1);
const shared = Buffer.from([1, 2, 3]);
for (const videoId of [1, 2]) {
upsertCoverArt(db, videoId, {
anilistId: 4242,
coverUrl: 'https://images.test/a.jpg',
coverBlob: shared,
titleRomaji: null,
titleEnglish: null,
episodesTotal: null,
});
}
upsertCoverArt(db, 3, {
anilistId: 99,
coverUrl: 'https://images.test/b.jpg',
coverBlob: Buffer.from([9]),
titleRomaji: null,
titleEnglish: null,
episodesTotal: null,
});
clearAnimeCoverArt(db, 1);
assert.equal(getAnimeCoverArt(db, 1), null);
assert.equal(getCoverArt(db, 3)?.coverBlob?.length, 1);
const blobs = (
db.prepare('SELECT COUNT(*) AS n FROM imm_cover_art_blobs').get() as { n: number }
).n;
assert.equal(blobs, 1);
});
});
@@ -30,6 +30,9 @@ interface AnimeMetadataRow {
title_native: string | null;
episodes_total: number | null;
description: string | null;
media_kind: string;
tmdb_id: number | null;
tmdb_type: string | null;
}
function emptyMergeSummary(survivingAnimeId: number): AnimeMergeSummary {
@@ -52,7 +55,8 @@ function readAnimeMetadata(db: DatabaseSync, animeId: number): AnimeMetadataRow
return (db
.prepare(
`
SELECT normalized_title_key, anilist_id, title_romaji, title_english, title_native, episodes_total, description
SELECT normalized_title_key, anilist_id, title_romaji, title_english, title_native, episodes_total, description,
media_kind, tmdb_id, tmdb_type
FROM imm_anime
WHERE anime_id = ?
`,
@@ -131,6 +135,12 @@ function absorbAnimeMetadata(
title_native = COALESCE(title_native, ?),
episodes_total = COALESCE(episodes_total, ?),
description = COALESCE(description, ?),
tmdb_id = COALESCE(tmdb_id, ?),
tmdb_type = CASE WHEN tmdb_id IS NULL THEN ? ELSE tmdb_type END,
media_kind = CASE
WHEN anilist_id IS NULL AND tmdb_id IS NULL AND ? IS NOT NULL THEN ?
ELSE media_kind
END,
LAST_UPDATE_DATE = ?
WHERE anime_id = ?
`,
@@ -141,6 +151,10 @@ function absorbAnimeMetadata(
source.title_native,
source.episodes_total,
source.description,
source.tmdb_id,
source.tmdb_type,
source.tmdb_id,
source.media_kind,
updatedAt,
targetAnimeId,
);
@@ -335,7 +335,8 @@ export function repairLegacySeasonlessAnimeRows(db: DatabaseSync): AnimeSeasonRe
SELECT a.anime_id AS animeId
FROM imm_anime a
JOIN imm_videos v ON v.anime_id = a.anime_id
WHERE v.parsed_title IS NOT NULL
WHERE a.media_kind != 'live_action'
AND v.parsed_title IS NOT NULL
AND TRIM(v.parsed_title) != ''
AND v.parsed_season IS NOT NULL
AND v.parsed_season > 0
@@ -0,0 +1,149 @@
import type { DatabaseSync } from './sqlite';
import type { TmdbMediaType } from '../../../shared/media-kind';
import { mergeAnimeRecords } from './anime-merge';
import { toDbTimestamp } from './query-shared';
import { nowMs } from './time';
export interface LiveActionTitleInput {
tmdbId: number;
tmdbType: TmdbMediaType;
titleEnglish: string | null;
titleNative: string | null;
description: string | null;
episodesTotal: number | null;
}
export interface LiveActionLinkResult {
/** Library entry that carries the TMDB link once the call finishes. */
animeId: number;
/** Entries folded into `animeId` because they pointed at the same TMDB title. */
mergedAnimeIds: number[];
}
export interface LiveActionLinkOptions {
/**
* `manual`: the user picked this title, so stored titles are overwritten and
* every other holder of the TMDB id is folded into this entry.
* `auto`: an exact filename match, so gaps are filled and the entry joins an
* existing holder rather than displacing it.
*/
mode: 'manual' | 'auto';
}
export interface VideoTmdbLink {
animeId: number;
tmdbId: number;
tmdbType: TmdbMediaType;
}
function findOtherTmdbHolders(
db: DatabaseSync,
animeId: number,
input: Pick<LiveActionTitleInput, 'tmdbId' | 'tmdbType'>,
): number[] {
return (
db
.prepare(
`SELECT anime_id AS animeId
FROM imm_anime
WHERE tmdb_id = ? AND tmdb_type = ? AND anime_id != ?
ORDER BY anime_id ASC`,
)
.all(input.tmdbId, input.tmdbType, animeId) as Array<{ animeId: number }>
).map((row) => row.animeId);
}
/**
* Link a library entry to a TMDB title. Unlike AniList, a TMDB show spans all
* of its seasons, so entries that resolve to the same title are one show and
* are merged regardless of the season each was parsed with.
*/
export function linkAnimeToTmdbTitle(
db: DatabaseSync,
animeId: number,
input: LiveActionTitleInput,
options: LiveActionLinkOptions,
): LiveActionLinkResult {
const others = findOtherTmdbHolders(db, animeId, input);
let survivor = animeId;
let mergedAnimeIds: number[] = [];
if (others.length > 0) {
if (options.mode === 'manual') {
mergedAnimeIds = mergeAnimeRecords(db, animeId, others).mergedAnimeIds;
} else {
// Keep the entry the user already sees; the newcomer is the transient
// "Show Season 3" row that a fresh season folder just created.
survivor = others[0]!;
mergedAnimeIds = mergeAnimeRecords(db, survivor, [
animeId,
...others.slice(1),
]).mergedAnimeIds;
}
}
const updatedAt = toDbTimestamp(nowMs());
if (options.mode === 'manual') {
db.prepare(
`UPDATE imm_anime
SET media_kind = 'live_action',
tmdb_id = ?,
tmdb_type = ?,
anilist_id = NULL,
title_romaji = NULL,
title_english = ?,
title_native = ?,
episodes_total = ?,
description = ?,
LAST_UPDATE_DATE = ?
WHERE anime_id = ?`,
).run(
input.tmdbId,
input.tmdbType,
input.titleEnglish,
input.titleNative,
input.episodesTotal,
input.description,
updatedAt,
survivor,
);
} else {
db.prepare(
`UPDATE imm_anime
SET media_kind = 'live_action',
tmdb_id = ?,
tmdb_type = ?,
title_english = COALESCE(title_english, ?),
title_native = COALESCE(title_native, ?),
episodes_total = COALESCE(episodes_total, ?),
description = COALESCE(description, ?),
LAST_UPDATE_DATE = ?
WHERE anime_id = ?`,
).run(
input.tmdbId,
input.tmdbType,
input.titleEnglish,
input.titleNative,
input.episodesTotal,
input.description,
updatedAt,
survivor,
);
}
return { animeId: survivor, mergedAnimeIds };
}
/** The TMDB link of the live-action entry a video belongs to, if any. */
export function getVideoTmdbLink(db: DatabaseSync, videoId: number): VideoTmdbLink | null {
const row = db
.prepare(
`SELECT a.anime_id AS animeId, a.tmdb_id AS tmdbId, a.tmdb_type AS tmdbType
FROM imm_videos v
JOIN imm_anime a ON a.anime_id = v.anime_id
WHERE v.video_id = ?
AND a.media_kind = 'live_action'
AND a.tmdb_id IS NOT NULL
AND a.tmdb_type IN ('tv', 'movie')`,
)
.get(videoId) as VideoTmdbLink | undefined;
return row ? { animeId: row.animeId, tmdbId: row.tmdbId, tmdbType: row.tmdbType } : null;
}
@@ -34,6 +34,9 @@ export function getAnimeLibrary(db: DatabaseSync): AnimeLibraryRow[] {
a.anime_id AS animeId,
a.canonical_title AS canonicalTitle,
a.anilist_id AS anilistId,
a.media_kind AS mediaKind,
a.tmdb_id AS tmdbId,
a.tmdb_type AS tmdbType,
COALESCE(lm.total_sessions, 0) AS totalSessions,
COALESCE(lm.total_active_ms, 0) AS totalActiveMs,
COALESCE(lm.total_cards, 0) AS totalCards,
@@ -64,6 +67,9 @@ export function getAnimeDetail(db: DatabaseSync, animeId: number): AnimeDetailRo
a.anime_id AS animeId,
a.canonical_title AS canonicalTitle,
a.anilist_id AS anilistId,
a.media_kind AS mediaKind,
a.tmdb_id AS tmdbId,
a.tmdb_type AS tmdbType,
a.title_romaji AS titleRomaji,
a.title_english AS titleEnglish,
a.title_native AS titleNative,
@@ -331,6 +331,29 @@ export async function cleanupVocabularyStats(
};
}
/**
* Drop the cached art of every episode in a library entry. Used when a manual
* relink points at a title with no artwork, so the previous link's cover does
* not keep standing in for it.
*/
export function clearAnimeCoverArt(db: DatabaseSync, animeId: number): void {
const rows = db
.prepare(
`SELECT m.cover_blob_hash AS coverBlobHash
FROM imm_media_art m
JOIN imm_videos v ON v.video_id = m.video_id
WHERE v.anime_id = ?`,
)
.all(animeId) as Array<{ coverBlobHash: string | null }>;
if (rows.length === 0) return;
db.prepare(
'DELETE FROM imm_media_art WHERE video_id IN (SELECT video_id FROM imm_videos WHERE anime_id = ?)',
).run(animeId);
for (const hash of new Set(rows.map((row) => row.coverBlobHash))) {
cleanupUnusedCoverArtBlobHash(db, hash);
}
}
export function upsertCoverArt(
db: DatabaseSync,
videoId: number,
@@ -916,11 +916,18 @@ export function ensureSchema(db: DatabaseSync): void {
title_native TEXT,
episodes_total INTEGER,
description TEXT,
media_kind TEXT NOT NULL DEFAULT 'anime',
tmdb_id INTEGER,
tmdb_type TEXT,
metadata_json TEXT,
CREATED_DATE TEXT,
LAST_UPDATE_DATE TEXT
);
`);
// Schema 24: live-action entries carry a TMDB link instead of an AniList id.
addColumnIfMissing(db, 'imm_anime', 'media_kind', "TEXT NOT NULL DEFAULT 'anime'");
addColumnIfMissing(db, 'imm_anime', 'tmdb_id', 'INTEGER');
addColumnIfMissing(db, 'imm_anime', 'tmdb_type', 'TEXT');
db.exec(`
CREATE TABLE IF NOT EXISTS imm_videos(
video_id INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -1478,6 +1485,10 @@ export function ensureSchema(db: DatabaseSync): void {
CREATE INDEX IF NOT EXISTS idx_anime_anilist_id
ON imm_anime(anilist_id)
`);
db.exec(`
CREATE INDEX IF NOT EXISTS idx_anime_tmdb_id
ON imm_anime(tmdb_id, tmdb_type)
`);
db.exec(`
CREATE INDEX IF NOT EXISTS idx_videos_anime_id
ON imm_videos(anime_id)
+9 -1
View File
@@ -1,4 +1,6 @@
export const SCHEMA_VERSION = 23;
import type { MediaKind, TmdbMediaType } from '../../../shared/media-kind';
export const SCHEMA_VERSION = 24;
export const DEFAULT_QUEUE_CAP = 1_000;
export const DEFAULT_BATCH_SIZE = 25;
export const DEFAULT_FLUSH_INTERVAL_MS = 500;
@@ -521,6 +523,9 @@ export interface AnimeLibraryRow {
animeId: number;
canonicalTitle: string;
anilistId: number | null;
mediaKind: MediaKind;
tmdbId: number | null;
tmdbType: TmdbMediaType | null;
totalSessions: number;
totalActiveMs: number;
totalCards: number;
@@ -534,6 +539,9 @@ export interface AnimeDetailRow {
animeId: number;
canonicalTitle: string;
anilistId: number | null;
mediaKind: MediaKind;
tmdbId: number | null;
tmdbType: TmdbMediaType | null;
titleRomaji: string | null;
titleEnglish: string | null;
titleNative: string | null;
+4
View File
@@ -3,6 +3,7 @@ import http, { type IncomingMessage, type ServerResponse } from 'node:http';
import { Readable } from 'node:stream';
import type { AnkiConnectConfig } from '../../types.js';
import type { AnilistRateLimiter } from './anilist/rate-limiter.js';
import type { TmdbClient } from './tmdb/tmdb-client.js';
import type { ImmersionTrackerService } from './immersion-tracker-service.js';
import type { RetimedSecondarySubtitleInput } from './secondary-subtitle-sidecar.js';
import type { StatsServerMediaGenerator } from './stats-server/mining-support.js';
@@ -86,6 +87,7 @@ export interface StatsServerConfig {
input: RetimedSecondarySubtitleInput,
) => Promise<string> | string;
anilistRateLimiter?: AnilistRateLimiter;
tmdbClient?: TmdbClient;
addYomitanNote?: (word: string) => Promise<number | null>;
resolveAnkiNoteId?: (noteId: number) => number;
resolveSentenceSearchHeadwords?: (term: string) => Promise<string[]> | string[];
@@ -108,6 +110,7 @@ export function createStatsApp(
input: RetimedSecondarySubtitleInput,
) => Promise<string> | string;
anilistRateLimiter?: AnilistRateLimiter;
tmdbClient?: TmdbClient;
addYomitanNote?: (word: string) => Promise<number | null>;
resolveAnkiNoteId?: (noteId: number) => number;
resolveSentenceSearchHeadwords?: (term: string) => Promise<string[]> | string[];
@@ -139,6 +142,7 @@ export function startStatsServer(config: StatsServerConfig): { close: () => void
getStatsMiningAlassPath: config.getStatsMiningAlassPath,
resolveRetimedSecondarySubtitleText: config.resolveRetimedSecondarySubtitleText,
anilistRateLimiter: config.anilistRateLimiter,
tmdbClient: config.tmdbClient,
addYomitanNote: config.addYomitanNote,
resolveAnkiNoteId: config.resolveAnkiNoteId,
resolveSentenceSearchHeadwords: config.resolveSentenceSearchHeadwords,
@@ -1,11 +1,13 @@
import type { Hono } from 'hono';
import type { Context, Hono } from 'hono';
import type { AnkiConnectConfig } from '../../../types.js';
import {
statsJson,
type StatsAnilistSearchResult,
type StatsAnkiBrowseResponse,
} from '../../../types/stats-http-contract.js';
import { isTmdbMediaType } from '../../../shared/media-kind.js';
import type { AnilistRateLimiter } from '../anilist/rate-limiter.js';
import { TmdbApiKeyMissingError, type TmdbClient } from '../tmdb/tmdb-client.js';
import { registerStatsCoverRoutes } from '../stats-cover-routes.js';
import type { ImmersionTrackerService } from '../immersion-tracker-service.js';
import {
@@ -27,6 +29,7 @@ export function registerStatsIntegrationRoutes(
ankiConnectConfig?: AnkiConnectConfig;
getAnkiConnectConfig?: () => AnkiConnectConfig | undefined;
anilistRateLimiter?: AnilistRateLimiter;
tmdbClient?: TmdbClient;
resolveAnkiNoteId?: (noteId: number) => number;
},
): void {
@@ -71,6 +74,50 @@ export function registerStatsIntegrationRoutes(
}
});
const tmdbUnavailable = (c: Context, err: unknown) => {
if (err instanceof TmdbApiKeyMissingError) {
return c.json(statsJson('error', { error: err.message }), 503);
}
return c.json(statsJson('error', { error: 'TMDB request failed' }), 502);
};
app.get('/api/stats/tmdb/search', async (c) => {
const query = (c.req.query('q') ?? '').trim();
if (!query) return c.json(statsJson('tmdbSearch', []));
const tmdbClient = options?.tmdbClient;
if (!tmdbClient) return c.json(statsJson('tmdbSearch', []));
try {
return c.json(statsJson('tmdbSearch', await tmdbClient.search(query)));
} catch (err) {
return tmdbUnavailable(c, err);
}
});
app.patch('/api/stats/anime/:animeId/tmdb', async (c) => {
const animeId = parseIntQuery(c.req.param('animeId'), 0);
if (animeId <= 0) return c.body(null, 400);
const body = await c.req.json().catch(() => null);
const tmdbId = body?.tmdbId;
if (
typeof tmdbId !== 'number' ||
!Number.isInteger(tmdbId) ||
tmdbId <= 0 ||
!isTmdbMediaType(body?.tmdbType)
) {
return c.body(null, 400);
}
const tmdbClient = options?.tmdbClient;
if (!tmdbClient) return c.json(statsJson('error', { error: 'TMDB is not available' }), 503);
try {
const details = await tmdbClient.getDetails(body.tmdbType, tmdbId);
if (!details) return c.body(null, 404);
await tracker.reassignAnimeTmdb(animeId, details);
return c.json(statsJson('reassignAnimeTmdb', { ok: true }));
} catch (err) {
return tmdbUnavailable(c, err);
}
});
app.get('/api/stats/known-words', (c) => {
const knownWordsSet = loadKnownWordsSet(options?.knownWordCachePath);
if (!knownWordsSet) return c.json(statsJson('knownWords', []));
+21 -1
View File
@@ -10,6 +10,9 @@ const ANIME_COPY_COLUMNS = [
'title_native',
'episodes_total',
'description',
'media_kind',
'tmdb_id',
'tmdb_type',
'metadata_json',
'CREATED_DATE',
'LAST_UPDATE_DATE',
@@ -99,7 +102,13 @@ export function mergeAnime(
): Map<number, number> {
const map = new Map<number, number>();
const byAnilist = local.query('SELECT anime_id FROM imm_anime WHERE anilist_id = ?');
const byTmdb = local.query(
'SELECT anime_id FROM imm_anime WHERE tmdb_id = ? AND tmdb_type = ? ORDER BY anime_id LIMIT 1',
);
const byTitleKey = local.query('SELECT anime_id FROM imm_anime WHERE normalized_title_key = ?');
// A TMDB link only fills in when the local row is unlinked: a row already
// pinned to AniList stays anime, and vice versa, so the two link kinds never
// coexist on one entry.
const fillMissing = local.query(
`UPDATE imm_anime
SET
@@ -107,7 +116,13 @@ export function mergeAnime(
title_english = COALESCE(title_english, ?),
title_native = COALESCE(title_native, ?),
episodes_total = COALESCE(episodes_total, ?),
description = COALESCE(description, ?)
description = COALESCE(description, ?),
tmdb_id = CASE WHEN anilist_id IS NULL THEN COALESCE(tmdb_id, ?) ELSE tmdb_id END,
tmdb_type = CASE WHEN anilist_id IS NULL AND tmdb_id IS NULL THEN ? ELSE tmdb_type END,
media_kind = CASE
WHEN anilist_id IS NULL AND tmdb_id IS NULL AND ? IS NOT NULL THEN ?
ELSE media_kind
END
WHERE anime_id = ?`,
);
@@ -117,6 +132,7 @@ export function mergeAnime(
)) {
const remoteId = Number(row.anime_id);
const existing = ((row.anilist_id !== null ? byAnilist.get(row.anilist_id) : undefined) ??
(row.tmdb_id !== null ? byTmdb.get(row.tmdb_id, row.tmdb_type) : undefined) ??
byTitleKey.get(row.normalized_title_key)) as SqlRow | undefined;
if (existing) {
const localId = Number(existing.anime_id);
@@ -127,6 +143,10 @@ export function mergeAnime(
row.title_native,
row.episodes_total,
row.description,
row.tmdb_id,
row.tmdb_type,
row.tmdb_id,
row.media_kind,
localId,
);
continue;
@@ -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));
},
};
}
+196
View File
@@ -0,0 +1,196 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { TmdbApiKeyMissingError, createTmdbClient, resolveTmdbApiKey } from './tmdb-client.js';
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 () => {
assert.equal(await resolveTmdbApiKey({ apiKey: ' abc ', apiKeyCommand: 'echo nope' }), '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 () => {
assert.equal(await resolveTmdbApiKey({ apiKeyCommand: 'printf " from-cmd "' }), 'from-cmd');
assert.equal(await resolveTmdbApiKey({ apiKeyCommand: 'exit 3' }), null);
});
test('resolveTmdbApiKey falls back to the bundled key only when the user set nothing usable', async () => {
assert.equal(await resolveTmdbApiKey({}, 'bundled'), 'bundled');
assert.equal(await resolveTmdbApiKey({ apiKey: 'mine' }, 'bundled'), 'mine');
assert.equal(await resolveTmdbApiKey({ apiKeyCommand: 'printf mine' }, 'bundled'), 'mine');
assert.equal(await resolveTmdbApiKey({ apiKeyCommand: '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 { calls, fetchImpl } = captureFetch(() => jsonResponse({ results: [] }));
const client = createTmdbClient({
resolveApiKey: async () => 'eyJhbGciOiJIUzI1NiJ9.payload.sig',
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 eyJhbGciOiJIUzI1NiJ9.payload.sig',
);
});
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);
});
+265
View File
@@ -0,0 +1,265 @@
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 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;
}
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;
},
};
}