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

This commit is contained in:
2026-09-21 00:10:00 -07:00
committed by GitHub
parent 4dd30f44d1
commit 1508863dbb
76 changed files with 3595 additions and 238 deletions
@@ -11,6 +11,7 @@ import {
startStatsServerWithRuntime,
} from '../stats-server.js';
import type { ImmersionTrackerService } from '../immersion-tracker-service.js';
import { INCOMPATIBLE_PROVIDER_MERGE_MESSAGE } from '../immersion-tracker/anime-merge.js';
import {
clearRetimedSecondarySubtitleCache,
resolveRetimedSecondarySubtitleTextFromSidecar,
@@ -311,6 +312,7 @@ function createMockTracker(
getKanjiOccurrences: async () => OCCURRENCES,
getAnimeLibrary: async () => ANIME_LIBRARY,
getAnimeDetail: async (animeId: number) => (animeId === 1 ? ANIME_DETAIL : null),
hasAnime: async (animeId: number) => animeId === 1,
getAnimeEpisodes: async () => ANIME_EPISODES,
getAnimeAnilistEntries: async () => [],
getAnimeWords: async () => ANIME_WORDS,
@@ -3729,6 +3731,25 @@ Aligned English subtitle
assert.equal(res.status, 404);
});
it('POST /api/stats/anime/:animeId/merge rejects mixed AniList and TMDB entries as 409', async () => {
const app = createStatsApp(
createMockTracker({
mergeAnime: async () => {
throw new Error(INCOMPATIBLE_PROVIDER_MERGE_MESSAGE);
},
} as Partial<ImmersionTrackerService>),
);
const res = await app.request('/api/stats/anime/7/merge', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: '{"sourceAnimeIds":[8]}',
});
assert.equal(res.status, 409);
assert.deepEqual(await res.json(), { error: INCOMPATIBLE_PROVIDER_MERGE_MESSAGE });
});
it('PATCH /api/stats/media/:videoId/anime reports an unknown target as 404', async () => {
const app = createStatsApp(
createMockTracker({
@@ -4252,3 +4273,51 @@ Aligned English subtitle
});
});
});
it('TMDB reassignment returns 404 for a missing library entry before fetching details', async () => {
const assignments: number[] = [];
let fetches = 0;
const app = createStatsApp(
createMockTracker({
reassignAnimeTmdb: async (animeId: number) => {
assignments.push(animeId);
return { animeId, mergedAnimeIds: [] };
},
}),
{
tmdbClient: {
search: async () => [],
getDetails: async () => {
fetches += 1;
return {
tmdbId: 12,
tmdbType: 'tv',
titleEnglish: 'Drama',
titleNative: null,
description: null,
posterUrl: null,
episodesTotal: 10,
year: null,
originalLanguage: 'ja',
isAnimation: false,
allTitles: ['Drama'],
};
},
},
},
);
const request = (animeId: number) =>
app.request(`/api/stats/anime/${animeId}/tmdb`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ tmdbId: 12, tmdbType: 'tv' }),
});
assert.equal((await request(99999)).status, 404);
assert.equal(fetches, 0);
assert.deepEqual(assignments, []);
const response = await request(1);
assert.equal(response.status, 200);
assert.deepEqual(await response.json(), { ok: true });
assert.equal(fetches, 1);
assert.deepEqual(assignments, [1]);
});
@@ -540,3 +540,200 @@ test('fetchIfMissing re-resolves an unresolved season once AniList publishes the
cleanupDbPath(dbPath);
}
});
for (const linkedToAnilist of [false, true]) {
test(`TMDB fallback preserves AniList identity when linked=${linkedToAnilist}`, 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: linkedToAnilist ? 42 : 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 };
if (linkedToAnilist) {
assert.equal(fetched, false);
assert.equal(stored?.coverBlob, null);
assert.equal(stored?.coverUrl, null);
assert.equal(anime.mediaKind, 'anime');
assert.equal(anime.tmdbId, null);
assert.deepEqual(resolvedTitles, []);
const requestCount = fetchCalls.length;
assert.equal(await fetcher.fetchIfMissing(db, videoId, 'Hanzawa Naoki - 01.mkv'), false);
assert.equal(fetchCalls.length, requestCount);
return;
}
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);
}
});
+91 -18
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,60 @@ 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 v.anime_id AS animeId, a.anilist_id AS anilistId
FROM imm_videos v LEFT JOIN imm_anime a ON a.anime_id = v.anime_id
WHERE v.video_id = ?`,
)
.get(videoId) as { animeId: number | null; anilistId: number | null } | undefined;
if (row?.anilistId != null) return false;
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,
@@ -197,7 +256,7 @@ export function createCoverArtFetcher(
`
SELECT 1 FROM imm_videos v
JOIN imm_anime a ON a.anime_id = v.anime_id
WHERE v.video_id = ? AND a.media_kind != 'anime'
WHERE v.video_id = ? AND a.media_kind = 'youtube'
`,
)
.get(videoId);
@@ -235,18 +294,31 @@ 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 hasAnilistLink = Boolean(
db
.prepare(
`SELECT 1 FROM imm_videos v JOIN imm_anime a ON a.anime_id = v.anime_id
WHERE v.video_id = ? AND a.anilist_id IS NOT NULL`,
)
.get(videoId),
);
const tmdbLink = getVideoTmdbLink(db, videoId);
if (tmdbLink && !hasAnilistLink) {
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;
}
@@ -304,15 +376,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 && !hasAnilistLink) {
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;
}
@@ -5394,3 +5394,91 @@ test('getVocabularySummary keeps different known-word snapshots independent', as
cleanupDbPath(dbPath);
}
});
for (const provider of ['anilist', 'tmdb'] as const) {
test(`${provider} reassignment keeps metadata and artwork on download failure, then replaces or clears both`, async () => {
const dbPath = makeDbPath();
const originalFetch = globalThis.fetch;
let tracker: ImmersionTrackerService | null = null;
try {
const Ctor = await loadTrackerCtor();
tracker = new Ctor({ dbPath });
const { db } = tracker as unknown as { db: DatabaseSync };
db.exec(`
INSERT INTO imm_anime(anime_id, normalized_title_key, canonical_title, CREATED_DATE, LAST_UPDATE_DATE)
VALUES (1, 'show', 'Show', 1000, 1000);
INSERT INTO imm_videos(video_id, video_key, canonical_title, source_type, anime_id, duration_ms, CREATED_DATE, LAST_UPDATE_DATE)
VALUES (1, 'local:/tmp/show.mkv', 'Show', 1, 1, 0, 1000, 1000);
`);
const tmdb = {
tmdbId: 12,
tmdbType: 'tv' as const,
titleEnglish: 'Drama',
titleNative: null,
description: 'New description',
episodesTotal: 10,
};
globalThis.fetch = async () => new Response(new Uint8Array([1, 2, 3]));
if (provider === 'anilist') {
await tracker.reassignAnimeTmdb(1, { ...tmdb, posterUrl: 'https://images.test/old' });
} else {
await tracker.reassignAnimeAnilist(1, {
anilistId: 42,
coverUrl: 'https://images.test/old',
});
}
assert.equal(await tracker.hasAnime(1), true);
assert.equal(await tracker.hasAnime(999), false);
const readMetadata = () =>
db.prepare('SELECT * FROM imm_anime WHERE anime_id = 1').get() as {
media_kind: string;
anilist_id: number | null;
tmdb_id: number | null;
};
const before = readMetadata();
const oldArt = await tracker.getAnimeCoverArt(1);
const snapshot = (value: unknown) =>
JSON.stringify(value, (key, item: unknown) => (key === '_metadata' ? undefined : item));
const reassign = (url: string | null) =>
provider === 'anilist'
? tracker!.reassignAnimeAnilist(1, { anilistId: 99, coverUrl: url })
: tracker!.reassignAnimeTmdb(1, { ...tmdb, posterUrl: url });
for (const failure of ['http', 'network']) {
globalThis.fetch = async () => {
if (failure === 'network') throw new Error('offline');
return new Response(null, { status: 503 });
};
await assert.rejects(reassign('https://images.test/new'));
assert.equal(snapshot(readMetadata()), snapshot(before));
assert.equal(snapshot(await tracker.getAnimeCoverArt(1)), snapshot(oldArt));
}
globalThis.fetch = async () => new Response(new Uint8Array([9, 8, 7]));
if (provider === 'anilist') {
// Retained sessions without lifetime summaries exercise the bootstrap
// inside the reassignment transaction.
db.exec(`INSERT INTO imm_sessions(session_uuid, video_id, started_at_ms, ended_at_ms,
status, active_watched_ms, CREATED_DATE, LAST_UPDATE_DATE)
VALUES ('retained-session', 1, '1000', '2000', 2, 1000, 1000, 2000)`);
}
await reassign('https://images.test/new');
const detail = readMetadata();
assert.equal(detail.media_kind, provider === 'anilist' ? 'anime' : 'live_action');
assert.equal(detail.anilist_id, provider === 'anilist' ? 99 : null);
assert.equal(detail.tmdb_id, provider === 'tmdb' ? 12 : null);
if (provider === 'anilist') {
assert.equal((await tracker.getAnimeDetail(1))?.totalActiveMs, 1000);
}
assert.deepEqual(
new Uint8Array((await tracker.getAnimeCoverArt(1))!.coverBlob!),
new Uint8Array([9, 8, 7]),
);
await reassign(null);
assert.equal(await tracker.getAnimeCoverArt(1), null);
assert.equal(readMetadata().media_kind, detail.media_kind);
} finally {
globalThis.fetch = originalFetch;
tracker?.destroy();
cleanupDbPath(dbPath);
}
});
}
+137 -50
View File
@@ -33,6 +33,7 @@ import {
applySessionLifetimeSummary,
reconcileStaleActiveSessions,
rebuildLifetimeSummaries as rebuildLifetimeSummaryTables,
rebuildLifetimeSummariesInTransaction,
recomputeLifetimeAnimeFromMedia,
recomputeLifetimeGlobalFromSummaries,
repairLifetimeSummariesFromMedia,
@@ -90,6 +91,7 @@ import {
} from './immersion-tracker/query-library';
import {
cleanupVocabularyStats,
clearAnimeCoverArt,
getVideoDurationMs,
markVideoWatched,
upsertCoverArt,
@@ -115,7 +117,7 @@ import {
dismissAnimeMergeRecommendation,
getAnimeMergeRecommendations,
repairLegacySeasonlessAnimeRows,
resolveAnimeAnilistConflict,
resolveAnimeAnilistConflictInTransaction,
type AnimeMergeRecommendation,
} from './immersion-tracker/anime-season-repair';
import {
@@ -124,6 +126,11 @@ import {
type AnimeMergeSummary,
type VideoMoveSummary,
} from './immersion-tracker/anime-merge';
import {
linkAnimeToTmdbTitleInTransaction,
type LiveActionLinkResult,
type LiveActionTitleInput,
} from './immersion-tracker/live-action-link';
import {
buildVideoKey,
deriveCanonicalTitle,
@@ -818,6 +825,10 @@ export class ImmersionTrackerService {
return getAnimeDetail(this.db, animeId);
}
async hasAnime(animeId: number): Promise<boolean> {
return Boolean(this.db.prepare('SELECT 1 FROM imm_anime WHERE anime_id = ?').get(animeId));
}
async getAnimeEpisodes(animeId: number): Promise<AnimeEpisodeRow[]> {
return getAnimeEpisodes(this.db, animeId);
}
@@ -1016,19 +1027,31 @@ export class ImmersionTrackerService {
coverUrl?: string | null;
},
): Promise<void> {
const coverBlob = await this.downloadReplacementCover(info.coverUrl);
this.requireWriteQueueDrained('reassigning an AniList entry');
// The user is acting on this entry, so it is the one that survives when
// another row already claims the same AniList id.
const repair = resolveAnimeAnilistConflict(this.db, animeId, info.anilistId, {
survivor: 'target',
matchConfidence: 'manual',
});
if (repair.anilistAssignmentBlocked) return;
this.db
.prepare(
`
this.db.exec('BEGIN IMMEDIATE');
try {
this.db
.prepare('UPDATE imm_anime SET tmdb_id = NULL, tmdb_type = NULL WHERE anime_id = ?')
.run(animeId);
// The user is acting on this entry, so it is the one that survives when
// another row already claims the same AniList id.
const repair = resolveAnimeAnilistConflictInTransaction(this.db, animeId, info.anilistId, {
survivor: 'target',
matchConfidence: 'manual',
});
if (repair.anilistAssignmentBlocked) {
this.db.exec('ROLLBACK');
return;
}
this.db
.prepare(
`
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),
@@ -1037,46 +1060,32 @@ export class ImmersionTrackerService {
LAST_UPDATE_DATE = ?
WHERE anime_id = ?
`,
)
.run(
info.anilistId,
info.titleRomaji ?? null,
info.titleEnglish ?? null,
info.titleNative ?? null,
info.episodesTotal ?? null,
info.description !== undefined ? 1 : 0,
info.description ?? null,
nowMs(),
animeId,
);
// Empty lifetime tables still need the retained-session bootstrap. Once a
// media ledger exists, only the redistributed and explicitly edited anime
// can have changed.
if (shouldBackfillLifetimeSummaries(this.db)) {
repairLifetimeSummariesFromMedia(this.db);
} else {
const affectedAnimeIds = new Set(repair.affectedAnimeIds);
affectedAnimeIds.add(animeId);
recomputeLifetimeAnimeFromMedia(this.db, [...affectedAnimeIds]);
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 */
)
.run(
info.anilistId,
info.titleRomaji ?? null,
info.titleEnglish ?? null,
info.titleNative ?? null,
info.episodesTotal ?? null,
info.description !== undefined ? 1 : 0,
info.description ?? null,
nowMs(),
animeId,
);
// Empty lifetime tables still need the retained-session bootstrap. Once a
// media ledger exists, only the redistributed and explicitly edited anime
// can have changed.
if (shouldBackfillLifetimeSummaries(this.db)) {
rebuildLifetimeSummariesInTransaction(this.db);
} else {
const affectedAnimeIds = new Set(repair.affectedAnimeIds);
affectedAnimeIds.add(animeId);
recomputeLifetimeAnimeFromMedia(this.db, [...affectedAnimeIds]);
recomputeLifetimeGlobalFromSummaries(this.db);
}
for (const v of videos) {
upsertCoverArt(this.db, v.video_id, {
if (info.coverUrl) {
this.applyCoverArtToAnimeVideos(animeId, {
anilistId: info.anilistId,
coverUrl: info.coverUrl,
coverBlob,
@@ -1084,7 +1093,85 @@ export class ImmersionTrackerService {
titleEnglish: info.titleEnglish ?? null,
episodesTotal: info.episodesTotal ?? null,
});
} else {
clearAnimeCoverArt(this.db, animeId);
}
this.db.exec('COMMIT');
} catch (error) {
this.db.exec('ROLLBACK');
throw error;
}
}
/**
* 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> {
const coverBlob = await this.downloadReplacementCover(details.posterUrl);
this.requireWriteQueueDrained('linking a TMDB title');
this.db.exec('BEGIN IMMEDIATE');
try {
const result = linkAnimeToTmdbTitleInTransaction(this.db, animeId, details, {
mode: 'manual',
});
if (details.posterUrl) {
this.applyCoverArtToAnimeVideos(result.animeId, {
anilistId: null,
coverUrl: details.posterUrl,
coverBlob,
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);
}
this.db.exec('COMMIT');
return result;
} catch (error) {
this.db.exec('ROLLBACK');
throw error;
}
}
private async downloadReplacementCover(url: string | null | undefined): Promise<Buffer | null> {
if (!url) return null;
const response = await fetch(url);
if (!response.ok) throw new Error(`Cover download failed: ${response.status}`);
return Buffer.from(await response.arrayBuffer());
}
/** Stores the downloaded replacement against every episode of the entry. */
private applyCoverArtToAnimeVideos(
animeId: number,
art: {
anilistId: number | null;
coverUrl: string;
coverBlob: Buffer | null;
titleRomaji: string | null;
titleEnglish: string | null;
episodesTotal: number | null;
},
): void {
const videos = this.db
.prepare('SELECT video_id FROM imm_videos WHERE anime_id = ?')
.all(animeId) as Array<{ video_id: number }>;
for (const v of videos) {
upsertCoverArt(this.db, v.video_id, {
anilistId: art.anilistId,
coverUrl: art.coverUrl,
coverBlob: art.coverBlob,
titleRomaji: art.titleRomaji,
titleEnglish: art.titleEnglish,
episodesTotal: art.episodesTotal,
});
}
}
@@ -0,0 +1,309 @@
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 { mergeAnimeRecords, mergeAnimeRecordsInTransaction } from '../anime-merge.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);
});
});
for (const targetId of [1, 2, 3]) {
test(`merge rejects mixed providers before moving any source into entry ${targetId}`, () => {
withDb((db) => {
insertAnime(db, 1, 'Anime', 77);
insertAnime(db, 2, 'Drama');
insertAnime(db, 3, 'Unlinked');
insertEpisode(db, 1, 1, 1);
insertEpisode(db, 2, 2, 1);
db.exec(
"UPDATE imm_anime SET media_kind = 'live_action', tmdb_id = 12, tmdb_type = 'tv' WHERE anime_id = 2",
);
for (const merge of [mergeAnimeRecords, mergeAnimeRecordsInTransaction]) {
assert.throws(
() => merge(db, targetId, [3, 1, 2]),
/AniList-linked and TMDB-linked library entries cannot be merged/,
);
assert.equal(animeCount(db), 3);
assert.equal(videoOwner(db, 1), 1);
assert.equal(videoOwner(db, 2), 2);
}
});
});
}
for (const mode of ['manual', 'auto'] as const) {
test(`TMDB ${mode} linking rolls back the merge when the survivor update fails`, () => {
withDb((db) => {
insertAnime(db, 1, 'New entry');
insertAnime(db, 2, 'Existing entry');
insertEpisode(db, 1, 1, 1);
insertEpisode(db, 2, 2, 2);
db.prepare(
"UPDATE imm_anime SET tmdb_id = ?, tmdb_type = 'tv', media_kind = 'live_action' WHERE anime_id = 2",
).run(HANZAWA.tmdbId);
db.exec(`CREATE TRIGGER reject_link BEFORE UPDATE ON imm_anime
WHEN NEW.description = 'A banker fights back.'
BEGIN SELECT RAISE(ABORT, 'rejected survivor update'); END`);
assert.throws(
() => linkAnimeToTmdbTitle(db, 1, HANZAWA, { mode }),
/rejected survivor update/,
);
assert.equal(animeCount(db), 2);
assert.equal(videoOwner(db, 1), 1);
assert.equal(videoOwner(db, 2), 2);
assert.equal(animeRow(db, 1)?.tmdbId, null);
assert.equal(animeRow(db, 2)?.tmdbId, HANZAWA.tmdbId);
});
});
}
for (const mode of ['manual', 'auto'] as const) {
test(`${mode} TMDB linking refreshes completion totals without merging records`, () => {
withDb((db) => {
insertAnime(db, 1, 'Hanzawa Naoki');
insertEpisode(db, 1, 1, 1);
const completed = () =>
(
db
.prepare('SELECT anime_completed AS count FROM imm_lifetime_global WHERE global_id = 1')
.get() as { count: number }
).count;
assert.equal(completed(), 0);
const result = linkAnimeToTmdbTitle(db, 1, { ...HANZAWA, episodesTotal: 1 }, { mode });
assert.deepEqual(result.mergedAnimeIds, []);
assert.equal(completed(), 1);
linkAnimeToTmdbTitle(db, 1, { ...HANZAWA, episodesTotal: 2 }, { mode: 'manual' });
assert.equal(completed(), 0);
assert.equal(animeCount(db), 1);
});
});
}
@@ -1,4 +1,4 @@
import type { MediaKind } from '../../../shared/media-kind';
import { shareTitleNamespace, type MediaKind } from '../../../shared/media-kind';
import type { DatabaseSync } from './sqlite';
import { recomputeLifetimeAnimeAggregatesInTransaction } from './lifetime';
import { toDbTimestamp } from './query-shared';
@@ -6,8 +6,12 @@ import { nowMs } from './time';
/** Thrown when a move names an episode or destination entry that is not there. */
export const UNKNOWN_MOVE_TARGET_MESSAGE = 'Unknown episode or target library entry';
/** Thrown when a merge or move would mix an anime entry with a YouTube channel. */
export const MEDIA_KIND_MISMATCH_MESSAGE = 'Anime and YouTube channel entries cannot be combined';
/** Thrown when a merge would combine an AniList-linked entry with a TMDB-linked one. */
export const INCOMPATIBLE_PROVIDER_MERGE_MESSAGE =
'AniList-linked and TMDB-linked library entries cannot be merged together';
/** Thrown when a merge or move would mix a YouTube channel with an anime or live-action entry. */
export const MEDIA_KIND_MISMATCH_MESSAGE =
'YouTube channels cannot be combined with anime or live-action entries';
export interface AnimeMergeSummary {
/** Library entry that owns every moved episode once the merge finishes. */
@@ -33,6 +37,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 {
@@ -55,7 +62,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 = ?
`,
@@ -137,6 +145,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 = ?
`,
@@ -147,6 +161,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,
);
@@ -172,6 +190,18 @@ export function mergeAnimeRecordsInTransaction(
return summary;
}
// Validate the whole group before moving anything, including when the
// unlinked target would inherit conflicting providers from two sources.
const metadata = [targetAnimeId, ...new Set(sourceAnimeIds)].map((id) =>
readAnimeMetadata(db, id),
);
if (
metadata.some((row) => row?.anilist_id != null) &&
metadata.some((row) => row?.tmdb_id != null)
) {
throw new Error(INCOMPATIBLE_PROVIDER_MERGE_MESSAGE);
}
const updatedAt = toDbTimestamp(nowMs());
const sourceVideosStmt = db.prepare(
'SELECT video_id AS videoId FROM imm_videos WHERE anime_id = ?',
@@ -206,8 +236,8 @@ export function mergeAnimeRecordsInTransaction(
const sourceKind = readMediaKind(db, sourceAnimeId);
if (sourceKind === null) continue;
// A channel folded into an anime would only be recreated on the next
// watch, because title lookups never cross kinds; refuse instead.
if (sourceKind !== targetKind) {
// watch, because title lookups never cross namespaces; refuse instead.
if (!shareTitleNamespace(sourceKind, targetKind)) {
throw new Error(MEDIA_KIND_MISMATCH_MESSAGE);
}
@@ -275,7 +305,8 @@ export function moveVideoToAnime(
}
const previousAnimeId = videoRow.animeId;
if (previousAnimeId !== null && readMediaKind(db, previousAnimeId) !== targetKind) {
const previousKind = previousAnimeId === null ? null : readMediaKind(db, previousAnimeId);
if (previousKind !== null && !shareTitleNamespace(previousKind, targetKind)) {
throw new Error(MEDIA_KIND_MISMATCH_MESSAGE);
}
if (previousAnimeId === targetAnimeId) {
@@ -134,7 +134,7 @@ function getAnimeRow(db: DatabaseSync, animeId: number): AnimeRow | null {
episodes_total,
description
FROM imm_anime
WHERE anime_id = ? AND media_kind = 'anime'
WHERE anime_id = ? AND media_kind != 'youtube'
`,
)
.get(animeId) as AnimeRow | null;
@@ -372,6 +372,18 @@ export function resolveAnimeAnilistConflict(
targetAnimeId: number,
anilistId: number,
options: AnimeAnilistConflictOptions = {},
): AnimeSeasonRepairSummary {
return runInTransaction(db, () =>
resolveAnimeAnilistConflictInTransaction(db, targetAnimeId, anilistId, options),
);
}
/** Caller owns the write transaction. */
export function resolveAnimeAnilistConflictInTransaction(
db: DatabaseSync,
targetAnimeId: number,
anilistId: number,
options: AnimeAnilistConflictOptions = {},
): AnimeSeasonRepairSummary {
if (!getAnimeRow(db, targetAnimeId)) {
const summary = emptySummary();
@@ -392,90 +404,88 @@ export function resolveAnimeAnilistConflict(
if (!conflict) {
return emptySummary();
}
if (!getAnimeRow(db, conflict.animeId)) {
const summary = emptySummary();
summary.anilistAssignmentBlocked = true;
return summary;
}
return runInTransaction(db, () => {
const targetRow = getAnimeRow(db, targetAnimeId);
if (
options.survivor !== 'target' &&
targetRow?.anilist_id != null &&
targetRow.anilist_id !== anilistId
) {
// An automatic lookup disagreeing with an existing explicit link is a
// mis-resolution, not evidence that either row should move or merge. The
// colliding id must not be assigned either: another row owns it and
// imm_anime.anilist_id is UNIQUE.
const summary = emptySummary(1);
summary.anilistAssignmentBlocked = true;
return summary;
}
const isManual = options.survivor === 'target' || options.matchConfidence === 'manual';
if (!isManual && hasDismissedAnimeMergeRecommendation(db, targetAnimeId, conflict.animeId)) {
const summary = emptySummary(1);
summary.anilistAssignmentBlocked = true;
return summary;
}
const targetSeasons = getParsedSeasonsForAnime(db, targetAnimeId);
const conflictSeasons = getParsedSeasonsForAnime(db, conflict.animeId);
if (
!isManual &&
targetSeasons.size === 1 &&
conflictSeasons.size === 1 &&
[...targetSeasons][0] !== [...conflictSeasons][0]
) {
const summary = emptySummary(1);
summary.anilistAssignmentBlocked = true;
return summary;
}
if (canMergeAnilistConflict(db, targetAnimeId, conflict.animeId, anilistId, options)) {
const survivingAnimeId = options.survivor === 'target' ? targetAnimeId : conflict.animeId;
const absorbedAnimeId = survivingAnimeId === targetAnimeId ? conflict.animeId : targetAnimeId;
const merge = mergeAnimeRecordsInTransaction(db, survivingAnimeId, [absorbedAnimeId]);
const summary = emptySummary(1);
summary.movedVideos = merge.movedVideos;
summary.deletedAnimeRows = merge.mergedAnimeIds.length;
if (merge.mergedAnimeIds.length > 0) {
summary.repaired = 1;
// Only reported once a row really absorbed the other, so callers never
// follow this to an anime id that was never written.
summary.survivingAnimeId = survivingAnimeId;
summary.affectedAnimeIds.push(survivingAnimeId, absorbedAnimeId);
}
// Lifetime summaries are rebuilt by the caller off this summary, the same
// as the redistribution path below.
return summary;
}
if (shouldRecommendAnilistConflict(db, targetAnimeId, conflict.animeId, options)) {
recordAnimeMergeRecommendation(db, targetAnimeId, conflict.animeId, anilistId);
const summary = emptySummary(1);
summary.mergeRecommended = true;
return summary;
const targetRow = getAnimeRow(db, targetAnimeId);
if (
options.survivor !== 'target' &&
targetRow?.anilist_id != null &&
targetRow.anilist_id !== anilistId
) {
// An automatic lookup disagreeing with an existing explicit link is a
// mis-resolution, not evidence that either row should move or merge. The
// colliding id must not be assigned either: another row owns it and
// imm_anime.anilist_id is UNIQUE.
const summary = emptySummary(1);
summary.anilistAssignmentBlocked = true;
return summary;
}
const isManual = options.survivor === 'target' || options.matchConfidence === 'manual';
if (!isManual && hasDismissedAnimeMergeRecommendation(db, targetAnimeId, conflict.animeId)) {
const summary = emptySummary(1);
summary.anilistAssignmentBlocked = true;
return summary;
}
const targetSeasons = getParsedSeasonsForAnime(db, targetAnimeId);
const conflictSeasons = getParsedSeasonsForAnime(db, conflict.animeId);
if (
!isManual &&
targetSeasons.size === 1 &&
conflictSeasons.size === 1 &&
[...targetSeasons][0] !== [...conflictSeasons][0]
) {
const summary = emptySummary(1);
summary.anilistAssignmentBlocked = true;
return summary;
}
if (canMergeAnilistConflict(db, targetAnimeId, conflict.animeId, anilistId, options)) {
const survivingAnimeId = options.survivor === 'target' ? targetAnimeId : conflict.animeId;
const absorbedAnimeId = survivingAnimeId === targetAnimeId ? conflict.animeId : targetAnimeId;
const merge = mergeAnimeRecordsInTransaction(db, survivingAnimeId, [absorbedAnimeId]);
const summary = emptySummary(1);
summary.movedVideos = merge.movedVideos;
summary.deletedAnimeRows = merge.mergedAnimeIds.length;
if (merge.mergedAnimeIds.length > 0) {
summary.repaired = 1;
// Only reported once a row really absorbed the other, so callers never
// follow this to an anime id that was never written.
summary.survivingAnimeId = survivingAnimeId;
summary.affectedAnimeIds.push(survivingAnimeId, absorbedAnimeId);
}
// Lifetime summaries are rebuilt by the caller off this summary, the same
// as the redistribution path below.
return summary;
}
const isExactAutomaticMatch =
options.matchConfidence === 'exact' ||
(options.matchConfidence === undefined &&
hasExactStoredTitleMatch(db, targetAnimeId, conflict.animeId));
if (!isManual && !isExactAutomaticMatch) {
// Redistribution dismantles the id's current owner and hands the id to
// the target. On a weak automatic match that owner is usually the
// correctly linked card (e.g. a legitimate multi-season entry), so
// splitting it here is exactly the fuzzy false merge this gate exists to
// stop. Only exact or manual evidence may fall through.
const summary = emptySummary(1);
summary.anilistAssignmentBlocked = true;
return summary;
}
if (shouldRecommendAnilistConflict(db, targetAnimeId, conflict.animeId, options)) {
recordAnimeMergeRecommendation(db, targetAnimeId, conflict.animeId, anilistId);
const summary = emptySummary(1);
summary.mergeRecommended = true;
return summary;
}
return redistributeAnimeRowByParsedSeasonsInTransaction(db, conflict.animeId, {
transferAnilistToAnimeId: targetAnimeId,
overwriteTargetAnilist: true,
});
const isExactAutomaticMatch =
options.matchConfidence === 'exact' ||
(options.matchConfidence === undefined &&
hasExactStoredTitleMatch(db, targetAnimeId, conflict.animeId));
if (!isManual && !isExactAutomaticMatch) {
// Redistribution dismantles the id's current owner and hands the id to
// the target. On a weak automatic match that owner is usually the
// correctly linked card (e.g. a legitimate multi-season entry), so
// splitting it here is exactly the fuzzy false merge this gate exists to
// stop. Only exact or manual evidence may fall through.
const summary = emptySummary(1);
summary.anilistAssignmentBlocked = true;
return summary;
}
return redistributeAnimeRowByParsedSeasonsInTransaction(db, conflict.animeId, {
transferAnilistToAnimeId: targetAnimeId,
overwriteTargetAnilist: true,
});
}
@@ -0,0 +1,179 @@
import type { DatabaseSync } from './sqlite';
import type { TmdbMediaType } from '../../../shared/media-kind';
import { mergeAnimeRecordsInTransaction } from './anime-merge';
import { recomputeLifetimeAnimeAggregatesInTransaction } from './lifetime';
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 {
db.exec('BEGIN IMMEDIATE');
try {
const result = linkAnimeToTmdbTitleInTransaction(db, animeId, input, options);
db.exec('COMMIT');
return result;
} catch (error) {
db.exec('ROLLBACK');
throw error;
}
}
/** Caller owns the write transaction, including any artwork replacement. */
export function linkAnimeToTmdbTitleInTransaction(
db: DatabaseSync,
animeId: number,
input: LiveActionTitleInput,
options: LiveActionLinkOptions,
): LiveActionLinkResult {
const target = db.prepare('SELECT anilist_id FROM imm_anime WHERE anime_id = ?').get(animeId) as
| { anilist_id: number | null }
| undefined;
if (!target) throw new Error('Unknown library entry');
if (target.anilist_id !== null) {
if (options.mode === 'auto')
throw new Error('Cannot automatically replace an AniList identity');
// An explicit reassignment changes providers before compatible rows merge.
db.prepare('UPDATE imm_anime SET anilist_id = NULL WHERE anime_id = ?').run(animeId);
}
const others = findOtherTmdbHolders(db, animeId, input);
let survivor = animeId;
let mergedAnimeIds: number[] = [];
if (others.length > 0) {
if (options.mode === 'manual') {
mergedAnimeIds = mergeAnimeRecordsInTransaction(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 = mergeAnimeRecordsInTransaction(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,
);
}
recomputeLifetimeAnimeAggregatesInTransaction(db);
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;
}
@@ -35,6 +35,9 @@ export function getAnimeLibrary(db: DatabaseSync): AnimeLibraryRow[] {
a.canonical_title AS canonicalTitle,
a.media_kind AS mediaKind,
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,
@@ -66,6 +69,9 @@ export function getAnimeDetail(db: DatabaseSync, animeId: number): AnimeDetailRo
a.canonical_title AS canonicalTitle,
a.media_kind AS mediaKind,
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,
+44 -21
View File
@@ -1,4 +1,4 @@
import type { MediaKind } from '../../../shared/media-kind';
import { sameTitleNamespaceSql, type MediaKind } from '../../../shared/media-kind';
import { createHash } from 'node:crypto';
import path from 'node:path';
import { parseMediaInfo } from '../../../jimaku/utils';
@@ -591,14 +591,19 @@ export function getOrCreateAnimeRecord(db: DatabaseSync, input: AnimeRecordInput
anime_id: number;
} | null)
: null;
// Title lookups stay inside the kind's namespace: a parsed filename may land
// on a TMDB-linked live-action row, but never on a YouTube channel.
const byNormalizedTitle = db
.prepare('SELECT anime_id FROM imm_anime WHERE normalized_title_key = ? AND media_kind = ?')
.prepare(
`SELECT anime_id FROM imm_anime
WHERE normalized_title_key = ? AND ${sameTitleNamespaceSql()}`,
)
.get(normalizedTitleKey, mediaKind) as { anime_id: number } | null;
const byTitleAlias = db
.prepare(
`SELECT a.anime_id FROM imm_anime_title_aliases AS alias
JOIN imm_anime AS a ON a.anime_id = alias.anime_id
WHERE alias.normalized_title_key = ? AND a.media_kind = ?`,
WHERE alias.normalized_title_key = ? AND ${sameTitleNamespaceSql('a.media_kind')}`,
)
.get(normalizedTitleKey, mediaKind) as { anime_id: number } | null;
const existing = byAnilistId ?? byNormalizedTitle ?? byTitleAlias;
@@ -611,7 +616,11 @@ export function getOrCreateAnimeRecord(db: DatabaseSync, input: AnimeRecordInput
UPDATE imm_anime
SET
canonical_title = COALESCE(NULLIF(?, ''), canonical_title),
anilist_id = CASE WHEN ? = 'youtube' THEN NULL ELSE COALESCE(?, anilist_id) END,
anilist_id = CASE
WHEN ? = 'youtube' THEN NULL
WHEN tmdb_id IS NOT NULL THEN anilist_id
ELSE COALESCE(?, anilist_id)
END,
title_romaji = COALESCE(?, title_romaji),
title_english = COALESCE(?, title_english),
title_native = COALESCE(?, title_native),
@@ -889,13 +898,19 @@ function migrateLegacyAnimeMetadata(db: DatabaseSync): void {
}
}
// SQLite cannot drop a table-level UNIQUE constraint. Rebuild with IDs intact
// and foreign keys disabled so dependent history and manual assignments survive.
function migrateAnimeTitleUniqueness(db: DatabaseSync): void {
// SQLite cannot drop a table-level UNIQUE constraint or a column CHECK.
// Rebuild with IDs intact and foreign keys disabled so dependent history and
// manual assignments survive. Two shapes need it: the original
// `normalized_title_key UNIQUE`, and the v0.19.6 `media_kind` column whose
// CHECK only allowed 'anime' and 'youtube'.
const LEGACY_TITLE_UNIQUE_RE = /normalized_title_key TEXT NOT NULL UNIQUE/i;
const LEGACY_MEDIA_KIND_CHECK_RE = /\s*CHECK\s*\(\s*media_kind IN \('anime',\s*'youtube'\)\s*\)/i;
function migrateAnimeTableConstraints(db: DatabaseSync): void {
const schema = db.prepare("SELECT sql FROM sqlite_master WHERE name = 'imm_anime'").get() as {
sql: string;
};
if (/normalized_title_key TEXT NOT NULL UNIQUE/i.test(schema.sql)) {
if (LEGACY_TITLE_UNIQUE_RE.test(schema.sql) || LEGACY_MEDIA_KIND_CHECK_RE.test(schema.sql)) {
const foreignKeys = db.prepare('PRAGMA foreign_keys').get() as { foreign_keys: number };
const sequence = db
.prepare("SELECT seq FROM sqlite_sequence WHERE name = 'imm_anime'")
@@ -909,10 +924,8 @@ function migrateAnimeTitleUniqueness(db: DatabaseSync): void {
/CREATE TABLE (?:IF NOT EXISTS )?["`]?imm_anime["`]?/i,
'CREATE TABLE imm_anime_new',
)
.replace(
/normalized_title_key TEXT NOT NULL UNIQUE/i,
'normalized_title_key TEXT NOT NULL',
),
.replace(LEGACY_TITLE_UNIQUE_RE, 'normalized_title_key TEXT NOT NULL')
.replace(LEGACY_MEDIA_KIND_CHECK_RE, ''),
);
db.exec(`INSERT INTO imm_anime_new SELECT * FROM imm_anime;
DROP TABLE imm_anime;
@@ -930,8 +943,11 @@ function migrateAnimeTitleUniqueness(db: DatabaseSync): void {
db.exec(`PRAGMA foreign_keys = ${foreignKeys.foreign_keys}`);
}
}
db.exec(`CREATE UNIQUE INDEX IF NOT EXISTS idx_anime_kind_title
ON imm_anime(media_kind, normalized_title_key)`);
// v0.19.6 scoped titles per kind; anime and live-action now share one
// namespace (an entry moves between them when relinked), YouTube is separate.
db.exec(`DROP INDEX IF EXISTS idx_anime_kind_title;
CREATE UNIQUE INDEX IF NOT EXISTS idx_anime_namespace_title
ON imm_anime((media_kind = 'youtube'), normalized_title_key)`);
}
// Older builds can create channel rows with the default anime kind even after
@@ -995,17 +1011,20 @@ 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
);
`);
addColumnIfMissing(
db,
'imm_anime',
'media_kind',
"TEXT NOT NULL DEFAULT 'anime' CHECK(media_kind IN ('anime', 'youtube'))",
);
// Schema 26: media_kind separates anime, live-action (TMDB link) and YouTube
// channel entries. Kinds are validated in code, not by a CHECK constraint,
// so adding one later does not need a table rebuild.
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,
@@ -1549,7 +1568,7 @@ export function ensureSchema(db: DatabaseSync): void {
);
}
migrateAnimeTitleUniqueness(db);
migrateAnimeTableConstraints(db);
classifyYoutubeChannels(db);
migrateSessionEventTimestampsToText(db);
@@ -1565,6 +1584,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)
+7 -2
View File
@@ -1,6 +1,7 @@
import type { MediaKind } from '../../../shared/media-kind';
import type { MediaKind, TmdbMediaType } from '../../../shared/media-kind';
export const SCHEMA_VERSION = 25;
// 26: live-action entries (TMDB link) and YouTube channels share the media_kind column.
export const SCHEMA_VERSION = 26;
export const DEFAULT_QUEUE_CAP = 1_000;
export const DEFAULT_BATCH_SIZE = 25;
export const DEFAULT_FLUSH_INTERVAL_MS = 500;
@@ -524,6 +525,8 @@ export interface AnimeLibraryRow {
animeId: number;
canonicalTitle: string;
anilistId: number | null;
tmdbId: number | null;
tmdbType: TmdbMediaType | null;
totalSessions: number;
totalActiveMs: number;
totalCards: number;
@@ -538,6 +541,8 @@ export interface AnimeDetailRow {
animeId: number;
canonicalTitle: string;
anilistId: number | null;
tmdbId: number | null;
tmdbType: TmdbMediaType | null;
titleRomaji: string | null;
titleEnglish: string | null;
titleNative: string | null;
@@ -81,7 +81,7 @@ test('schema 23 channel migration preserves history and manual assignments and i
const history = getAnimeLibrary(db);
// Reproduce the previous schema, including its lack of a media kind column.
db.exec(
'DROP INDEX idx_anime_kind_title; ALTER TABLE imm_anime DROP COLUMN media_kind; DELETE FROM imm_schema_version; INSERT INTO imm_schema_version VALUES (23, 0)',
'DROP INDEX idx_anime_namespace_title; ALTER TABLE imm_anime DROP COLUMN media_kind; DELETE FROM imm_schema_version; INSERT INTO imm_schema_version VALUES (23, 0)',
);
ensureSchema(db);
ensureSchema(db);
@@ -240,6 +240,52 @@ test('title identity and aliases never cross media kinds', () => {
}
});
test('anime title lookups land on a same-named live-action entry but never on a channel', () => {
const db = new Database(':memory:');
try {
ensureSchema(db);
const dramaId = createAnime(db, 'Hanzawa Naoki');
db.prepare(
"UPDATE imm_anime SET media_kind = 'live_action', tmdb_id = 61222, tmdb_type = 'tv' WHERE anime_id = ?",
).run(dramaId);
// A later season folder parses to the same title with the default anime
// kind and must join the TMDB-linked entry rather than duplicate it.
assert.equal(
getOrCreateAnimeRecord(db, {
parsedTitle: 'Hanzawa Naoki',
canonicalTitle: 'Hanzawa Naoki',
anilistId: 99,
titleRomaji: null,
titleEnglish: null,
titleNative: null,
metadataJson: null,
}),
dramaId,
);
const row = db
.prepare('SELECT media_kind, anilist_id, tmdb_id FROM imm_anime WHERE anime_id = ?')
.get(dramaId) as { media_kind: string; anilist_id: number | null; tmdb_id: number };
assert.equal(row.media_kind, 'live_action');
assert.equal(row.anilist_id, null);
assert.equal(row.tmdb_id, 61222);
assert.notEqual(
getOrCreateAnimeRecord(db, {
mediaKind: 'youtube',
parsedTitle: 'Hanzawa Naoki',
canonicalTitle: 'Hanzawa Naoki',
anilistId: null,
titleRomaji: null,
titleEnglish: null,
titleNative: null,
metadataJson: null,
}),
dramaId,
);
} finally {
db.close();
}
});
test('schema 24 title constraint migration preserves referenced data', () => {
const db = new Database(':memory:');
try {
@@ -273,7 +319,9 @@ test('schema 24 title constraint migration preserves referenced data', () => {
title_romaji TEXT, title_english TEXT, title_native TEXT, episodes_total INTEGER,
description TEXT, metadata_json TEXT, CREATED_DATE TEXT, LAST_UPDATE_DATE TEXT,
media_kind TEXT NOT NULL DEFAULT 'anime' CHECK(media_kind IN ('anime', 'youtube')));
INSERT INTO imm_anime SELECT * FROM old_anime;
INSERT INTO imm_anime SELECT anime_id, normalized_title_key, canonical_title, anilist_id,
title_romaji, title_english, title_native, episodes_total, description, metadata_json,
CREATED_DATE, LAST_UPDATE_DATE, media_kind FROM old_anime;
DROP TABLE old_anime;
DELETE FROM imm_schema_version;
INSERT INTO imm_schema_version VALUES (24, 0);
@@ -281,6 +329,10 @@ test('schema 24 title constraint migration preserves referenced data', () => {
ensureSchema(db);
ensureSchema(db);
assert.deepEqual(db.prepare('PRAGMA foreign_key_check').all(), []);
// The v0.19.6 CHECK only allowed anime and youtube; live-action must fit now.
db.prepare(
"INSERT INTO imm_anime(normalized_title_key, canonical_title, media_kind, tmdb_id, tmdb_type) VALUES ('drama', 'Drama', 'live_action', 1, 'tv')",
).run();
assert.equal(
(db.prepare('PRAGMA foreign_keys').get() as { foreign_keys: number }).foreign_keys,
1,
+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';
@@ -129,6 +130,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[];
@@ -151,6 +153,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[];
@@ -186,6 +189,7 @@ export async function startStatsServerWithRuntime(
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 {
@@ -29,6 +31,7 @@ export function registerStatsIntegrationRoutes(
ankiConnectConfig?: AnkiConnectConfig;
getAnkiConnectConfig?: () => AnkiConnectConfig | undefined;
anilistRateLimiter?: AnilistRateLimiter;
tmdbClient?: TmdbClient;
resolveAnkiNoteId?: (noteId: number) => number;
},
): void {
@@ -73,6 +76,51 @@ 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 = parsePositiveId(c.req.param('animeId'));
if (animeId === null) 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);
}
if (!(await tracker.hasAnime(animeId))) return c.body(null, 404);
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', []));
@@ -1,6 +1,7 @@
import type { Hono } from 'hono';
import { statsJson } from '../../../types/stats-http-contract.js';
import {
INCOMPATIBLE_PROVIDER_MERGE_MESSAGE,
MEDIA_KIND_MISMATCH_MESSAGE,
UNKNOWN_MOVE_TARGET_MESSAGE,
} from '../immersion-tracker/anime-merge.js';
@@ -252,8 +253,14 @@ export function registerStatsLibraryRoutes(
try {
summary = await tracker.mergeAnime(animeId, sourceAnimeIds);
} catch (error) {
if (error instanceof Error && error.message === MEDIA_KIND_MISMATCH_MESSAGE) {
return c.text(MEDIA_KIND_MISMATCH_MESSAGE, 409);
// Mixing providers or kinds is a rejected request, not a server fault,
// so the dashboard can explain it instead of showing a bare 500.
if (
error instanceof Error &&
(error.message === INCOMPATIBLE_PROVIDER_MERGE_MESSAGE ||
error.message === MEDIA_KIND_MISMATCH_MESSAGE)
) {
return c.json(statsJson('error', { error: error.message }), 409);
}
throw error;
}
@@ -0,0 +1,60 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { Database, type DatabaseSync } from '../immersion-tracker/sqlite';
import { ensureSchema } from '../immersion-tracker/storage';
import { mergeAnime } from './merge-catalog';
import { createEmptyMergeSummary } from './shared';
const identities = {
unlinked: [null, null, null],
anilist: [42, null, null],
tmdb: [null, 12, 'tv'],
otherTmdb: [null, 13, 'tv'],
} as const;
for (const [localKind, remoteKind, sameEntry] of [
['anilist', 'tmdb', false],
['tmdb', 'anilist', false],
['tmdb', 'otherTmdb', false],
['unlinked', 'tmdb', true],
['unlinked', 'anilist', true],
['tmdb', 'unlinked', true],
['tmdb', 'tmdb', true],
['anilist', 'anilist', true],
] as const) {
test(`catalog title match: ${localKind} with ${remoteKind}`, () => {
const local = new Database(':memory:');
const remote = new Database(':memory:');
const adapt = (db: DatabaseSync) => ({
query: (sql: string) => db.prepare(sql),
exec: (sql: string) => {
db.exec(sql);
},
close: () => {
db.close();
},
});
try {
for (const [db, kind] of [
[local, localKind],
[remote, remoteKind],
] as const) {
ensureSchema(db);
db.prepare(
`INSERT INTO imm_anime(normalized_title_key, canonical_title, anilist_id, tmdb_id, tmdb_type, CREATED_DATE, LAST_UPDATE_DATE)
VALUES ('same title', 'Same title', ?, ?, ?, 1000, 1000)`,
).run(...identities[kind]);
}
const summary = createEmptyMergeSummary();
const map = mergeAnime(adapt(local), adapt(remote), summary);
assert.equal(map.get(1) === 1, sameEntry);
assert.equal(summary.animeAdded, sameEntry ? 0 : 1);
const again = createEmptyMergeSummary();
assert.equal(mergeAnime(adapt(local), adapt(remote), again).get(1), map.get(1));
assert.equal(again.animeAdded, 0);
} finally {
local.close();
remote.close();
}
});
}
+55 -10
View File
@@ -1,5 +1,6 @@
import { selectAll, selectOne, type SqlRow, type SyncDb } from './libsql-driver';
import { insertRow, tableExists, type SyncMergeSummary } from './shared';
import { sameTitleNamespaceSql } from '../../../shared/media-kind';
const ANIME_COPY_COLUMNS = [
'media_kind',
@@ -11,6 +12,8 @@ const ANIME_COPY_COLUMNS = [
'title_native',
'episodes_total',
'description',
'tmdb_id',
'tmdb_type',
'metadata_json',
'CREATED_DATE',
'LAST_UPDATE_DATE',
@@ -102,8 +105,14 @@ export function mergeAnime(
const byAnilist = local.query(
"SELECT anime_id FROM imm_anime WHERE anilist_id = ? AND media_kind = 'anime'",
);
const byTmdb = local.query(
'SELECT anime_id FROM imm_anime WHERE tmdb_id = ? AND tmdb_type = ? ORDER BY anime_id LIMIT 1',
);
// Anime and live-action rows share a title namespace; YouTube channels are
// looked up on their own, so a same-named anime and channel stay separate.
const byTitleKey = local.query(
'SELECT anime_id FROM imm_anime WHERE normalized_title_key = ? AND media_kind = ?',
`SELECT anime_id, anilist_id, tmdb_id, tmdb_type FROM imm_anime
WHERE normalized_title_key = ? AND ${sameTitleNamespaceSql()}`,
);
// A pre-classification channel can be repaired, but a genuine anime sharing
// its title must remain a separate entry.
@@ -116,16 +125,25 @@ export function mergeAnime(
const releaseChannelAnilistId = local.query(
"UPDATE imm_anime SET anilist_id = NULL WHERE media_kind = 'youtube' AND anilist_id = ?",
);
// 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. A channel match always becomes a channel.
const fillMissing = local.query(
`UPDATE imm_anime
SET
media_kind = ?,
anilist_id = CASE WHEN ? = 'youtube' THEN NULL ELSE anilist_id END,
title_romaji = COALESCE(title_romaji, ?),
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 ? = 'youtube' THEN 'youtube'
WHEN anilist_id IS NULL AND tmdb_id IS NULL THEN ?
ELSE media_kind
END
WHERE anime_id = ?`,
);
@@ -139,10 +157,25 @@ export function mergeAnime(
// incorrectly attached one to a channel.
releaseChannelAnilistId.run(row.anilist_id);
}
const titleMatch = byTitleKey.get(row.normalized_title_key, row.media_kind) as
| SqlRow
| undefined;
const compatibleTitleMatch =
titleMatch &&
((titleMatch.anilist_id === null && titleMatch.tmdb_id === null) ||
(row.anilist_id === null && row.tmdb_id === null) ||
(titleMatch.tmdb_id === null &&
row.tmdb_id === null &&
titleMatch.anilist_id === row.anilist_id) ||
(titleMatch.anilist_id === null &&
row.anilist_id === null &&
titleMatch.tmdb_id === row.tmdb_id &&
titleMatch.tmdb_type === row.tmdb_type));
const existing = ((row.media_kind === 'anime' && row.anilist_id !== null
? byAnilist.get(row.anilist_id)
: undefined) ??
byTitleKey.get(row.normalized_title_key, row.media_kind) ??
(row.tmdb_id !== null ? byTmdb.get(row.tmdb_id, row.tmdb_type) : undefined) ??
(compatibleTitleMatch ? titleMatch : undefined) ??
(row.media_kind === 'youtube' ? legacyChannel.get(row.normalized_title_key) : undefined)) as
| SqlRow
| undefined;
@@ -150,22 +183,34 @@ export function mergeAnime(
const localId = Number(existing.anime_id);
map.set(remoteId, localId);
fillMissing.run(
row.media_kind,
row.media_kind,
row.title_romaji,
row.title_english,
row.title_native,
row.episodes_total,
row.description,
row.tmdb_id,
row.tmdb_type,
row.media_kind,
row.media_kind,
localId,
);
continue;
}
// No local row matched by anilist_id (checked first in `existing` above)
// or title key, so the remote anilist_id — if any — is free to insert as-is.
const values = ANIME_COPY_COLUMNS.map((column) =>
column === 'anilist_id' && row.media_kind !== 'anime' ? null : row[column],
);
// Conflicting providers can share a title, but the stored title key is
// unique within its namespace.
let titleKey = row.normalized_title_key;
for (let suffix = 1; byTitleKey.get(titleKey, row.media_kind); suffix += 1) {
titleKey = `${row.normalized_title_key}:sync:${suffix}`;
}
const values = ANIME_COPY_COLUMNS.map((column) => {
if (column === 'normalized_title_key') return titleKey;
// No local row matched by anilist_id (checked first in `existing` above)
// or title key, so the remote anilist_id is free to insert as-is, except
// that channels never carry one.
if (column === 'anilist_id' && row.media_kind !== 'anime') return null;
return row[column];
});
map.set(remoteId, insertRow(local, 'imm_anime', ANIME_COPY_COLUMNS, values));
summary.animeAdded += 1;
}
@@ -0,0 +1,22 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import test from 'node:test';
import { BUNDLED_INTEGRATION_KEYS_FILENAME, readBundledTmdbApiKey } from './bundled-api-key.js';
test('readBundledTmdbApiKey reads the staged key and tolerates a missing or malformed file', () => {
const distDir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-bundled-key-'));
const filePath = path.join(distDir, BUNDLED_INTEGRATION_KEYS_FILENAME);
try {
assert.equal(readBundledTmdbApiKey(distDir), null);
fs.writeFileSync(filePath, '{"tmdbApiKey":" abc "}');
assert.equal(readBundledTmdbApiKey(distDir), 'abc');
fs.writeFileSync(filePath, '{"tmdbApiKey":""}');
assert.equal(readBundledTmdbApiKey(distDir), null);
fs.writeFileSync(filePath, 'not json');
assert.equal(readBundledTmdbApiKey(distDir), null);
} finally {
fs.rmSync(distDir, { recursive: true, force: true });
}
});
+20
View File
@@ -0,0 +1,20 @@
import fs from 'node:fs';
import path from 'node:path';
/**
* Release builds stage a project-owned TMDB key into dist/ (see
* scripts/bundled-integration-keys.mjs). Source checkouts and CI builds have no
* such file, and TMDB lookups then depend on the user's own `tmdb.apiKey`.
*/
export const BUNDLED_INTEGRATION_KEYS_FILENAME = 'bundled-integration-keys.json';
export function readBundledTmdbApiKey(distDir: string): string | null {
try {
const raw = fs.readFileSync(path.join(distDir, BUNDLED_INTEGRATION_KEYS_FILENAME), 'utf8');
const parsed = JSON.parse(raw) as { tmdbApiKey?: unknown };
const key = typeof parsed.tmdbApiKey === 'string' ? parsed.tmdbApiKey.trim() : '';
return key.length > 0 ? key : null;
} catch {
return null;
}
}
@@ -0,0 +1,108 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { createLiveActionMetadataResolver, titlesMatch } from './live-action-resolver.js';
import {
TmdbApiKeyMissingError,
type TmdbClient,
type TmdbSearchResult,
type TmdbTitleDetails,
} from './tmdb-client.js';
const silentLogger = { info: () => {}, warn: () => {} };
function searchResult(over: Partial<TmdbSearchResult> & { tmdbId: number }): TmdbSearchResult {
return {
tmdbType: 'tv',
title: 'Title',
originalTitle: 'Title',
originalLanguage: 'ja',
overview: null,
posterUrl: null,
year: null,
isAnimation: false,
...over,
};
}
function details(over: Partial<TmdbTitleDetails> & { tmdbId: number }): TmdbTitleDetails {
return {
tmdbType: 'tv',
titleEnglish: null,
titleNative: null,
description: null,
posterUrl: null,
episodesTotal: null,
year: null,
originalLanguage: 'ja',
isAnimation: false,
allTitles: [],
...over,
};
}
test('titlesMatch ignores case, width, and punctuation but not extra words', () => {
assert.equal(titlesMatch('Hanzawa Naoki', ['HANZAWA NAOKI!']), true);
assert.equal(titlesMatch('半沢直樹', ['半沢直樹']), true);
assert.equal(titlesMatch('Hanzawa Naoki', ['Hanzawa Naoki Season 2']), false);
assert.equal(titlesMatch('', ['']), false);
});
test('resolveByTitle only accepts a Japanese non-animated result whose known titles match exactly', async () => {
const detailCalls: number[] = [];
const client: TmdbClient = {
async search() {
return [
searchResult({ tmdbId: 1, title: 'Hanzawa Naoki', originalLanguage: 'ko' }),
searchResult({ tmdbId: 2, title: 'Hanzawa Naoki', isAnimation: true }),
searchResult({ tmdbId: 3, title: 'Hanzawa Naoki: The Movie' }),
searchResult({ tmdbId: 4, title: 'Hanzawa Naoki' }),
];
},
async getDetails(_type, tmdbId) {
detailCalls.push(tmdbId);
if (tmdbId === 3) return details({ tmdbId: 3, allTitles: ['Hanzawa Naoki: The Movie'] });
if (tmdbId === 4) return details({ tmdbId: 4, allTitles: ['Hanzawa Naoki', '半沢直樹'] });
return null;
},
};
const resolver = createLiveActionMetadataResolver(client, silentLogger);
const resolved = await resolver.resolveByTitle('hanzawa naoki');
assert.equal(resolved?.tmdbId, 4);
assert.deepEqual(detailCalls, [3, 4]);
});
test('resolveByTitle returns null when nothing matches or the key is missing', async () => {
const noMatch: TmdbClient = {
async search() {
return [searchResult({ tmdbId: 1, title: 'Something Else' })];
},
async getDetails() {
return details({ tmdbId: 1, allTitles: ['Something Else'] });
},
};
assert.equal(
await createLiveActionMetadataResolver(noMatch, silentLogger).resolveByTitle('Hanzawa Naoki'),
null,
);
let infoCount = 0;
const noKey: TmdbClient = {
async search() {
throw new TmdbApiKeyMissingError();
},
async getDetails() {
throw new TmdbApiKeyMissingError();
},
};
const resolver = createLiveActionMetadataResolver(noKey, {
info: () => {
infoCount += 1;
},
warn: () => {},
});
assert.equal(await resolver.resolveByTitle('Hanzawa Naoki'), null);
assert.equal(await resolver.resolveById('tv', 1), null);
assert.equal(infoCount, 1);
});
@@ -0,0 +1,75 @@
import { normalizeTitleIdentity } from '../../utils/title-normalization';
import type { TmdbMediaType } from '../../../shared/media-kind';
import { TmdbApiKeyMissingError, type TmdbClient, type TmdbTitleDetails } from './tmdb-client';
const MAX_DETAIL_LOOKUPS = 3;
/**
* Resolves live-action titles for the automatic cover-art path. Anime is
* AniList's job, so only non-animated Japanese-language results qualify, and
* a candidate must match the parsed title exactly under one of the names TMDB
* knows for it. Fuzzy search hits are never trusted on their own: a stray
* filename would otherwise pin the wrong show to a library entry.
*/
export interface LiveActionMetadataResolver {
resolveByTitle(title: string): Promise<TmdbTitleDetails | null>;
resolveById(tmdbType: TmdbMediaType, tmdbId: number): Promise<TmdbTitleDetails | null>;
}
interface Logger {
info(msg: string, ...args: unknown[]): void;
warn(msg: string, ...args: unknown[]): void;
}
export function titlesMatch(candidate: string, knownTitles: Iterable<string>): boolean {
const key = normalizeTitleIdentity(candidate);
if (!key) return false;
for (const known of knownTitles) {
if (normalizeTitleIdentity(known) === key) return true;
}
return false;
}
export function createLiveActionMetadataResolver(
client: TmdbClient,
logger: Logger,
): LiveActionMetadataResolver {
let warnedMissingKey = false;
const guard = async <T>(work: () => Promise<T>): Promise<T | null> => {
try {
return await work();
} catch (err) {
if (err instanceof TmdbApiKeyMissingError) {
if (!warnedMissingKey) {
warnedMissingKey = true;
logger.info('tmdb: no API key configured, skipping live-action metadata lookups');
}
return null;
}
logger.warn('tmdb: lookup failed: %s', err instanceof Error ? err.message : String(err));
return null;
}
};
return {
resolveByTitle(title) {
return guard(async () => {
const results = await client.search(title);
const candidates = results
.filter((result) => result.originalLanguage === 'ja' && !result.isAnimation)
.slice(0, MAX_DETAIL_LOOKUPS);
for (const candidate of candidates) {
const details = await client.getDetails(candidate.tmdbType, candidate.tmdbId);
if (details && !details.isAnimation && titlesMatch(title, details.allTitles)) {
return details;
}
}
return null;
});
},
resolveById(tmdbType, tmdbId) {
return guard(() => client.getDetails(tmdbType, tmdbId));
},
};
}
+335
View File
@@ -0,0 +1,335 @@
import assert from 'node:assert/strict';
import test, { type TestContext } from 'node:test';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import type { TmdbConfig } from '../../../types/integrations';
import {
TmdbApiKeyMissingError,
createTmdbClient,
createTmdbApiKeyResolver,
resolveTmdbApiKey,
} from './tmdb-client.js';
function commandFixture(t: TestContext) {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer tmdb command-'));
t.after(() => fs.rmSync(dir, { recursive: true, force: true }));
let nextId = 0;
const quotePath = (value: string) =>
`"${process.platform === 'win32' ? value : value.replace(/["\\$`]/g, '\\$&')}"`;
return {
dir,
command(source: string): string {
const script = path.join(dir, `credential-${nextId++}.cjs`);
fs.writeFileSync(script, source);
return `${quotePath(process.execPath)} ${quotePath(script)}`;
},
};
}
function jsonResponse(payload: unknown, status = 200): Response {
return new Response(JSON.stringify(payload), {
status,
headers: { 'content-type': 'application/json' },
});
}
function captureFetch(handler: (url: URL, init?: RequestInit) => Response) {
const calls: Array<{ url: URL; init?: RequestInit }> = [];
const fetchImpl = (async (input: RequestInfo | URL, init?: RequestInit) => {
const url = new URL(String(input));
calls.push({ url, init });
return handler(url, init);
}) as typeof fetch;
return { calls, fetchImpl };
}
test('resolveTmdbApiKey prefers the literal key and trims it', async (t) => {
const { command } = commandFixture(t);
assert.equal(
await resolveTmdbApiKey({ apiKey: ' abc ', apiKeyCommand: command('process.exit(3)') }),
'abc',
);
assert.equal(await resolveTmdbApiKey({ apiKey: '', apiKeyCommand: '' }), null);
assert.equal(await resolveTmdbApiKey(undefined), null);
});
test('resolveTmdbApiKey runs apiKeyCommand when no literal key is set', async (t) => {
const { command } = commandFixture(t);
assert.equal(
await resolveTmdbApiKey({ apiKeyCommand: command('process.stdout.write(" from-cmd ")') }),
'from-cmd',
);
assert.equal(await resolveTmdbApiKey({ apiKeyCommand: command('process.exit(3)') }), null);
});
test('resolveTmdbApiKey falls back to the bundled key only when the user set nothing usable', async (t) => {
const { command } = commandFixture(t);
assert.equal(await resolveTmdbApiKey({}, 'bundled'), 'bundled');
assert.equal(await resolveTmdbApiKey({ apiKey: 'mine' }, 'bundled'), 'mine');
assert.equal(
await resolveTmdbApiKey({ apiKeyCommand: command('process.stdout.write("mine")') }, 'bundled'),
'mine',
);
assert.equal(
await resolveTmdbApiKey({ apiKeyCommand: command('process.exit(3)') }, 'bundled'),
'bundled',
);
});
test('search rejects without a key and never touches the network', async () => {
const { calls, fetchImpl } = captureFetch(() => jsonResponse({ results: [] }));
const client = createTmdbClient({ resolveApiKey: async () => null, fetch: fetchImpl });
await assert.rejects(client.search('半沢直樹'), TmdbApiKeyMissingError);
assert.equal(calls.length, 0);
});
test('search sends a v3 key as a query parameter and drops people from multi results', async () => {
const { calls, fetchImpl } = captureFetch(() =>
jsonResponse({
results: [
{ media_type: 'person', id: 1, name: 'Sakai Masato' },
{
media_type: 'tv',
id: 61222,
name: 'Hanzawa Naoki',
original_name: '半沢直樹',
original_language: 'ja',
overview: 'A banker fights back.',
poster_path: '/hanzawa.jpg',
first_air_date: '2013-07-07',
genre_ids: [18],
},
{
media_type: 'movie',
id: 9,
title: 'Anime Film',
original_title: 'アニメ映画',
original_language: 'ja',
release_date: '2020-01-01',
genre_ids: [16],
},
],
}),
);
const client = createTmdbClient({ resolveApiKey: async () => 'v3key', fetch: fetchImpl });
const results = await client.search(' 半沢直樹 ');
assert.equal(calls.length, 1);
const url = calls[0]!.url;
assert.equal(url.pathname, '/3/search/multi');
assert.equal(url.searchParams.get('query'), '半沢直樹');
assert.equal(url.searchParams.get('api_key'), 'v3key');
assert.equal((calls[0]!.init?.headers as Record<string, string>).Authorization, undefined);
assert.deepEqual(results, [
{
tmdbId: 61222,
tmdbType: 'tv',
title: 'Hanzawa Naoki',
originalTitle: '半沢直樹',
originalLanguage: 'ja',
overview: 'A banker fights back.',
posterUrl: 'https://image.tmdb.org/t/p/w500/hanzawa.jpg',
year: 2013,
isAnimation: false,
},
{
tmdbId: 9,
tmdbType: 'movie',
title: 'Anime Film',
originalTitle: 'アニメ映画',
originalLanguage: 'ja',
overview: null,
posterUrl: null,
year: 2020,
isAnimation: true,
},
]);
});
test('a v4 read token travels as a bearer header instead of api_key', async () => {
const v4Token = ['eyJ', 'test-header', '.payload', '.sig'].join('');
const { calls, fetchImpl } = captureFetch(() => jsonResponse({ results: [] }));
const client = createTmdbClient({
resolveApiKey: async () => v4Token,
fetch: fetchImpl,
});
await client.search('x');
assert.equal(calls[0]!.url.searchParams.has('api_key'), false);
assert.equal(
(calls[0]!.init?.headers as Record<string, string>).Authorization,
`Bearer ${v4Token}`,
);
});
test('getDetails folds translations and alternative titles into the normalized shape', async () => {
const { calls, fetchImpl } = captureFetch(() =>
jsonResponse({
id: 61222,
name: 'Hanzawa Naoki',
original_name: '半沢直樹',
original_language: 'ja',
overview: 'A banker fights back.',
poster_path: '/hanzawa.jpg',
first_air_date: '2013-07-07',
number_of_episodes: 10,
genres: [{ id: 18, name: 'Drama' }],
alternative_titles: { results: [{ iso_3166_1: 'JP', title: 'Hanzawa Naoki Season 1' }] },
translations: {
translations: [
{ iso_639_1: 'en', data: { name: 'Hanzawa Naoki', overview: 'A banker fights back.' } },
{ iso_639_1: 'ja', data: { name: '半沢直樹', overview: '銀行員の物語' } },
],
},
}),
);
const client = createTmdbClient({ resolveApiKey: async () => 'k', fetch: fetchImpl });
const details = await client.getDetails('tv', 61222);
assert.equal(calls[0]!.url.pathname, '/3/tv/61222');
assert.equal(
calls[0]!.url.searchParams.get('append_to_response'),
'alternative_titles,translations',
);
assert.deepEqual(details, {
tmdbId: 61222,
tmdbType: 'tv',
titleEnglish: 'Hanzawa Naoki',
titleNative: '半沢直樹',
description: 'A banker fights back.',
posterUrl: 'https://image.tmdb.org/t/p/w500/hanzawa.jpg',
episodesTotal: 10,
year: 2013,
originalLanguage: 'ja',
isAnimation: false,
allTitles: ['Hanzawa Naoki', '半沢直樹', 'Hanzawa Naoki Season 1'],
});
});
test('getDetails falls back to the Japanese overview and counts a movie as one episode', async () => {
const { fetchImpl } = captureFetch(() =>
jsonResponse({
id: 5,
title: '半沢直樹',
original_title: '半沢直樹',
original_language: 'ja',
overview: '',
release_date: '2019-03-01',
translations: {
translations: [{ iso_639_1: 'ja', data: { title: '半沢直樹', overview: 'あらすじ' } }],
},
}),
);
const client = createTmdbClient({ resolveApiKey: async () => 'k', fetch: fetchImpl });
const details = await client.getDetails('movie', 5);
assert.equal(details?.titleEnglish, null);
assert.equal(details?.titleNative, '半沢直樹');
assert.equal(details?.description, 'あらすじ');
assert.equal(details?.episodesTotal, 1);
});
test('getDetails returns null for an unknown id', async () => {
const { fetchImpl } = captureFetch(() => jsonResponse({ status_message: 'nope' }, 404));
const client = createTmdbClient({ resolveApiKey: async () => 'k', fetch: fetchImpl });
assert.equal(await client.getDetails('tv', 1), null);
});
test('client reuses command output across requests and invalidates it when either setting changes', async (t) => {
const fixture = commandFixture(t);
const counter = path.join(fixture.dir, 'calls');
const createCommand = (key: string) =>
fixture.command(`
const fs = require('node:fs');
const path = require('node:path');
fs.appendFileSync(path.join(__dirname, 'calls'), 'x');
process.stdout.write(${JSON.stringify(key)});
`);
let config: TmdbConfig = { apiKeyCommand: createCommand('command-key') };
const { calls, fetchImpl } = captureFetch(() => jsonResponse({ results: [] }));
const client = createTmdbClient({
resolveApiKey: createTmdbApiKeyResolver(
() => config,
() => 'bundled',
),
fetch: fetchImpl,
});
await Promise.all([client.search('a'), client.search('b')]);
await client.getDetails('tv', 1);
assert.equal(fs.readFileSync(counter, 'utf8'), 'x');
assert.ok(calls.every(({ url }) => url.searchParams.get('api_key') === 'command-key'));
config = { ...config, apiKey: 'literal' };
await client.search('c');
assert.equal(calls.at(-1)?.url.searchParams.get('api_key'), 'literal');
config = { ...config, apiKey: '' };
await client.search('d');
assert.equal(fs.readFileSync(counter, 'utf8'), 'xx');
config = { apiKeyCommand: createCommand('new-key') };
await client.search('e');
assert.equal(fs.readFileSync(counter, 'utf8'), 'xxx');
assert.equal(calls.at(-1)?.url.searchParams.get('api_key'), 'new-key');
});
for (const failure of ['error', 'empty'] as const) {
test(`${failure} command output uses a bounded cooldown before retrying`, async (t) => {
const fixture = commandFixture(t);
const counter = path.join(fixture.dir, 'calls');
const command = fixture.command(`
const fs = require('node:fs');
const path = require('node:path');
const counter = path.join(__dirname, 'calls');
fs.appendFileSync(counter, 'x');
if (fs.readFileSync(counter, 'utf8').length === 1) process.exit(${failure === 'error' ? 1 : 0});
process.stdout.write('recovered');
`);
let now = 1000;
const originalNow = Date.now;
Date.now = () => now;
t.after(() => {
Date.now = originalNow;
});
let bundledKey: string | null = 'bundled';
const resolve = createTmdbApiKeyResolver(
() => ({ apiKeyCommand: command }),
() => bundledKey,
);
assert.deepEqual(await Promise.all([resolve(), resolve()]), ['bundled', 'bundled']);
now += 29_999;
assert.equal(await resolve(), 'bundled');
bundledKey = null;
assert.equal(await resolve(), null);
assert.equal(fs.readFileSync(counter, 'utf8'), 'x');
now += 1;
assert.equal(await resolve(), 'recovered');
assert.equal(await resolve(), 'recovered');
assert.equal(fs.readFileSync(counter, 'utf8'), 'xx');
});
}
for (const setting of ['apiKey', 'apiKeyCommand'] as const) {
test(`changing ${setting} clears a failed command cooldown`, async (t) => {
const fixture = commandFixture(t);
const counter = path.join(fixture.dir, 'calls');
const source = `
const fs = require('node:fs');
const path = require('node:path');
fs.appendFileSync(path.join(__dirname, 'calls'), 'x');
process.exit(1);
`;
let config: TmdbConfig = { apiKeyCommand: fixture.command(source) };
const resolve = createTmdbApiKeyResolver(
() => config,
() => 'bundled',
);
assert.equal(await resolve(), 'bundled');
assert.equal(await resolve(), 'bundled');
assert.equal(fs.readFileSync(counter, 'utf8'), 'x');
config =
setting === 'apiKey'
? { ...config, apiKey: ' ' }
: { apiKeyCommand: fixture.command(source) };
assert.equal(await resolve(), 'bundled');
assert.equal(fs.readFileSync(counter, 'utf8'), 'xx');
});
}
+311
View File
@@ -0,0 +1,311 @@
import * as childProcess from 'node:child_process';
import type { TmdbMediaType } from '../../../shared/media-kind';
import type { TmdbConfig } from '../../../types/integrations';
import type { StatsTmdbSearchResult } from '../../../types/stats-http-contract';
export const TMDB_API_BASE_URL = 'https://api.themoviedb.org/3';
const TMDB_POSTER_BASE_URL = 'https://image.tmdb.org/t/p/w500';
const REQUEST_TIMEOUT_MS = 8_000;
const API_KEY_COMMAND_RETRY_MS = 30_000;
const ANIMATION_GENRE_ID = 16;
export type TmdbSearchResult = StatsTmdbSearchResult;
export interface TmdbTitleDetails {
tmdbId: number;
tmdbType: TmdbMediaType;
titleEnglish: string | null;
titleNative: string | null;
/** English synopsis, falling back to the Japanese one. */
description: string | null;
posterUrl: string | null;
episodesTotal: number | null;
year: number | null;
originalLanguage: string;
isAnimation: boolean;
/** Every name TMDB knows for the title, used for exact-title matching. */
allTitles: string[];
}
export interface TmdbClient {
search(query: string): Promise<TmdbSearchResult[]>;
getDetails(tmdbType: TmdbMediaType, tmdbId: number): Promise<TmdbTitleDetails | null>;
}
export class TmdbApiKeyMissingError extends Error {
constructor() {
super('TMDB API key not configured. Set tmdb.apiKey or tmdb.apiKeyCommand.');
this.name = 'TmdbApiKeyMissingError';
}
}
export class TmdbRequestError extends Error {
constructor(
message: string,
readonly status: number,
) {
super(message);
this.name = 'TmdbRequestError';
}
}
function execCommand(command: string): Promise<string> {
return new Promise((resolve, reject) => {
childProcess.exec(command, { timeout: 10_000 }, (err, stdout) => {
if (err) {
reject(err);
return;
}
resolve(stdout);
});
});
}
/**
* Resolves the key in priority order: the user's literal `apiKey`, then the
* output of `apiKeyCommand`, then the key bundled into release builds.
*/
export async function resolveTmdbApiKey(
config: TmdbConfig | undefined,
bundledKey: string | null = null,
): Promise<string | null> {
const literal = config?.apiKey?.trim();
if (literal) return literal;
const command = config?.apiKeyCommand?.trim();
if (command) {
try {
const key = (await execCommand(command)).trim();
if (key.length > 0) return key;
} catch {
/* fall through to the bundled key */
}
}
return bundledKey;
}
/** Cache successful command output until either credential setting changes. */
export function createTmdbApiKeyResolver(
getConfig: () => TmdbConfig | undefined,
getBundledKey: () => string | null = () => null,
): () => Promise<string | null> {
let state:
| {
apiKey: string | undefined;
apiKeyCommand: string | undefined;
pending: Promise<string | null> | null;
retryAfterMs: number;
}
| undefined;
return async () => {
const config = getConfig();
if (
!state ||
state.apiKey !== config?.apiKey ||
state.apiKeyCommand !== config?.apiKeyCommand
) {
state = {
apiKey: config?.apiKey,
apiKeyCommand: config?.apiKeyCommand,
pending: null,
retryAfterMs: 0,
};
}
const current = state;
const literal = current.apiKey?.trim();
if (literal) return literal;
if (!current.apiKeyCommand?.trim()) return getBundledKey();
if (Date.now() < current.retryAfterMs) return getBundledKey();
current.pending ??= resolveTmdbApiKey(current).then((key) => {
if (!key) {
current.retryAfterMs = Date.now() + API_KEY_COMMAND_RETRY_MS;
current.pending = null;
}
return key;
});
const key = await current.pending;
return key ?? getBundledKey();
};
}
interface RawSearchItem {
media_type?: string;
id?: number;
name?: string;
original_name?: string;
title?: string;
original_title?: string;
original_language?: string;
overview?: string;
poster_path?: string | null;
first_air_date?: string;
release_date?: string;
genre_ids?: number[];
}
interface RawTranslation {
iso_639_1?: string;
data?: { name?: string; title?: string; overview?: string };
}
interface RawDetails extends RawSearchItem {
number_of_episodes?: number;
genres?: Array<{ id?: number }>;
alternative_titles?: { results?: Array<{ title?: string }>; titles?: Array<{ title?: string }> };
translations?: { translations?: RawTranslation[] };
}
function nonEmpty(value: unknown): string | null {
return typeof value === 'string' && value.trim() ? value.trim() : null;
}
function yearOf(date: string | undefined): number | null {
const year = Number.parseInt(date?.slice(0, 4) ?? '', 10);
return Number.isFinite(year) && year > 0 ? year : null;
}
function posterUrlOf(path: string | null | undefined): string | null {
return path ? `${TMDB_POSTER_BASE_URL}${path}` : null;
}
function mediaTypeOf(value: unknown): TmdbMediaType | null {
return value === 'tv' || value === 'movie' ? value : null;
}
function normalizeSearchItem(item: RawSearchItem): TmdbSearchResult | null {
const tmdbType = mediaTypeOf(item.media_type);
if (!tmdbType || typeof item.id !== 'number') return null;
const title = nonEmpty(item.name) ?? nonEmpty(item.title);
const originalTitle = nonEmpty(item.original_name) ?? nonEmpty(item.original_title) ?? title;
if (!title || !originalTitle) return null;
return {
tmdbId: item.id,
tmdbType,
title,
originalTitle,
originalLanguage: item.original_language ?? '',
overview: nonEmpty(item.overview),
posterUrl: posterUrlOf(item.poster_path),
year: yearOf(item.first_air_date ?? item.release_date),
isAnimation: (item.genre_ids ?? []).includes(ANIMATION_GENRE_ID),
};
}
function normalizeDetails(tmdbType: TmdbMediaType, raw: RawDetails): TmdbTitleDetails | null {
if (typeof raw.id !== 'number') return null;
const localizedTitle = nonEmpty(raw.name) ?? nonEmpty(raw.title);
const originalTitle = nonEmpty(raw.original_name) ?? nonEmpty(raw.original_title);
const originalLanguage = raw.original_language ?? '';
const translations = raw.translations?.translations ?? [];
const translationFor = (language: string) =>
translations.find((entry) => entry.iso_639_1 === language)?.data;
const english = translationFor('en');
const japanese = translationFor('ja');
const englishTitle =
nonEmpty(english?.name) ??
nonEmpty(english?.title) ??
(localizedTitle && localizedTitle !== originalTitle ? localizedTitle : null);
const nativeTitle =
originalLanguage === 'ja'
? originalTitle
: (nonEmpty(japanese?.name) ?? nonEmpty(japanese?.title));
const alternativeTitles = [
...(raw.alternative_titles?.results ?? []),
...(raw.alternative_titles?.titles ?? []),
].map((entry) => nonEmpty(entry.title));
const translatedTitles = translations.map(
(entry) => nonEmpty(entry.data?.name) ?? nonEmpty(entry.data?.title),
);
const allTitles = [
...new Set(
[
localizedTitle,
originalTitle,
englishTitle,
nativeTitle,
...translatedTitles,
...alternativeTitles,
].filter((title): title is string => Boolean(title)),
),
];
return {
tmdbId: raw.id,
tmdbType,
titleEnglish: englishTitle,
titleNative: nativeTitle,
description:
nonEmpty(raw.overview) ?? nonEmpty(english?.overview) ?? nonEmpty(japanese?.overview),
posterUrl: posterUrlOf(raw.poster_path),
episodesTotal:
tmdbType === 'movie'
? 1
: typeof raw.number_of_episodes === 'number' && raw.number_of_episodes > 0
? raw.number_of_episodes
: null,
year: yearOf(raw.first_air_date ?? raw.release_date),
originalLanguage,
isAnimation: (raw.genres ?? []).some((genre) => genre.id === ANIMATION_GENRE_ID),
allTitles,
};
}
// TMDB issues two kinds of credential: a short v3 key that travels as a query
// parameter and a long v4 read token (a JWT) that goes in the Authorization
// header. Users paste whichever the settings page showed them.
function isV4Token(apiKey: string): boolean {
return apiKey.startsWith('eyJ');
}
export function createTmdbClient(deps: {
resolveApiKey: () => Promise<string | null>;
fetch?: typeof fetch;
baseUrl?: string;
}): TmdbClient {
const fetchImpl = deps.fetch ?? fetch;
const baseUrl = (deps.baseUrl ?? TMDB_API_BASE_URL).replace(/\/+$/, '');
async function request<T>(path: string, params: Record<string, string>): Promise<T | null> {
const apiKey = await deps.resolveApiKey();
if (!apiKey) throw new TmdbApiKeyMissingError();
const url = new URL(`${baseUrl}${path}`);
for (const [key, value] of Object.entries(params)) url.searchParams.set(key, value);
const headers: Record<string, string> = { Accept: 'application/json' };
if (isV4Token(apiKey)) {
headers.Authorization = `Bearer ${apiKey}`;
} else {
url.searchParams.set('api_key', apiKey);
}
const res = await fetchImpl(url, { headers, signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS) });
if (res.status === 404) return null;
if (!res.ok) {
throw new TmdbRequestError(
`TMDB request failed: ${res.status} ${res.statusText}`,
res.status,
);
}
return (await res.json()) as T;
}
return {
async search(query) {
const trimmed = query.trim();
if (!trimmed) return [];
const payload = await request<{ results?: RawSearchItem[] }>('/search/multi', {
query: trimmed,
include_adult: 'false',
language: 'en-US',
page: '1',
});
return (payload?.results ?? [])
.map(normalizeSearchItem)
.filter((item): item is TmdbSearchResult => item !== null);
},
async getDetails(tmdbType, tmdbId) {
const raw = await request<RawDetails>(`/${tmdbType}/${tmdbId}`, {
language: 'en-US',
append_to_response: 'alternative_titles,translations',
});
return raw ? normalizeDetails(tmdbType, raw) : null;
},
};
}