mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-09-19 05:16:27 -07:00
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:
@@ -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: [
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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')
|
||||
) {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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', []));
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
});
|
||||
@@ -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,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);
|
||||
});
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
}
|
||||
+14
@@ -429,6 +429,9 @@ import {
|
||||
} from './core/services/anilist/anilist-updater';
|
||||
import { createCoverArtFetcher } from './core/services/anilist/cover-art-fetcher';
|
||||
import { createAnilistRateLimiter } from './core/services/anilist/rate-limiter';
|
||||
import { createLiveActionMetadataResolver } from './core/services/tmdb/live-action-resolver';
|
||||
import { createTmdbClient, resolveTmdbApiKey } from './core/services/tmdb/tmdb-client';
|
||||
import { readBundledTmdbApiKey } from './core/services/tmdb/bundled-api-key';
|
||||
import { createJellyfinTokenStore } from './core/services/jellyfin-token-store';
|
||||
import { applyRuntimeOptionResultRuntime } from './core/services/runtime-options-ipc';
|
||||
import { createAnilistTokenStore } from './core/services/anilist/anilist-token-store';
|
||||
@@ -962,6 +965,8 @@ const reportFatalError = createFatalErrorReporter({
|
||||
|
||||
let forceQuitTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
const statsDistPath = path.join(__dirname, '..', 'stats', 'dist');
|
||||
// Release builds stage a project TMDB key next to the compiled main process.
|
||||
const bundledTmdbApiKey = readBundledTmdbApiKey(__dirname);
|
||||
const statsPreloadPath = path.join(__dirname, 'preload-stats.js');
|
||||
const statsServerRuntime = createStatsServerRuntime({
|
||||
userDataPath: USER_DATA_PATH,
|
||||
@@ -988,6 +993,7 @@ const statsServerRuntime = createStatsServerRuntime({
|
||||
},
|
||||
getYomitanAnkiDeckName: () => getCurrentYomitanAnkiDeckNameForRuntime(),
|
||||
getAnilistRateLimiter: () => anilistRateLimiter,
|
||||
getBundledTmdbApiKey: () => bundledTmdbApiKey,
|
||||
resolveAnkiNoteId: (noteId) => appState.ankiIntegration?.resolveCurrentNoteId(noteId) ?? noteId,
|
||||
trackDuplicateNoteIdsForNote: (noteId, duplicateNoteIds) => {
|
||||
appState.ankiIntegration?.trackDuplicateNoteIdsForNote(noteId, duplicateNoteIds);
|
||||
@@ -1680,6 +1686,14 @@ const anilistRateLimiter = createAnilistRateLimiter();
|
||||
const statsCoverArtFetcher = createCoverArtFetcher(
|
||||
anilistRateLimiter,
|
||||
createLogger('main:stats-cover-art'),
|
||||
{
|
||||
liveAction: createLiveActionMetadataResolver(
|
||||
createTmdbClient({
|
||||
resolveApiKey: () => resolveTmdbApiKey(configService.getConfig().tmdb, bundledTmdbApiKey),
|
||||
}),
|
||||
createLogger('main:tmdb'),
|
||||
),
|
||||
},
|
||||
);
|
||||
const anilistStateRuntime = createAnilistStateRuntime(buildAnilistStateRuntimeMainDepsHandler());
|
||||
const configDerivedRuntime = createConfigDerivedRuntime(buildConfigDerivedRuntimeMainDepsHandler());
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
syncYomitanDefaultAnkiServer as syncYomitanDefaultAnkiServerCore,
|
||||
} from '../../core/services';
|
||||
import { startStatsServer } from '../../core/services/stats-server';
|
||||
import { createTmdbClient, resolveTmdbApiKey } from '../../core/services/tmdb/tmdb-client';
|
||||
import { createLogger } from '../../logger';
|
||||
import type { ResolvedConfig } from '../../types/config';
|
||||
import type { AppState } from '../state';
|
||||
@@ -52,6 +53,8 @@ export interface StatsServerRuntimeDeps {
|
||||
getAnilistRateLimiter: () => NonNullable<
|
||||
Parameters<typeof startStatsServer>[0]['anilistRateLimiter']
|
||||
>;
|
||||
/** Project TMDB key staged into release builds; null for source builds. */
|
||||
getBundledTmdbApiKey?: () => string | null;
|
||||
resolveAnkiNoteId: (noteId: number) => number;
|
||||
trackDuplicateNoteIdsForNote: (noteId: number, duplicateNoteIds: number[]) => void;
|
||||
resolveSentenceSearchHeadwords: (term: string) => Promise<string[]>;
|
||||
@@ -165,6 +168,10 @@ export function createStatsServerRuntime(deps: StatsServerRuntimeDeps): {
|
||||
deps.getResolvedConfig().secondarySub.secondarySubLanguages,
|
||||
getStatsMiningAlassPath: () => deps.getResolvedConfig().subsync.alass_path,
|
||||
anilistRateLimiter: deps.getAnilistRateLimiter(),
|
||||
tmdbClient: createTmdbClient({
|
||||
resolveApiKey: () =>
|
||||
resolveTmdbApiKey(deps.getResolvedConfig().tmdb, deps.getBundledTmdbApiKey?.() ?? null),
|
||||
}),
|
||||
resolveAnkiNoteId: (noteId: number) => deps.resolveAnkiNoteId(noteId),
|
||||
resolveSentenceSearchHeadwords: (term: string) => deps.resolveSentenceSearchHeadwords(term),
|
||||
addYomitanNote: async (word: string) => {
|
||||
|
||||
@@ -125,18 +125,21 @@ test('prerelease workflow builds and uploads all release platforms', () => {
|
||||
assert.ok(executableRunLines(publish).includes('release/package-size-*.json'));
|
||||
});
|
||||
|
||||
test('release callers pass only the declared macOS signing secrets to packaging', () => {
|
||||
const secrets = [
|
||||
test('release callers pass only the declared packaging secrets', () => {
|
||||
const signingSecrets = [
|
||||
'CSC_LINK',
|
||||
'CSC_KEY_PASSWORD',
|
||||
'APPLE_ID',
|
||||
'APPLE_APP_SPECIFIC_PASSWORD',
|
||||
'APPLE_TEAM_ID',
|
||||
];
|
||||
assert.deepEqual(
|
||||
parsedPackageWorkflow.on?.workflow_call?.secrets,
|
||||
Object.fromEntries(secrets.map((name) => [name, { required: true }])),
|
||||
);
|
||||
// The bundled TMDB key is optional: artifacts stay valid without it and
|
||||
// users fall back to their own tmdb.apiKey.
|
||||
const optionalSecrets = ['SUBMINER_TMDB_API_KEY'];
|
||||
assert.deepEqual(parsedPackageWorkflow.on?.workflow_call?.secrets, {
|
||||
...Object.fromEntries(signingSecrets.map((name) => [name, { required: true }])),
|
||||
...Object.fromEntries(optionalSecrets.map((name) => [name, { required: false }])),
|
||||
});
|
||||
for (const workflow of [
|
||||
parsedPrereleaseWorkflow,
|
||||
readWorkflow(resolve(__dirname, '../.github/workflows/release.yml')),
|
||||
@@ -144,7 +147,12 @@ test('release callers pass only the declared macOS signing secrets to packaging'
|
||||
assert.equal(workflow.jobs?.package?.uses, './.github/workflows/package-release.yml');
|
||||
assert.deepEqual(
|
||||
workflow.jobs?.package?.secrets,
|
||||
Object.fromEntries(secrets.map((name) => [name, '${{ secrets.' + name + ' }}'])),
|
||||
Object.fromEntries(
|
||||
[...signingSecrets, ...optionalSecrets].map((name) => [
|
||||
name,
|
||||
'${{ secrets.' + name + ' }}',
|
||||
]),
|
||||
),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
/**
|
||||
* Library entry classification shared by the tracker, the stats HTTP layer and
|
||||
* the stats SPA. Anime entries link to AniList; live-action entries link to TMDB.
|
||||
*/
|
||||
export const MEDIA_KINDS = ['anime', 'live_action'] as const;
|
||||
export type MediaKind = (typeof MEDIA_KINDS)[number];
|
||||
|
||||
export const TMDB_MEDIA_TYPES = ['tv', 'movie'] as const;
|
||||
export type TmdbMediaType = (typeof TMDB_MEDIA_TYPES)[number];
|
||||
|
||||
export function isMediaKind(value: unknown): value is MediaKind {
|
||||
return typeof value === 'string' && (MEDIA_KINDS as readonly string[]).includes(value);
|
||||
}
|
||||
|
||||
export function isTmdbMediaType(value: unknown): value is TmdbMediaType {
|
||||
return typeof value === 'string' && (TMDB_MEDIA_TYPES as readonly string[]).includes(value);
|
||||
}
|
||||
@@ -7,6 +7,9 @@ import { createLogger, setLogLevel } from './logger';
|
||||
import { ImmersionTrackerService } from './core/services/immersion-tracker-service';
|
||||
import { createCoverArtFetcher } from './core/services/anilist/cover-art-fetcher';
|
||||
import { createAnilistRateLimiter } from './core/services/anilist/rate-limiter';
|
||||
import { createLiveActionMetadataResolver } from './core/services/tmdb/live-action-resolver';
|
||||
import { createTmdbClient, resolveTmdbApiKey } from './core/services/tmdb/tmdb-client';
|
||||
import { readBundledTmdbApiKey } from './core/services/tmdb/bundled-api-key';
|
||||
import { startStatsServer } from './core/services/stats-server';
|
||||
import {
|
||||
removeBackgroundStatsServerState,
|
||||
@@ -124,6 +127,7 @@ const daemonUserDataPath = userDataPath;
|
||||
const statePath = path.join(userDataPath, 'stats-daemon.json');
|
||||
const knownWordCachePath = path.join(userDataPath, 'known-words-cache.json');
|
||||
const statsDistPath = path.join(__dirname, '..', 'stats', 'dist');
|
||||
const bundledTmdbApiKey = readBundledTmdbApiKey(__dirname);
|
||||
const wordHelperScriptPath = path.join(__dirname, 'stats-word-helper.js');
|
||||
|
||||
let tracker: ImmersionTrackerService | null = null;
|
||||
@@ -194,8 +198,13 @@ async function main(): Promise<void> {
|
||||
},
|
||||
},
|
||||
});
|
||||
const tmdbClient = createTmdbClient({
|
||||
resolveApiKey: () => resolveTmdbApiKey(configService.reloadConfig().tmdb, bundledTmdbApiKey),
|
||||
});
|
||||
tracker.setCoverArtFetcher(
|
||||
createCoverArtFetcher(createAnilistRateLimiter(), createLogger('stats-daemon:cover-art')),
|
||||
createCoverArtFetcher(createAnilistRateLimiter(), createLogger('stats-daemon:cover-art'), {
|
||||
liveAction: createLiveActionMetadataResolver(tmdbClient, createLogger('stats-daemon:tmdb')),
|
||||
}),
|
||||
);
|
||||
|
||||
statsServer = startStatsServer({
|
||||
@@ -204,6 +213,7 @@ async function main(): Promise<void> {
|
||||
tracker,
|
||||
knownWordCachePath,
|
||||
getAnkiConnectConfig: () => configService.reloadConfig().ankiConnect,
|
||||
tmdbClient,
|
||||
getYomitanAnkiDeckName: async () =>
|
||||
await readStatsYomitanDeckName({
|
||||
helperScriptPath: wordHelperScriptPath,
|
||||
|
||||
@@ -12,6 +12,7 @@ import type {
|
||||
ImmersionTrackingRetentionMode,
|
||||
ImmersionTrackingRetentionPreset,
|
||||
TsukihimeConfig,
|
||||
TmdbConfig,
|
||||
JellyfinConfig,
|
||||
JimakuConfig,
|
||||
JimakuLanguagePreference,
|
||||
@@ -160,6 +161,7 @@ export interface Config {
|
||||
/** @deprecated Use tsukihime. */
|
||||
animetosho?: TsukihimeConfig;
|
||||
tsukihime?: TsukihimeConfig;
|
||||
tmdb?: TmdbConfig;
|
||||
anilist?: AnilistConfig;
|
||||
yomitan?: YomitanConfig;
|
||||
jellyfin?: JellyfinConfig;
|
||||
@@ -332,6 +334,10 @@ export interface ResolvedConfig {
|
||||
apiBaseUrl: string;
|
||||
maxSearchResults: number;
|
||||
};
|
||||
tmdb: {
|
||||
apiKey: string;
|
||||
apiKeyCommand: string;
|
||||
};
|
||||
anilist: {
|
||||
enabled: boolean;
|
||||
accessToken: string;
|
||||
|
||||
@@ -283,3 +283,9 @@ export interface TsukihimeConfig {
|
||||
apiBaseUrl?: string;
|
||||
maxSearchResults?: number;
|
||||
}
|
||||
|
||||
/** TMDB (The Movie Database) access for live-action drama and movie metadata. */
|
||||
export interface TmdbConfig {
|
||||
apiKey?: string;
|
||||
apiKeyCommand?: string;
|
||||
}
|
||||
|
||||
@@ -27,6 +27,7 @@ import type {
|
||||
WatchTimePerAnime,
|
||||
WordDetailData,
|
||||
} from './stats-wire';
|
||||
import type { TmdbMediaType } from '../shared/media-kind';
|
||||
|
||||
export type StatsTrendRange = '7d' | '30d' | '90d' | '365d' | 'all';
|
||||
export type StatsTrendGroupBy = 'day' | 'month';
|
||||
@@ -78,6 +79,25 @@ export interface StatsAnilistSearchResult {
|
||||
title: { romaji: string | null; english: string | null; native: string | null } | null;
|
||||
}
|
||||
|
||||
export interface StatsTmdbSearchResult {
|
||||
tmdbId: number;
|
||||
tmdbType: TmdbMediaType;
|
||||
/** English title when TMDB has one, otherwise the original title. */
|
||||
title: string;
|
||||
originalTitle: string;
|
||||
originalLanguage: string;
|
||||
overview: string | null;
|
||||
posterUrl: string | null;
|
||||
year: number | null;
|
||||
/** True when TMDB tags the title with the Animation genre. */
|
||||
isAnimation: boolean;
|
||||
}
|
||||
|
||||
export interface StatsTmdbAssignment {
|
||||
tmdbId: number;
|
||||
tmdbType: TmdbMediaType;
|
||||
}
|
||||
|
||||
export interface StatsAnilistAssignment {
|
||||
anilistId: number;
|
||||
titleRomaji?: string | null;
|
||||
@@ -209,11 +229,13 @@ export interface StatsJsonResponseMap {
|
||||
moveVideoToAnime: StatsMoveVideoResponse;
|
||||
dismissAnimeMergeRecommendation: StatsOkResponse;
|
||||
anilistSearch: StatsAnilistSearchResult[];
|
||||
tmdbSearch: StatsTmdbSearchResult[];
|
||||
knownWords: string[];
|
||||
knownWordsSummary: StatsKnownWordsSummary;
|
||||
animeKnownWordsSummary: StatsKnownWordsSummary;
|
||||
mediaKnownWordsSummary: StatsKnownWordsSummary;
|
||||
reassignAnimeAnilist: StatsOkResponse;
|
||||
reassignAnimeTmdb: StatsOkResponse;
|
||||
coverImages: StatsCoverImagesData;
|
||||
episodeDetail: EpisodeDetailData;
|
||||
ankiBrowse: StatsAnkiBrowseResponse;
|
||||
@@ -302,6 +324,8 @@ export interface StatsHttpClient {
|
||||
getMediaKnownWordsSummary: (videoId: number) => Promise<StatsKnownWordsSummary>;
|
||||
searchAnilist: (query: string) => Promise<StatsAnilistSearchResult[]>;
|
||||
reassignAnimeAnilist: (animeId: number, info: StatsAnilistAssignment) => Promise<void>;
|
||||
searchTmdb: (query: string) => Promise<StatsTmdbSearchResult[]>;
|
||||
reassignAnimeTmdb: (animeId: number, info: StatsTmdbAssignment) => Promise<void>;
|
||||
mineCard: (params: StatsMineCardParams) => Promise<StatsMineCardResponse>;
|
||||
ankiBrowse: (noteId: number) => Promise<void>;
|
||||
ankiNotesInfo: (noteIds: number[]) => Promise<StatsAnkiNoteInfo[]>;
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import type { MediaKind, TmdbMediaType } from '../shared/media-kind';
|
||||
|
||||
export interface SessionSummary {
|
||||
sessionId: number;
|
||||
canonicalTitle: string | null;
|
||||
@@ -243,6 +245,9 @@ export interface AnimeLibraryItem {
|
||||
animeId: number;
|
||||
canonicalTitle: string;
|
||||
anilistId: number | null;
|
||||
mediaKind: MediaKind;
|
||||
tmdbId: number | null;
|
||||
tmdbType: TmdbMediaType | null;
|
||||
totalSessions: number;
|
||||
totalActiveMs: number;
|
||||
totalCards: number;
|
||||
@@ -264,6 +269,9 @@ export interface AnimeDetailData {
|
||||
animeId: number;
|
||||
canonicalTitle: string;
|
||||
anilistId: number | null;
|
||||
mediaKind: MediaKind;
|
||||
tmdbId: number | null;
|
||||
tmdbType: TmdbMediaType | null;
|
||||
titleRomaji: string | null;
|
||||
titleEnglish: string | null;
|
||||
titleNative: string | null;
|
||||
|
||||
Reference in New Issue
Block a user