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
@@ -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,