feat(stats): add library entry merge and episode move (#190)

This commit is contained in:
2026-08-14 21:54:17 -07:00
committed by GitHub
parent c5a77ac067
commit 6fb3d2eb13
42 changed files with 4850 additions and 89 deletions
@@ -0,0 +1,942 @@
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,
findManualDirectoryAnimeAssignment,
getManualAnimeAssignment,
getOrCreateAnimeRecord,
linkVideoToAnimeRecord,
} from '../storage.js';
import { mergeAnimeRecords, moveVideoToAnime } from '../anime-merge.js';
import {
dismissAnimeMergeRecommendation,
getAnimeMergeRecommendations,
resolveAnimeAnilistConflict,
} from '../anime-season-repair.js';
import { updateAnimeAnilistInfo } from '../query-maintenance.js';
const BASE_MS = 1_700_000_000_000;
function makeDbPath(): string {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-anime-merge-test-'));
return path.join(dir, 'immersion.sqlite');
}
function cleanupDbPath(dbPath: string): void {
const dir = path.dirname(dbPath);
if (!fs.existsSync(dir)) return;
fs.rmSync(dir, { recursive: true, force: true });
}
function withDb(work: (db: DatabaseSync) => void): void {
const dbPath = makeDbPath();
const db = new Database(dbPath);
try {
applyPragmas(db);
ensureSchema(db);
work(db);
} finally {
db.close();
cleanupDbPath(dbPath);
}
}
interface AnimeSeed {
animeId: number;
key: string;
title: string;
anilistId?: number | null;
titleRomaji?: string | null;
}
function insertAnime(db: DatabaseSync, seed: AnimeSeed): void {
db.prepare(
`INSERT INTO imm_anime(anime_id, normalized_title_key, canonical_title, anilist_id, title_romaji, CREATED_DATE, LAST_UPDATE_DATE)
VALUES (?, ?, ?, ?, ?, ?, ?)`,
).run(
seed.animeId,
seed.key,
seed.title,
seed.anilistId ?? null,
seed.titleRomaji ?? null,
BASE_MS,
BASE_MS,
);
}
interface EpisodeSeed {
videoId: number;
animeId: number;
season?: number | null;
episode?: number;
activeMs?: number;
cards?: number;
}
/**
* One episode with one ended session, plus the imm_lifetime_media row the
* session would have left behind, so lifetime aggregates have something to sum.
*/
function insertEpisode(db: DatabaseSync, seed: EpisodeSeed): void {
const activeMs = seed.activeMs ?? 1000;
const cards = seed.cards ?? 1;
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, 'Show', ?, ?, 1, 1440000, ?, ?)`,
).run(
seed.videoId,
`local:/tmp/show-${seed.videoId}.mkv`,
seed.animeId,
`Show ${seed.videoId}`,
seed.season ?? null,
seed.episode ?? seed.videoId,
BASE_MS,
BASE_MS,
);
db.prepare(
`INSERT INTO imm_sessions(session_id, session_uuid, video_id, started_at_ms, ended_at_ms, status, active_watched_ms, cards_mined, CREATED_DATE, LAST_UPDATE_DATE)
VALUES (?, ?, ?, ?, ?, 2, ?, ?, ?, ?)`,
).run(
seed.videoId,
`session-${seed.videoId}`,
seed.videoId,
String(BASE_MS),
String(BASE_MS + activeMs),
activeMs,
cards,
BASE_MS,
BASE_MS,
);
db.prepare(
`INSERT INTO imm_subtitle_lines(session_id, video_id, anime_id, line_index, text, CREATED_DATE, LAST_UPDATE_DATE)
VALUES (?, ?, ?, 1, ?, ?, ?)`,
).run(seed.videoId, seed.videoId, seed.animeId, `line ${seed.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, ?, ?, 1, ?, ?, ?, ?)`,
).run(
seed.videoId,
activeMs,
cards,
String(BASE_MS),
String(BASE_MS + activeMs),
BASE_MS,
BASE_MS,
);
}
function animeIds(db: DatabaseSync): number[] {
return (
db.prepare('SELECT anime_id AS id FROM imm_anime ORDER BY anime_id').all() as Array<{
id: number;
}>
).map((row) => row.id);
}
function videoAnimeId(db: DatabaseSync, videoId: number): number | null {
return (
db.prepare('SELECT anime_id AS id FROM imm_videos WHERE video_id = ?').get(videoId) as {
id: number | null;
}
).id;
}
function assignmentLocked(db: DatabaseSync, videoId: number): number {
return (
db
.prepare('SELECT anime_assignment_locked AS locked FROM imm_videos WHERE video_id = ?')
.get(videoId) as { locked: number }
).locked;
}
function lineAnimeIds(db: DatabaseSync, animeId: number): number {
return Number(
(
db
.prepare('SELECT COUNT(*) AS total FROM imm_subtitle_lines WHERE anime_id = ?')
.get(animeId) as { total: number }
).total,
);
}
test('mergeAnimeRecords folds episodes, lines and lifetime totals into the target', () => {
withDb((db) => {
insertAnime(db, { animeId: 1, key: 'show', title: 'Show' });
insertAnime(db, { animeId: 2, key: 'show season 1', title: 'Show Season 1', anilistId: 555 });
insertEpisode(db, { videoId: 1, animeId: 1, activeMs: 1000, cards: 1 });
insertEpisode(db, { videoId: 2, animeId: 2, season: 1, activeMs: 2000, cards: 3 });
const summary = mergeAnimeRecords(db, 1, [2]);
assert.equal(summary.survivingAnimeId, 1);
assert.deepEqual(summary.mergedAnimeIds, [2]);
assert.equal(summary.movedVideos, 1);
assert.deepEqual(animeIds(db), [1]);
assert.equal(videoAnimeId(db, 2), 1);
assert.equal(lineAnimeIds(db, 1), 2);
const lifetime = db
.prepare(
'SELECT total_active_ms AS activeMs, total_cards AS cards, episodes_started AS episodes FROM imm_lifetime_anime WHERE anime_id = 1',
)
.get() as { activeMs: number; cards: number; episodes: number };
assert.equal(lifetime.activeMs, 3000);
assert.equal(lifetime.cards, 4);
assert.equal(lifetime.episodes, 2);
});
});
test('merge and move preserve lifetime history whose raw sessions were pruned', () => {
withDb((db) => {
insertAnime(db, { animeId: 1, key: 'show', title: 'Show' });
insertAnime(db, { animeId: 2, key: 'show season 1', title: 'Show Season 1' });
insertAnime(db, { animeId: 3, key: 'other show', title: 'Other Show' });
insertEpisode(db, { videoId: 1, animeId: 1, activeMs: 1000, cards: 1 });
insertEpisode(db, { videoId: 2, animeId: 2, season: 1, activeMs: 2000, cards: 3 });
insertEpisode(db, { videoId: 3, animeId: 3, activeMs: 4000, cards: 5 });
// Retention pruned every raw session; only the lifetime summaries remain.
db.exec('DELETE FROM imm_sessions');
db.prepare(
`UPDATE imm_lifetime_global
SET total_sessions = 200, total_active_ms = 360000000, total_cards = 500, active_days = 90
WHERE global_id = 1`,
).run();
mergeAnimeRecords(db, 1, [2]);
moveVideoToAnime(db, 3, 1);
const globalRow = db
.prepare(
`SELECT total_sessions AS sessions, total_active_ms AS activeMs, total_cards AS cards, active_days AS days
FROM imm_lifetime_global WHERE global_id = 1`,
)
.get() as { sessions: number; activeMs: number; cards: number; days: number };
assert.equal(globalRow.sessions, 200);
assert.equal(globalRow.activeMs, 360000000);
assert.equal(globalRow.cards, 500);
assert.equal(globalRow.days, 90);
const survivor = db
.prepare(
`SELECT total_active_ms AS activeMs, total_cards AS cards, episodes_started AS episodes
FROM imm_lifetime_anime WHERE anime_id = 1`,
)
.get() as { activeMs: number; cards: number; episodes: number };
assert.equal(survivor.activeMs, 7000);
assert.equal(survivor.cards, 9);
assert.equal(survivor.episodes, 3);
assert.equal(
db.prepare('SELECT 1 FROM imm_lifetime_anime WHERE anime_id = 3').get(),
undefined,
);
});
});
test('mergeAnimeRecords repoints subtitle lines recorded before the anime link landed', () => {
withDb((db) => {
insertAnime(db, { animeId: 1, key: 'show', title: 'Show' });
insertAnime(db, { animeId: 2, key: 'show season 1', title: 'Show Season 1' });
insertEpisode(db, { videoId: 1, animeId: 1 });
insertEpisode(db, { videoId: 2, animeId: 2, season: 1 });
// Lines are written with the video's anime_id at the time, which is NULL
// until the async title parse assigns one.
db.prepare(
`INSERT INTO imm_subtitle_lines(session_id, video_id, anime_id, line_index, text, CREATED_DATE, LAST_UPDATE_DATE)
VALUES (2, 2, NULL, 2, 'unlinked line', ?, ?)`,
).run(BASE_MS, BASE_MS);
mergeAnimeRecords(db, 1, [2]);
assert.equal(lineAnimeIds(db, 1), 3);
const orphaned = Number(
(
db
.prepare('SELECT COUNT(*) AS total FROM imm_subtitle_lines WHERE anime_id IS NULL')
.get() as { total: number }
).total,
);
assert.equal(orphaned, 0);
});
});
test('mergeAnimeRecords inherits metadata the target is missing without clobbering its own', () => {
withDb((db) => {
insertAnime(db, { animeId: 1, key: 'show', title: 'Show', titleRomaji: 'Shou' });
insertAnime(db, {
animeId: 2,
key: 'show season 1',
title: 'Show Season 1',
anilistId: 555,
titleRomaji: 'Show Romaji',
});
insertEpisode(db, { videoId: 1, animeId: 1 });
insertEpisode(db, { videoId: 2, animeId: 2, season: 1 });
mergeAnimeRecords(db, 1, [2]);
const row = db
.prepare(
'SELECT canonical_title AS title, anilist_id AS anilistId, title_romaji AS romaji FROM imm_anime WHERE anime_id = 1',
)
.get() as { title: string; anilistId: number | null; romaji: string | null };
assert.equal(row.title, 'Show');
// anilist_id is UNIQUE, so inheriting it proves the source row was gone first.
assert.equal(row.anilistId, 555);
assert.equal(row.romaji, 'Shou');
});
});
test('mergeAnimeRecords preserves source title identities as aliases of the survivor', () => {
withDb((db) => {
insertAnime(db, { animeId: 1, key: 'show', title: 'Show' });
insertAnime(db, { animeId: 2, key: 'show season 1', title: 'Show Season 1' });
insertEpisode(db, { videoId: 1, animeId: 1 });
insertEpisode(db, { videoId: 2, animeId: 2, season: 1 });
db.prepare(
`INSERT INTO imm_anime_title_aliases(normalized_title_key, anime_id, CREATED_DATE, LAST_UPDATE_DATE)
VALUES ('show s01', 2, ?, ?)`,
).run(BASE_MS, BASE_MS);
mergeAnimeRecords(db, 1, [2]);
const fromSourceTitle = getOrCreateAnimeRecord(db, {
parsedTitle: 'Show Season 1',
canonicalTitle: 'Show Season 1',
seasonScope: 1,
anilistId: null,
titleRomaji: null,
titleEnglish: null,
titleNative: null,
metadataJson: null,
});
const fromTransferredAlias = getOrCreateAnimeRecord(db, {
parsedTitle: 'Show S01',
canonicalTitle: 'Show S01',
anilistId: null,
titleRomaji: null,
titleEnglish: null,
titleNative: null,
metadataJson: null,
});
assert.equal(fromSourceTitle, 1);
assert.equal(fromTransferredAlias, 1);
assert.deepEqual(animeIds(db), [1]);
assert.equal(
(
db.prepare('SELECT canonical_title AS title FROM imm_anime WHERE anime_id = 1').get() as {
title: string;
}
).title,
'Show',
);
});
});
test('mergeAnimeRecords ignores unknown targets and self-merges', () => {
withDb((db) => {
insertAnime(db, { animeId: 1, key: 'show', title: 'Show' });
insertEpisode(db, { videoId: 1, animeId: 1 });
assert.deepEqual(mergeAnimeRecords(db, 99, [1]).mergedAnimeIds, []);
assert.deepEqual(mergeAnimeRecords(db, 1, [1]).mergedAnimeIds, []);
assert.deepEqual(animeIds(db), [1]);
assert.equal(videoAnimeId(db, 1), 1);
});
});
test('moveVideoToAnime moves one episode and prunes the emptied entry', () => {
withDb((db) => {
insertAnime(db, { animeId: 1, key: 'show', title: 'Show' });
insertAnime(db, { animeId: 2, key: 'stray', title: 'Stray Episode Title', anilistId: 777 });
insertEpisode(db, { videoId: 1, animeId: 1 });
insertEpisode(db, { videoId: 2, animeId: 2, activeMs: 5000, cards: 2 });
const summary = moveVideoToAnime(db, 2, 1);
assert.equal(summary.targetAnimeId, 1);
assert.equal(summary.previousAnimeId, 2);
assert.equal(summary.removedPreviousAnime, true);
assert.deepEqual(animeIds(db), [1]);
assert.equal(videoAnimeId(db, 2), 1);
assert.equal(assignmentLocked(db, 2), 1);
assert.equal(getManualAnimeAssignment(db, 2), 1);
assert.equal(lineAnimeIds(db, 1), 2);
const lifetime = db
.prepare('SELECT total_active_ms AS activeMs FROM imm_lifetime_anime WHERE anime_id = 1')
.get() as { activeMs: number };
assert.equal(lifetime.activeMs, 6000);
// The stray entry's AniList link is dropped, not inherited: a move makes no
// claim that the two entries are the same show.
const target = db
.prepare('SELECT anilist_id AS anilistId FROM imm_anime WHERE anime_id = 1')
.get() as { anilistId: number | null };
assert.equal(target.anilistId, null);
});
});
test('moveVideoToAnime is a no-op when the episode is already in the target entry', () => {
withDb((db) => {
insertAnime(db, { animeId: 1, key: 'show', title: 'Show' });
insertEpisode(db, { videoId: 1, animeId: 1 });
const summary = moveVideoToAnime(db, 1, 1);
assert.equal(summary.targetAnimeId, 1);
assert.equal(summary.previousAnimeId, 1);
assert.equal(summary.removedPreviousAnime, false);
assert.deepEqual(animeIds(db), [1]);
assert.equal(videoAnimeId(db, 1), 1);
assert.equal(assignmentLocked(db, 1), 1);
});
});
test('automatic metadata cannot overwrite a manual episode assignment', () => {
withDb((db) => {
insertAnime(db, { animeId: 1, key: 'show', title: 'Show' });
insertAnime(db, { animeId: 2, key: 'stray', title: 'Stray' });
insertAnime(db, { animeId: 3, key: 'parser result', title: 'Parser Result' });
insertEpisode(db, { videoId: 1, animeId: 2, season: 1 });
moveVideoToAnime(db, 1, 1);
linkVideoToAnimeRecord(db, 1, {
animeId: 3,
parsedBasename: 'Parser Result S01E01.mkv',
parsedTitle: 'Parser Result',
parsedSeason: 1,
parsedEpisode: 1,
parserSource: 'guessit',
parserConfidence: 1,
parseMetadataJson: null,
});
assert.equal(videoAnimeId(db, 1), 1);
assert.equal(getManualAnimeAssignment(db, 1), 1);
const parsedTitle = db
.prepare('SELECT parsed_title AS parsedTitle FROM imm_videos WHERE video_id = 1')
.get() as { parsedTitle: string | null };
assert.equal(parsedTitle.parsedTitle, 'Parser Result');
});
});
test('directory grouping requires one season-compatible manual destination', () => {
withDb((db) => {
insertAnime(db, { animeId: 1, key: 'show', title: 'Show' });
insertAnime(db, { animeId: 2, key: 'stray', title: 'Stray' });
insertAnime(db, { animeId: 3, key: 'other', title: 'Other' });
insertEpisode(db, { videoId: 1, animeId: 2, season: 1 });
insertEpisode(db, { videoId: 2, animeId: 3, season: 1 });
insertEpisode(db, { videoId: 3, animeId: 3, season: 1 });
db.prepare('UPDATE imm_videos SET source_path = ? WHERE video_id = ?').run(
'/library/show/Show S01E01.mkv',
1,
);
db.prepare('UPDATE imm_videos SET source_path = ? WHERE video_id = ?').run(
'/library/show/Stray S01E02.mkv',
2,
);
db.prepare('UPDATE imm_videos SET source_path = ? WHERE video_id = ?').run(
'/library/show/Other S01E03.mkv',
3,
);
moveVideoToAnime(db, 1, 1);
// 'Stray' has no library identity of its own once corrected, so it may
// inherit the neighbor's correction.
assert.equal(
findManualDirectoryAnimeAssignment(db, 2, '/library/show/Stray S01E02.mkv', 'Stray E02', 1),
1,
);
assert.equal(
findManualDirectoryAnimeAssignment(db, 2, '/library/show/Stray S02E02.mkv', 'Stray E02', 2),
null,
);
assert.equal(
findManualDirectoryAnimeAssignment(db, 2, '/library/other/Stray S01E02.mkv', 'Stray E02', 1),
null,
);
// A clean parse of a show that already owns a library entry keeps that
// identity instead of being captured by the neighbor's correction.
assert.equal(
findManualDirectoryAnimeAssignment(db, 2, '/library/show/Other E01.mkv', 'Other', null),
null,
);
moveVideoToAnime(db, 3, 3);
assert.equal(
findManualDirectoryAnimeAssignment(db, 2, '/library/show/Stray S01E02.mkv', 'Stray E02', 1),
null,
);
});
});
test('moveVideoToAnime keeps the source entry when other episodes remain', () => {
withDb((db) => {
insertAnime(db, { animeId: 1, key: 'show', title: 'Show' });
insertAnime(db, { animeId: 2, key: 'other', title: 'Other' });
insertEpisode(db, { videoId: 1, animeId: 2 });
insertEpisode(db, { videoId: 2, animeId: 2 });
const summary = moveVideoToAnime(db, 2, 1);
assert.equal(summary.removedPreviousAnime, false);
assert.deepEqual(animeIds(db), [1, 2]);
assert.equal(videoAnimeId(db, 1), 2);
assert.equal(videoAnimeId(db, 2), 1);
});
});
test('moveVideoToAnime rejects unknown episodes and targets', () => {
withDb((db) => {
insertAnime(db, { animeId: 1, key: 'show', title: 'Show' });
insertEpisode(db, { videoId: 1, animeId: 1 });
assert.throws(() => moveVideoToAnime(db, 99, 1));
assert.throws(() => moveVideoToAnime(db, 1, 99));
assert.equal(videoAnimeId(db, 1), 1);
});
});
test('resolveAnimeAnilistConflict folds a seasonless duplicate into the entry that owns the id', () => {
withDb((db) => {
// Same show, split because one release tagged S01 and the other did not.
insertAnime(db, { animeId: 1, key: 'show', title: 'Show', anilistId: 163132 });
insertAnime(db, { animeId: 2, key: 'show season 1', title: 'Show Season 1' });
insertEpisode(db, { videoId: 1, animeId: 1 });
insertEpisode(db, { videoId: 2, animeId: 2, season: 1 });
const summary = resolveAnimeAnilistConflict(db, 2, 163132);
assert.equal(summary.survivingAnimeId, 1);
assert.equal(summary.movedVideos, 1);
assert.equal(summary.deletedAnimeRows, 1);
assert.deepEqual(animeIds(db), [1]);
assert.equal(videoAnimeId(db, 2), 1);
});
});
test('resolveAnimeAnilistConflict recommends a weak title collision instead of merging it', () => {
withDb((db) => {
insertAnime(db, {
animeId: 1,
key: 'actual show',
title: 'Actual Show',
anilistId: 163132,
titleRomaji: 'Actual Show',
});
insertAnime(db, { animeId: 2, key: 'unrelated release', title: 'Unrelated Release' });
insertEpisode(db, { videoId: 1, animeId: 1, season: 1 });
insertEpisode(db, { videoId: 2, animeId: 2, season: 1 });
const summary = resolveAnimeAnilistConflict(db, 2, 163132);
assert.equal(summary.repaired, 0);
assert.deepEqual(animeIds(db), [1, 2]);
assert.equal(videoAnimeId(db, 2), 2);
assert.deepEqual(getAnimeMergeRecommendations(db), [{ recommendationId: 1, animeIds: [1, 2] }]);
});
});
test('automatic AniList update leaves a weak collision unassigned for user review', () => {
withDb((db) => {
insertAnime(db, {
animeId: 1,
key: 'actual show',
title: 'Actual Show',
anilistId: 163132,
titleRomaji: 'Actual Show',
});
insertAnime(db, { animeId: 2, key: 'unrelated release', title: 'Unrelated Release' });
insertEpisode(db, { videoId: 1, animeId: 1, season: 1 });
insertEpisode(db, { videoId: 2, animeId: 2, season: 1 });
updateAnimeAnilistInfo(db, 2, {
anilistId: 163132,
titleRomaji: 'Actual Show',
titleEnglish: null,
titleNative: null,
episodesTotal: 12,
exactTitleMatch: false,
});
const target = db
.prepare('SELECT anilist_id AS anilistId FROM imm_anime WHERE anime_id = 2')
.get() as {
anilistId: number | null;
};
assert.equal(target.anilistId, null);
assert.deepEqual(getAnimeMergeRecommendations(db), [{ recommendationId: 1, animeIds: [1, 2] }]);
});
});
test('dismissed weak collision stays dismissed when automatic resolution repeats', () => {
withDb((db) => {
insertAnime(db, {
animeId: 1,
key: 'actual show',
title: 'Actual Show',
anilistId: 163132,
titleRomaji: 'Actual Show',
});
insertAnime(db, { animeId: 2, key: 'unrelated release', title: 'Unrelated Release' });
insertEpisode(db, { videoId: 1, animeId: 1, season: 1 });
insertEpisode(db, { videoId: 2, animeId: 2, season: 1 });
resolveAnimeAnilistConflict(db, 2, 163132);
assert.equal(dismissAnimeMergeRecommendation(db, 1), true);
resolveAnimeAnilistConflict(db, 2, 163132);
assert.deepEqual(getAnimeMergeRecommendations(db), []);
});
});
test('dismissed recommendation prevents a later exact automatic merge of the pair', () => {
withDb((db) => {
insertAnime(db, {
animeId: 1,
key: 'actual show',
title: 'Actual Show',
anilistId: 163132,
titleRomaji: 'Actual Show',
});
insertAnime(db, { animeId: 2, key: 'unrelated release', title: 'Unrelated Release' });
insertEpisode(db, { videoId: 1, animeId: 1, season: 1 });
insertEpisode(db, { videoId: 2, animeId: 2, season: 1 });
resolveAnimeAnilistConflict(db, 2, 163132, { matchConfidence: 'weak' });
assert.equal(dismissAnimeMergeRecommendation(db, 1), true);
const summary = resolveAnimeAnilistConflict(db, 2, 163132, { matchConfidence: 'exact' });
assert.equal(summary.repaired, 0);
assert.deepEqual(animeIds(db), [1, 2]);
assert.equal(videoAnimeId(db, 2), 2);
assert.deepEqual(getAnimeMergeRecommendations(db), []);
});
});
test('manual merge clears recommendations involving the absorbed entry', () => {
withDb((db) => {
insertAnime(db, {
animeId: 1,
key: 'actual show',
title: 'Actual Show',
anilistId: 163132,
titleRomaji: 'Actual Show',
});
insertAnime(db, { animeId: 2, key: 'unrelated release', title: 'Unrelated Release' });
insertEpisode(db, { videoId: 1, animeId: 1, season: 1 });
insertEpisode(db, { videoId: 2, animeId: 2, season: 1 });
resolveAnimeAnilistConflict(db, 2, 163132);
mergeAnimeRecords(db, 1, [2]);
assert.deepEqual(getAnimeMergeRecommendations(db), []);
});
});
test('resolveAnimeAnilistConflict keeps the target entry when the user drove the change', () => {
withDb((db) => {
insertAnime(db, { animeId: 1, key: 'show', title: 'Show', anilistId: 163132 });
insertAnime(db, { animeId: 2, key: 'show season 1', title: 'Show Season 1' });
insertEpisode(db, { videoId: 1, animeId: 1 });
insertEpisode(db, { videoId: 2, animeId: 2, season: 1 });
const summary = resolveAnimeAnilistConflict(db, 2, 163132, { survivor: 'target' });
assert.equal(summary.survivingAnimeId, 2);
assert.deepEqual(animeIds(db), [2]);
assert.equal(videoAnimeId(db, 1), 2);
const row = db.prepare('SELECT anilist_id AS id FROM imm_anime WHERE anime_id = 2').get() as {
id: number | null;
};
assert.equal(row.id, 163132);
});
});
test('resolveAnimeAnilistConflict falls back to season redistribution for multi-season rows', () => {
withDb((db) => {
insertAnime(db, { animeId: 1, key: 'show', title: 'Show', anilistId: 163132 });
insertAnime(db, { animeId: 2, key: 'show season 1', title: 'Show Season 1' });
insertEpisode(db, { videoId: 1, animeId: 1, season: 1 });
insertEpisode(db, { videoId: 2, animeId: 1, season: 2 });
insertEpisode(db, { videoId: 3, animeId: 2, season: 1 });
resolveAnimeAnilistConflict(db, 2, 163132);
// The mixed row is split by season instead of being poured onto one card.
const titles = (
db.prepare('SELECT canonical_title AS title FROM imm_anime ORDER BY title').all() as Array<{
title: string;
}>
).map((row) => row.title);
assert.deepEqual(titles, ['Show Season 1', 'Show Season 2']);
assert.equal(videoAnimeId(db, 1), 2);
assert.equal(videoAnimeId(db, 3), 2);
assert.notEqual(videoAnimeId(db, 2), 2);
});
});
test('weak collision with a multi-season entry blocks the assignment instead of redistributing', () => {
for (const options of [{ matchConfidence: 'weak' as const }, {}]) {
withDb((db) => {
// Titles deliberately share nothing with the fixture's parsed title
// ('Show'), so the undefined-confidence pass has no stored-title match.
insertAnime(db, { animeId: 1, key: 'actual show', title: 'Actual Show', anilistId: 163132 });
insertAnime(db, { animeId: 2, key: 'unrelated release', title: 'Unrelated Release' });
insertEpisode(db, { videoId: 1, animeId: 1, season: 1 });
insertEpisode(db, { videoId: 2, animeId: 1, season: 2 });
insertEpisode(db, { videoId: 3, animeId: 2, season: 1 });
const summary = resolveAnimeAnilistConflict(db, 2, 163132, options);
// The legitimate multi-season owner keeps its id and both seasons; the
// weakly matched card gets nothing rather than the owner's identity.
assert.equal(summary.anilistAssignmentBlocked, true);
assert.equal(summary.repaired, 0);
assert.deepEqual(animeIds(db), [1, 2]);
assert.equal(videoAnimeId(db, 1), 1);
assert.equal(videoAnimeId(db, 2), 1);
assert.equal(videoAnimeId(db, 3), 2);
const anilistIds = db
.prepare(
'SELECT anime_id AS animeId, anilist_id AS anilistId FROM imm_anime ORDER BY anime_id',
)
.all() as Array<{ animeId: number; anilistId: number | null }>;
assert.deepEqual(anilistIds, [
{ animeId: 1, anilistId: 163132 },
{ animeId: 2, anilistId: null },
]);
assert.deepEqual(getAnimeMergeRecommendations(db), []);
});
}
});
test('season redistribution leaves manually assigned episodes in place', () => {
withDb((db) => {
insertAnime(db, { animeId: 1, key: 'show', title: 'Show', anilistId: 163132 });
insertAnime(db, { animeId: 2, key: 'show season 1', title: 'Show Season 1' });
insertEpisode(db, { videoId: 1, animeId: 1, season: 1 });
insertEpisode(db, { videoId: 2, animeId: 1, season: 2 });
insertEpisode(db, { videoId: 3, animeId: 2, season: 1 });
moveVideoToAnime(db, 1, 1);
const summary = resolveAnimeAnilistConflict(db, 2, 163132);
assert.equal(videoAnimeId(db, 1), 1);
assert.equal(assignmentLocked(db, 1), 1);
assert.notEqual(videoAnimeId(db, 2), 1);
assert.equal(summary.movedVideos, 1);
});
});
test('resolveAnimeAnilistConflict leaves explicit incompatible seasons and assignments unchanged', () => {
withDb((db) => {
insertAnime(db, {
animeId: 1,
key: 'show season 1',
title: 'Show Season 1',
anilistId: 163132,
titleRomaji: 'Show',
});
insertAnime(db, { animeId: 2, key: 'show season 2', title: 'Show Season 2' });
insertEpisode(db, { videoId: 1, animeId: 1, season: 1 });
insertEpisode(db, { videoId: 2, animeId: 2, season: 2 });
const summary = resolveAnimeAnilistConflict(db, 2, 163132, { matchConfidence: 'exact' });
assert.equal(summary.repaired, 0);
assert.equal(summary.movedVideos, 0);
assert.equal(summary.deletedAnimeRows, 0);
assert.deepEqual(animeIds(db), [1, 2]);
assert.equal(videoAnimeId(db, 1), 1);
assert.equal(videoAnimeId(db, 2), 2);
const assignments = db
.prepare(
'SELECT anime_id AS animeId, anilist_id AS anilistId FROM imm_anime ORDER BY anime_id',
)
.all() as Array<{ animeId: number; anilistId: number | null }>;
assert.deepEqual(assignments, [
{ animeId: 1, anilistId: 163132 },
{ animeId: 2, anilistId: null },
]);
assert.deepEqual(getAnimeMergeRecommendations(db), []);
});
});
test('manual AniList resolution reassigns across explicit seasons without merging them', () => {
withDb((db) => {
insertAnime(db, {
animeId: 1,
key: 'show season 1',
title: 'Show Season 1',
anilistId: 163132,
titleRomaji: 'Show',
});
insertAnime(db, { animeId: 2, key: 'show season 2', title: 'Show Season 2' });
insertEpisode(db, { videoId: 1, animeId: 1, season: 1 });
insertEpisode(db, { videoId: 2, animeId: 2, season: 2 });
const summary = resolveAnimeAnilistConflict(db, 2, 163132, { survivor: 'target' });
assert.equal(summary.anilistAssignmentBlocked, false);
assert.deepEqual(animeIds(db), [1, 2]);
const assignments = db
.prepare(
'SELECT anime_id AS animeId, anilist_id AS anilistId FROM imm_anime ORDER BY anime_id',
)
.all() as Array<{ animeId: number; anilistId: number | null }>;
assert.deepEqual(assignments, [
{ animeId: 1, anilistId: null },
{ animeId: 2, anilistId: 163132 },
]);
});
});
test('automatic AniList update does not transfer an assignment across explicit seasons', () => {
withDb((db) => {
insertAnime(db, {
animeId: 1,
key: 'show season 1',
title: 'Show Season 1',
anilistId: 163132,
titleRomaji: 'Show',
});
insertAnime(db, { animeId: 2, key: 'show season 2', title: 'Show Season 2' });
insertEpisode(db, { videoId: 1, animeId: 1, season: 1 });
insertEpisode(db, { videoId: 2, animeId: 2, season: 2 });
updateAnimeAnilistInfo(db, 2, {
anilistId: 163132,
titleRomaji: 'Show',
titleEnglish: null,
titleNative: null,
episodesTotal: 12,
exactTitleMatch: true,
});
const assignments = db
.prepare(
'SELECT anime_id AS animeId, anilist_id AS anilistId FROM imm_anime ORDER BY anime_id',
)
.all() as Array<{ animeId: number; anilistId: number | null }>;
assert.deepEqual(assignments, [
{ animeId: 1, anilistId: 163132 },
{ animeId: 2, anilistId: null },
]);
assert.equal(videoAnimeId(db, 1), 1);
assert.equal(videoAnimeId(db, 2), 2);
});
});
test('automatic AniList update with unknown match confidence validates stored titles', () => {
withDb((db) => {
insertAnime(db, {
animeId: 1,
key: 'actual show',
title: 'Actual Show',
anilistId: 163132,
titleRomaji: 'Actual Show',
});
insertAnime(db, { animeId: 2, key: 'unrelated release', title: 'Unrelated Release' });
insertEpisode(db, { videoId: 1, animeId: 1, season: 1 });
insertEpisode(db, { videoId: 2, animeId: 2, season: 1 });
updateAnimeAnilistInfo(db, 2, {
anilistId: 163132,
titleRomaji: 'Actual Show',
titleEnglish: null,
titleNative: null,
episodesTotal: 12,
});
assert.deepEqual(animeIds(db), [1, 2]);
assert.equal(videoAnimeId(db, 2), 2);
assert.deepEqual(getAnimeMergeRecommendations(db), [{ recommendationId: 1, animeIds: [1, 2] }]);
});
});
test('stored AniList titles ignore season suffixes when validating an automatic merge', () => {
withDb((db) => {
insertAnime(db, {
animeId: 1,
key: 'legacy show',
title: 'Show Season 1',
anilistId: 163132,
titleRomaji: 'Show Season 1',
});
insertAnime(db, { animeId: 2, key: 'show season 1', title: 'Show Season 1' });
insertEpisode(db, { videoId: 1, animeId: 1, season: 1 });
insertEpisode(db, { videoId: 2, animeId: 2, season: 1 });
const summary = resolveAnimeAnilistConflict(db, 2, 163132);
assert.equal(summary.deletedAnimeRows, 1);
assert.deepEqual(animeIds(db), [1]);
assert.equal(videoAnimeId(db, 2), 1);
assert.deepEqual(getAnimeMergeRecommendations(db), []);
});
});
test('resolveAnimeAnilistConflict leaves an entry that already links elsewhere alone', () => {
withDb((db) => {
insertAnime(db, { animeId: 1, key: 'show', title: 'Show', anilistId: 163132 });
insertAnime(db, { animeId: 2, key: 'show s2', title: 'Show Season 2', anilistId: 999 });
insertEpisode(db, { videoId: 1, animeId: 1, season: 1 });
insertEpisode(db, { videoId: 2, animeId: 2, season: 2 });
const summary = resolveAnimeAnilistConflict(db, 2, 163132);
assert.equal(videoAnimeId(db, 2), 2);
assert.ok(animeIds(db).includes(2));
assert.equal(
(
db.prepare('SELECT anilist_id AS anilistId FROM imm_anime WHERE anime_id = 2').get() as {
anilistId: number;
}
).anilistId,
999,
);
assert.equal(summary.repaired, 0);
assert.equal(summary.movedVideos, 0);
assert.deepEqual(getAnimeMergeRecommendations(db), []);
});
});
test('automatic AniList update onto an entry that already links elsewhere does not throw', () => {
withDb((db) => {
insertAnime(db, { animeId: 1, key: 'show', title: 'Show', anilistId: 163132 });
insertAnime(db, { animeId: 2, key: 'show s2', title: 'Show Season 2', anilistId: 999 });
insertEpisode(db, { videoId: 1, animeId: 1, season: 1 });
insertEpisode(db, { videoId: 2, animeId: 2, season: 2 });
// Entry 2 explicitly links to 999; a later video re-resolving to entry 1's
// id must be refused, not written over the UNIQUE anilist_id column.
updateAnimeAnilistInfo(db, 2, {
anilistId: 163132,
titleRomaji: 'Show',
titleEnglish: null,
titleNative: null,
episodesTotal: 12,
exactTitleMatch: true,
});
assert.deepEqual(animeIds(db), [1, 2]);
assert.equal(
(
db.prepare('SELECT anilist_id AS anilistId FROM imm_anime WHERE anime_id = 2').get() as {
anilistId: number;
}
).anilistId,
999,
);
});
});
@@ -0,0 +1,169 @@
import type { DatabaseSync } from './sqlite';
import { animeSeasonsAreMergeCompatible, getParsedSeasonsForAnime } from './anime-merge';
import { toDbTimestamp } from './query-shared';
import { normalizeAnimeIdentityKey } from './storage';
import { nowMs } from './time';
export interface AnimeMergeRecommendation {
recommendationId: number;
animeIds: [number, number];
}
export interface AnimeConflictRecommendationOptions {
survivor?: 'target' | 'existing';
/** Automatic matches must be exact; manual assignment is authoritative. */
matchConfidence?: 'exact' | 'weak' | 'manual';
}
interface AnimeTitleRow {
canonical_title: string;
title_romaji: string | null;
title_english: string | null;
title_native: string | null;
}
function getAnimeTitles(db: DatabaseSync, animeId: number): AnimeTitleRow | null {
return db
.prepare(
`SELECT canonical_title, title_romaji, title_english, title_native
FROM imm_anime
WHERE anime_id = ?`,
)
.get(animeId) as AnimeTitleRow | null;
}
function getParsedTitles(db: DatabaseSync, animeId: number): Array<string | null> {
return (
db.prepare('SELECT parsed_title FROM imm_videos WHERE anime_id = ?').all(animeId) as Array<{
parsed_title: string | null;
}>
).map((row) => row.parsed_title);
}
function stripSeasonIdentitySuffix(title: string): string {
return title
.replace(/\bseason\s*\d{1,2}\b/gi, ' ')
.replace(/\b\d{1,2}(?:st|nd|rd|th)\s+season\b/gi, ' ')
.replace(/\bs\d{1,2}\b/gi, ' ');
}
export function hasExactStoredTitleMatch(
db: DatabaseSync,
targetAnimeId: number,
conflictAnimeId: number,
): boolean {
const target = getAnimeTitles(db, targetAnimeId);
const conflict = getAnimeTitles(db, conflictAnimeId);
if (!target || !conflict) return false;
const targetKeys = [target.canonical_title, ...getParsedTitles(db, targetAnimeId)]
.filter((title): title is string => Boolean(title?.trim()))
.map((title) => normalizeAnimeIdentityKey(stripSeasonIdentitySuffix(title)))
.filter(Boolean);
const anilistTitleKeys = [
conflict.title_romaji,
conflict.title_english,
conflict.title_native,
conflict.canonical_title,
]
.filter((title): title is string => Boolean(title?.trim()))
.map((title) => normalizeAnimeIdentityKey(stripSeasonIdentitySuffix(title)))
.filter(Boolean);
return targetKeys.some((key) => anilistTitleKeys.includes(key));
}
export function shouldRecommendAnilistConflict(
db: DatabaseSync,
targetAnimeId: number,
conflictAnimeId: number,
options: AnimeConflictRecommendationOptions,
): boolean {
if (options.survivor === 'target' || options.matchConfidence === 'manual') return false;
if (
!animeSeasonsAreMergeCompatible(
getParsedSeasonsForAnime(db, targetAnimeId),
getParsedSeasonsForAnime(db, conflictAnimeId),
)
) {
return false;
}
return (
options.matchConfidence === 'weak' ||
(options.matchConfidence === undefined &&
!hasExactStoredTitleMatch(db, targetAnimeId, conflictAnimeId))
);
}
export function recordAnimeMergeRecommendation(
db: DatabaseSync,
firstCandidateAnimeId: number,
secondCandidateAnimeId: number,
anilistId: number,
): void {
const firstAnimeId = Math.min(firstCandidateAnimeId, secondCandidateAnimeId);
const secondAnimeId = Math.max(firstCandidateAnimeId, secondCandidateAnimeId);
const timestamp = toDbTimestamp(nowMs());
db.prepare(
`INSERT INTO imm_anime_merge_recommendations(
first_anime_id, second_anime_id, anilist_id, status, CREATED_DATE, LAST_UPDATE_DATE
) VALUES (?, ?, ?, 'pending', ?, ?)
ON CONFLICT(first_anime_id, second_anime_id, anilist_id) DO UPDATE SET
LAST_UPDATE_DATE = excluded.LAST_UPDATE_DATE`,
).run(firstAnimeId, secondAnimeId, anilistId, timestamp, timestamp);
}
export function hasDismissedAnimeMergeRecommendation(
db: DatabaseSync,
firstCandidateAnimeId: number,
secondCandidateAnimeId: number,
): boolean {
const firstAnimeId = Math.min(firstCandidateAnimeId, secondCandidateAnimeId);
const secondAnimeId = Math.max(firstCandidateAnimeId, secondCandidateAnimeId);
return Boolean(
db
.prepare(
`SELECT 1
FROM imm_anime_merge_recommendations
WHERE first_anime_id = ?
AND second_anime_id = ?
AND status = 'dismissed'
LIMIT 1`,
)
.get(firstAnimeId, secondAnimeId),
);
}
export function getAnimeMergeRecommendations(db: DatabaseSync): AnimeMergeRecommendation[] {
return (
db
.prepare(
`SELECT recommendation_id AS recommendationId,
first_anime_id AS firstAnimeId,
second_anime_id AS secondAnimeId
FROM imm_anime_merge_recommendations
WHERE status = 'pending'
ORDER BY recommendation_id ASC`,
)
.all() as Array<{
recommendationId: number;
firstAnimeId: number;
secondAnimeId: number;
}>
).map((row) => ({
recommendationId: row.recommendationId,
animeIds: [row.firstAnimeId, row.secondAnimeId],
}));
}
export function dismissAnimeMergeRecommendation(
db: DatabaseSync,
recommendationId: number,
): boolean {
const result = db
.prepare(
`UPDATE imm_anime_merge_recommendations
SET status = 'dismissed', LAST_UPDATE_DATE = ?
WHERE recommendation_id = ? AND status = 'pending'`,
)
.run(toDbTimestamp(nowMs()), recommendationId) as { changes: number };
return result.changes > 0;
}
@@ -0,0 +1,296 @@
import type { DatabaseSync } from './sqlite';
import { recomputeLifetimeAnimeAggregatesInTransaction } from './lifetime';
import { toDbTimestamp } from './query-shared';
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';
export interface AnimeMergeSummary {
/** Library entry that owns every moved episode once the merge finishes. */
survivingAnimeId: number;
/** Entries that were folded into the survivor and deleted. */
mergedAnimeIds: number[];
movedVideos: number;
}
export interface VideoMoveSummary {
targetAnimeId: number;
/** Previous owner, or null when the episode had no library entry yet. */
previousAnimeId: number | null;
/** True when the previous owner was left empty and pruned. */
removedPreviousAnime: boolean;
}
interface AnimeMetadataRow {
normalized_title_key: string;
anilist_id: number | null;
title_romaji: string | null;
title_english: string | null;
title_native: string | null;
episodes_total: number | null;
description: string | null;
}
function emptyMergeSummary(survivingAnimeId: number): AnimeMergeSummary {
return { survivingAnimeId, mergedAnimeIds: [], movedVideos: 0 };
}
function runInTransaction<T>(db: DatabaseSync, work: () => T): T {
db.exec('BEGIN IMMEDIATE');
try {
const result = work();
db.exec('COMMIT');
return result;
} catch (error) {
db.exec('ROLLBACK');
throw error;
}
}
function readAnimeMetadata(db: DatabaseSync, animeId: number): AnimeMetadataRow | null {
return (db
.prepare(
`
SELECT normalized_title_key, anilist_id, title_romaji, title_english, title_native, episodes_total, description
FROM imm_anime
WHERE anime_id = ?
`,
)
.get(animeId) ?? null) as AnimeMetadataRow | null;
}
function animeExists(db: DatabaseSync, animeId: number): boolean {
return Boolean(db.prepare('SELECT 1 FROM imm_anime WHERE anime_id = ?').get(animeId));
}
function hasAnimeReferences(db: DatabaseSync, animeId: number): boolean {
const row = db
.prepare(
`
SELECT 1 AS found
WHERE EXISTS (SELECT 1 FROM imm_videos WHERE anime_id = ?)
OR EXISTS (SELECT 1 FROM imm_subtitle_lines WHERE anime_id = ?)
`,
)
.get(animeId, animeId) as { found: number } | null;
return Boolean(row);
}
/**
* Distinct explicit seasons behind a library entry. Videos with no parsed
* season are ignored, so an entry built from `Show - 03.mkv` style filenames
* reports an empty set rather than a bogus season.
*/
export function getParsedSeasonsForAnime(db: DatabaseSync, animeId: number): Set<number> {
const rows = db
.prepare(
`
SELECT DISTINCT parsed_season AS season
FROM imm_videos
WHERE anime_id = ?
AND parsed_season IS NOT NULL
AND parsed_season > 0
`,
)
.all(animeId) as Array<{ season: number }>;
return new Set(rows.map((row) => row.season));
}
/**
* Two entries are safe to fold together when neither spans more than one
* explicit season and they do not disagree about which season that is. A
* seasonless entry is compatible with anything single-season: those are the
* `Show - 03.mkv` vs `Show.S01E03.mkv` splits that produce duplicate cards.
*/
export function animeSeasonsAreMergeCompatible(a: Set<number>, b: Set<number>): boolean {
if (a.size > 1 || b.size > 1) return false;
if (a.size === 0 || b.size === 0) return true;
return [...a][0] === [...b][0];
}
/**
* Fill in whatever the target is missing from a source row that is on its way
* out. Must run after the source row is deleted: imm_anime.anilist_id is
* UNIQUE, so the two rows cannot hold the same id at once.
*/
function absorbAnimeMetadata(
db: DatabaseSync,
targetAnimeId: number,
source: AnimeMetadataRow | null,
updatedAt: string,
): void {
if (!source) return;
db.prepare(
`
UPDATE imm_anime
SET
anilist_id = COALESCE(anilist_id, ?),
title_romaji = COALESCE(title_romaji, ?),
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(
source.anilist_id,
source.title_romaji,
source.title_english,
source.title_native,
source.episodes_total,
source.description,
updatedAt,
targetAnimeId,
);
}
/**
* Fold `sourceAnimeIds` into `targetAnimeId`: every episode and subtitle line
* is repointed, metadata the target is missing is inherited from the sources,
* and the emptied source rows are deleted.
*
* Assumes the caller already holds a write transaction and refreshes the
* per-anime lifetime aggregates afterwards; use {@link mergeAnimeRecords}
* otherwise.
*/
export function mergeAnimeRecordsInTransaction(
db: DatabaseSync,
targetAnimeId: number,
sourceAnimeIds: number[],
): AnimeMergeSummary {
const summary = emptyMergeSummary(targetAnimeId);
if (!animeExists(db, targetAnimeId)) {
return summary;
}
const updatedAt = toDbTimestamp(nowMs());
const sourceVideosStmt = db.prepare(
'SELECT video_id AS videoId FROM imm_videos WHERE anime_id = ?',
);
const moveVideosStmt = db.prepare(
'UPDATE imm_videos SET anime_id = ?, LAST_UPDATE_DATE = ? WHERE anime_id = ?',
);
// Repointed per video rather than by anime_id: lines recorded before the
// async title parse assigns the link are stored with a NULL anime_id, and
// matching on the source id would strand them unattributed.
const moveLinesStmt = db.prepare(
'UPDATE imm_subtitle_lines SET anime_id = ?, LAST_UPDATE_DATE = ? WHERE video_id = ?',
);
const dropLifetimeStmt = db.prepare('DELETE FROM imm_lifetime_anime WHERE anime_id = ?');
const sourceAliasesStmt = db.prepare(
'SELECT normalized_title_key AS normalizedTitleKey FROM imm_anime_title_aliases WHERE anime_id = ?',
);
const upsertAliasStmt = db.prepare(
`INSERT INTO imm_anime_title_aliases(normalized_title_key, anime_id, CREATED_DATE, LAST_UPDATE_DATE)
VALUES (?, ?, ?, ?)
ON CONFLICT(normalized_title_key) DO UPDATE SET
anime_id = excluded.anime_id,
LAST_UPDATE_DATE = excluded.LAST_UPDATE_DATE`,
);
const dropSourceAliasesStmt = db.prepare(
'DELETE FROM imm_anime_title_aliases WHERE anime_id = ?',
);
const dropAnimeStmt = db.prepare('DELETE FROM imm_anime WHERE anime_id = ?');
for (const sourceAnimeId of new Set(sourceAnimeIds)) {
if (sourceAnimeId === targetAnimeId || !animeExists(db, sourceAnimeId)) {
continue;
}
const sourceMetadata = readAnimeMetadata(db, sourceAnimeId);
const sourceAliases = sourceAliasesStmt.all(sourceAnimeId) as Array<{
normalizedTitleKey: string;
}>;
const sourceVideoIds = (sourceVideosStmt.all(sourceAnimeId) as Array<{ videoId: number }>).map(
(row) => row.videoId,
);
const moved = moveVideosStmt.run(targetAnimeId, updatedAt, sourceAnimeId) as {
changes: number;
};
for (const videoId of sourceVideoIds) {
moveLinesStmt.run(targetAnimeId, updatedAt, videoId);
}
dropSourceAliasesStmt.run(sourceAnimeId);
for (const alias of [
...(sourceMetadata ? [sourceMetadata.normalized_title_key] : []),
...sourceAliases.map((row) => row.normalizedTitleKey),
]) {
upsertAliasStmt.run(alias, targetAnimeId, updatedAt, updatedAt);
}
dropLifetimeStmt.run(sourceAnimeId);
dropAnimeStmt.run(sourceAnimeId);
absorbAnimeMetadata(db, targetAnimeId, sourceMetadata, updatedAt);
summary.mergedAnimeIds.push(sourceAnimeId);
summary.movedVideos += moved.changes;
}
return summary;
}
export function mergeAnimeRecords(
db: DatabaseSync,
targetAnimeId: number,
sourceAnimeIds: number[],
): AnimeMergeSummary {
return runInTransaction(db, () => {
const summary = mergeAnimeRecordsInTransaction(db, targetAnimeId, sourceAnimeIds);
if (summary.mergedAnimeIds.length > 0) {
recomputeLifetimeAnimeAggregatesInTransaction(db);
}
return summary;
});
}
/**
* Move a single episode to another library entry, pruning the previous owner
* when it is left with nothing.
*/
export function moveVideoToAnime(
db: DatabaseSync,
videoId: number,
targetAnimeId: number,
): VideoMoveSummary {
return runInTransaction(db, () => {
const videoRow = db
.prepare('SELECT anime_id AS animeId FROM imm_videos WHERE video_id = ?')
.get(videoId) as { animeId: number | null } | null;
if (!videoRow || !animeExists(db, targetAnimeId)) {
throw new Error(UNKNOWN_MOVE_TARGET_MESSAGE);
}
const previousAnimeId = videoRow.animeId;
if (previousAnimeId === targetAnimeId) {
db.prepare(
'UPDATE imm_videos SET anime_assignment_locked = 1, LAST_UPDATE_DATE = ? WHERE video_id = ?',
).run(toDbTimestamp(nowMs()), videoId);
return { targetAnimeId, previousAnimeId, removedPreviousAnime: false };
}
const updatedAt = toDbTimestamp(nowMs());
db.prepare(
`UPDATE imm_videos
SET anime_id = ?, anime_assignment_locked = 1, LAST_UPDATE_DATE = ?
WHERE video_id = ?`,
).run(targetAnimeId, updatedAt, videoId);
db.prepare(
'UPDATE imm_subtitle_lines SET anime_id = ?, LAST_UPDATE_DATE = ? WHERE video_id = ?',
).run(targetAnimeId, updatedAt, videoId);
let removedPreviousAnime = false;
if (previousAnimeId !== null && !hasAnimeReferences(db, previousAnimeId)) {
// The emptied entry's metadata is deliberately dropped rather than
// absorbed. A move says "this episode belongs elsewhere", not "these are
// the same show", and the entry being emptied is usually a mis-parse
// whose AniList link would be wrong for the target.
db.prepare('DELETE FROM imm_lifetime_anime WHERE anime_id = ?').run(previousAnimeId);
db.prepare('DELETE FROM imm_anime WHERE anime_id = ?').run(previousAnimeId);
removedPreviousAnime = true;
}
recomputeLifetimeAnimeAggregatesInTransaction(db);
return { targetAnimeId, previousAnimeId, removedPreviousAnime };
});
}
@@ -1,4 +1,16 @@
import type { DatabaseSync } from './sqlite';
import {
animeSeasonsAreMergeCompatible,
getParsedSeasonsForAnime,
mergeAnimeRecordsInTransaction,
} from './anime-merge';
import {
hasExactStoredTitleMatch,
hasDismissedAnimeMergeRecommendation,
recordAnimeMergeRecommendation,
shouldRecommendAnilistConflict,
type AnimeConflictRecommendationOptions,
} from './anime-merge-recommendations';
import { getOrCreateAnimeRecord } from './storage';
import { toDbTimestamp } from './query-shared';
import { nowMs } from './time';
@@ -8,8 +20,33 @@ export interface AnimeSeasonRepairSummary {
repaired: number;
movedVideos: number;
deletedAnimeRows: number;
/**
* Entry that owns the videos afterwards when two rows were folded together,
* so callers can keep pointing at a row that still exists.
*/
survivingAnimeId: number | null;
/** True when an ambiguous AniList collision was saved for user review. */
mergeRecommended: boolean;
/** True when automatic metadata must not assign the colliding AniList id. */
anilistAssignmentBlocked: boolean;
}
export interface AnimeAnilistConflictOptions extends AnimeConflictRecommendationOptions {
/**
* Which row keeps its identity when two entries claim the same AniList id.
* `existing` (the default) keeps the row that already held the id, so
* automatic cover-art resolution does not rename a card under the user;
* `target` keeps the row the user is acting on.
*/
survivor?: 'target' | 'existing';
}
export {
dismissAnimeMergeRecommendation,
getAnimeMergeRecommendations,
type AnimeMergeRecommendation,
} from './anime-merge-recommendations';
interface AnimeRow {
anime_id: number;
anilist_id: number | null;
@@ -24,6 +61,7 @@ interface ParsedVideoRow {
video_id: number;
parsed_title: string | null;
parsed_season: number | null;
anime_assignment_locked: number;
}
interface RedistributeOptions {
@@ -38,6 +76,9 @@ function emptySummary(scanned = 0): AnimeSeasonRepairSummary {
repaired: 0,
movedVideos: 0,
deletedAnimeRows: 0,
survivingAnimeId: null,
mergeRecommended: false,
anilistAssignmentBlocked: false,
};
}
@@ -49,11 +90,17 @@ function mergeSummary(
target.repaired += source.repaired;
target.movedVideos += source.movedVideos;
target.deletedAnimeRows += source.deletedAnimeRows;
target.survivingAnimeId = source.survivingAnimeId ?? target.survivingAnimeId;
target.mergeRecommended ||= source.mergeRecommended;
target.anilistAssignmentBlocked ||= source.anilistAssignmentBlocked;
return target;
}
function runInTransaction<T>(db: DatabaseSync, work: () => T): T {
db.exec('BEGIN');
// IMMEDIATE: the reads before the first write must hold the write lock, or a
// concurrent writer (app vs stats daemon on the same WAL db) upgrades this
// deferred snapshot into an unretried SQLITE_BUSY_SNAPSHOT mid-repair.
db.exec('BEGIN IMMEDIATE');
try {
const result = work();
db.exec('COMMIT');
@@ -94,7 +141,7 @@ function getParsedVideos(db: DatabaseSync, animeId: number): ParsedVideoRow[] {
return db
.prepare(
`
SELECT video_id, parsed_title, parsed_season
SELECT video_id, parsed_title, parsed_season, anime_assignment_locked
FROM imm_videos
WHERE anime_id = ?
ORDER BY video_id ASC
@@ -188,6 +235,9 @@ function redistributeAnimeRowByParsedSeasonsInTransaction(
const targetBySeason = new Map<number, number>();
for (const video of videos) {
if (video.anime_assignment_locked === 1) {
continue;
}
const parsedTitle = video.parsed_title?.trim();
const season = normalizeSeason(video.parsed_season);
if (!parsedTitle || season === null) {
@@ -301,10 +351,19 @@ export function repairLegacySeasonlessAnimeRows(db: DatabaseSync): AnimeSeasonRe
});
}
/**
* Two library entries cannot both hold the same AniList id
* (`imm_anime.anilist_id` is UNIQUE). Fold an automatic collision only when
* exact title evidence and compatible parsed seasons make it safe. Persist a
* review recommendation for compatible weak matches. Legacy season
* redistribution of a conflicting multi-season row is reserved for exact or
* manual matches; a weak automatic match blocks the assignment instead.
*/
export function resolveAnimeAnilistConflict(
db: DatabaseSync,
targetAnimeId: number,
anilistId: number,
options: AnimeAnilistConflictOptions = {},
): AnimeSeasonRepairSummary {
const conflict = db
.prepare(
@@ -321,10 +380,115 @@ export function resolveAnimeAnilistConflict(
return emptySummary();
}
return runInTransaction(db, () =>
redistributeAnimeRowByParsedSeasonsInTransaction(db, conflict.animeId, {
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;
}
// 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 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,
}),
});
});
}
function canMergeAnilistConflict(
db: DatabaseSync,
targetAnimeId: number,
conflictAnimeId: number,
anilistId: number,
options: AnimeAnilistConflictOptions,
): boolean {
const targetRow = getAnimeRow(db, targetAnimeId);
if (!targetRow) {
// Nothing to merge with a row that no longer exists (a stale id from the
// caller); fall through to the redistribution path.
return false;
}
if (options.survivor !== 'target') {
// The target is the row about to disappear here, so an existing link of its
// own means this is a mis-resolution rather than a duplicate: leave it be.
if (targetRow.anilist_id != null && targetRow.anilist_id !== anilistId) {
return false;
}
}
if (
options.matchConfidence === 'weak' ||
(options.matchConfidence === undefined &&
!hasExactStoredTitleMatch(db, targetAnimeId, conflictAnimeId))
) {
return false;
}
return animeSeasonsAreMergeCompatible(
getParsedSeasonsForAnime(db, targetAnimeId),
getParsedSeasonsForAnime(db, conflictAnimeId),
);
}
@@ -8,6 +8,8 @@ import type { JellyfinLinkRepairSummary } from './types';
type LegacyJellyfinVideoRow = {
video_id: number;
video_key: string;
anime_id: number | null;
anime_assignment_locked: number;
source_url: string | null;
canonical_title: string;
};
@@ -15,6 +17,7 @@ type LegacyJellyfinVideoRow = {
type JellyfinTargetVideoRow = {
video_id: number;
anime_id: number | null;
anime_assignment_locked: number;
canonical_title: string;
parsed_basename: string | null;
parsed_title: string | null;
@@ -258,7 +261,13 @@ export function repairJellyfinStreamVideoLinks(db: DatabaseSync): JellyfinLinkRe
const candidates = db
.prepare(
`
SELECT video_id, video_key, source_url, canonical_title
SELECT
video_id,
video_key,
anime_id,
anime_assignment_locked,
source_url,
canonical_title
FROM imm_videos
WHERE source_type = 2
AND (
@@ -310,6 +319,7 @@ export function repairJellyfinStreamVideoLinks(db: DatabaseSync): JellyfinLinkRe
SELECT
video_id,
anime_id,
anime_assignment_locked,
canonical_title,
parsed_basename,
parsed_title,
@@ -357,12 +367,22 @@ export function repairJellyfinStreamVideoLinks(db: DatabaseSync): JellyfinLinkRe
continue;
}
const assignmentAnimeId =
candidate.anime_assignment_locked === 1 ? candidate.anime_id : target.anime_id;
// A lock without an assignment is meaningless and would pin later
// relinking to nothing, so never carry the flag onto a NULL anime_id.
const assignmentLocked =
assignmentAnimeId !== null &&
(candidate.anime_assignment_locked === 1 || target.anime_assignment_locked === 1)
? 1
: 0;
db.prepare(
`
UPDATE imm_videos
SET
video_key = ?,
anime_id = ?,
anime_assignment_locked = ?,
canonical_title = ?,
source_url = ?,
parsed_basename = ?,
@@ -377,7 +397,8 @@ export function repairJellyfinStreamVideoLinks(db: DatabaseSync): JellyfinLinkRe
`,
).run(
sanitizedVideoKey,
target.anime_id,
assignmentAnimeId,
assignmentLocked,
target.canonical_title,
statsUrl,
target.parsed_basename,
@@ -390,15 +411,13 @@ export function repairJellyfinStreamVideoLinks(db: DatabaseSync): JellyfinLinkRe
currentTimestamp,
candidate.video_id,
);
if (target.anime_id !== null) {
db.prepare(
`
UPDATE imm_subtitle_lines
SET anime_id = ?, LAST_UPDATE_DATE = ?
WHERE video_id = ?
`,
).run(target.anime_id, currentTimestamp, candidate.video_id);
}
db.prepare(
`
UPDATE imm_subtitle_lines
SET anime_id = ?, LAST_UPDATE_DATE = ?
WHERE video_id = ?
`,
).run(assignmentAnimeId, currentTimestamp, candidate.video_id);
summary.repaired += 1;
}
summary.repaired += repairLeakedJellyfinAnimeTitles(db, currentTimestamp);
@@ -708,6 +708,87 @@ export function rebuildLifetimeSummariesInTransaction(
return rebuildLifetimeSummariesInternal(db, rebuiltAtMs);
}
/**
* Re-derive every per-anime lifetime row from the per-video summaries after
* episodes changed owners (merge, move, season repair).
*
* Deliberately NOT a full rebuild: {@link rebuildLifetimeSummariesInTransaction}
* recomputes from raw sessions, which are pruned after the retention window, so
* it silently truncates lifetime history. `imm_lifetime_media` is keyed by
* video and survives repointing, so aggregating it preserves all-time totals;
* `imm_lifetime_global` only needs `anime_completed` refreshed because moving
* attribution between entries cannot change the global counters.
*
* Assumes the caller holds a write transaction; use
* {@link recomputeLifetimeAnimeAggregates} otherwise.
*/
export function recomputeLifetimeAnimeAggregatesInTransaction(db: DatabaseSync): void {
const updatedAt = toDbTimestamp(nowMs());
db.exec('DELETE FROM imm_lifetime_anime');
db.prepare(
`
INSERT INTO imm_lifetime_anime (
anime_id,
total_sessions,
total_active_ms,
total_cards,
total_lines_seen,
total_tokens_seen,
episodes_started,
episodes_completed,
first_watched_ms,
last_watched_ms,
CREATED_DATE,
LAST_UPDATE_DATE
)
SELECT
v.anime_id,
COALESCE(SUM(m.total_sessions), 0),
COALESCE(SUM(m.total_active_ms), 0),
COALESCE(SUM(m.total_cards), 0),
COALESCE(SUM(m.total_lines_seen), 0),
COALESCE(SUM(m.total_tokens_seen), 0),
COUNT(*),
COUNT(CASE WHEN m.completed > 0 THEN 1 END),
MIN(m.first_watched_ms),
MAX(m.last_watched_ms),
?,
?
FROM imm_lifetime_media m
JOIN imm_videos v ON v.video_id = m.video_id
WHERE v.anime_id IS NOT NULL
GROUP BY v.anime_id
`,
).run(updatedAt, updatedAt);
db.prepare(
`
UPDATE imm_lifetime_global
SET
anime_completed = (
SELECT COUNT(*)
FROM imm_lifetime_anime la
JOIN imm_anime a ON a.anime_id = la.anime_id
WHERE a.episodes_total IS NOT NULL
AND a.episodes_total > 0
AND la.episodes_completed >= a.episodes_total
),
LAST_UPDATE_DATE = ?
WHERE global_id = 1
`,
).run(updatedAt);
}
export function recomputeLifetimeAnimeAggregates(db: DatabaseSync): void {
db.exec('BEGIN IMMEDIATE');
try {
recomputeLifetimeAnimeAggregatesInTransaction(db);
db.exec('COMMIT');
} catch (error) {
db.exec('ROLLBACK');
throw error;
}
}
export function reconcileStaleActiveSessions(db: DatabaseSync): number {
const sessions = getRetainedStaleActiveSessions(db);
if (sessions.length === 0) {
@@ -1,7 +1,10 @@
import { createHash } from 'node:crypto';
import type { DatabaseSync } from './sqlite';
import { buildCoverBlobReference, normalizeCoverBlobBytes } from './storage';
import { rebuildLifetimeSummaries, rebuildLifetimeSummariesInTransaction } from './lifetime';
import {
recomputeLifetimeAnimeAggregates,
rebuildLifetimeSummariesInTransaction,
} from './lifetime';
import { getRollupGroupsForSessions, refreshRollupsForGroupsInTransaction } from './maintenance';
import { nowMs } from './time';
import { resolveAnimeAnilistConflict } from './anime-season-repair';
@@ -418,6 +421,7 @@ export function updateAnimeAnilistInfo(
titleEnglish: string | null;
titleNative: string | null;
episodesTotal: number | null;
exactTitleMatch?: boolean;
},
): void {
const row = db.prepare('SELECT anime_id FROM imm_videos WHERE video_id = ?').get(videoId) as {
@@ -425,7 +429,11 @@ export function updateAnimeAnilistInfo(
} | null;
if (!row?.anime_id) return;
const repair = resolveAnimeAnilistConflict(db, row.anime_id, info.anilistId);
const repair = resolveAnimeAnilistConflict(db, row.anime_id, info.anilistId, {
matchConfidence:
info.exactTitleMatch === true ? 'exact' : info.exactTitleMatch === false ? 'weak' : undefined,
});
if (repair.mergeRecommended || repair.anilistAssignmentBlocked) return;
const targetRow = db
.prepare('SELECT anime_id FROM imm_videos WHERE video_id = ?')
.get(videoId) as {
@@ -455,7 +463,7 @@ export function updateAnimeAnilistInfo(
targetRow.anime_id,
);
if (repair.movedVideos > 0 || repair.deletedAnimeRows > 0) {
rebuildLifetimeSummaries(db);
recomputeLifetimeAnimeAggregates(db);
}
}
@@ -20,6 +20,7 @@ import {
} from './storage';
import {
EVENT_SUBTITLE_LINE,
SCHEMA_VERSION,
SESSION_STATUS_ENDED,
SOURCE_TYPE_LOCAL,
SOURCE_TYPE_REMOTE,
@@ -132,6 +133,7 @@ test('ensureSchema creates immersion core tables', () => {
assert.ok(videoColumns.has('parser_source'));
assert.ok(videoColumns.has('parser_confidence'));
assert.ok(videoColumns.has('parse_metadata_json'));
assert.ok(videoColumns.has('anime_assignment_locked'));
const mediaArtColumns = new Set(
(
@@ -155,6 +157,33 @@ test('ensureSchema creates immersion core tables', () => {
}
});
test('ensureSchema adds manual assignment locks when upgrading the previous schema', () => {
const dbPath = makeDbPath();
const db = new Database(dbPath);
try {
ensureSchema(db);
db.exec('ALTER TABLE imm_videos DROP COLUMN anime_assignment_locked');
db.prepare('UPDATE imm_schema_version SET schema_version = ?').run(SCHEMA_VERSION - 1);
ensureSchema(db);
const columns = new Set(
(db.prepare('PRAGMA table_info(imm_videos)').all() as Array<{ name: string }>).map(
(row) => row.name,
),
);
assert.ok(columns.has('anime_assignment_locked'));
const version = db
.prepare('SELECT MAX(schema_version) AS version FROM imm_schema_version')
.get() as { version: number };
assert.equal(version.version, SCHEMA_VERSION);
} finally {
db.close();
cleanupDbPath(dbPath);
}
});
test('stats excluded words are replaced and read from sqlite storage', () => {
const dbPath = makeDbPath();
const db = new Database(dbPath);
@@ -807,6 +836,7 @@ test('ensureSchema migrates legacy videos and backfills anime metadata from file
assert.ok(videoColumns.has('parser_source'));
assert.ok(videoColumns.has('parser_confidence'));
assert.ok(videoColumns.has('parse_metadata_json'));
assert.ok(videoColumns.has('anime_assignment_locked'));
const animeRows = db
.prepare('SELECT canonical_title FROM imm_anime ORDER BY canonical_title')
@@ -1336,6 +1366,70 @@ test('youtube videos can be regrouped under a shared channel anime identity', ()
}
});
test('youtube channel relinking preserves a locked manual assignment', () => {
const dbPath = makeDbPath();
const db = new Database(dbPath);
try {
ensureSchema(db);
const videoId = getOrCreateVideoRecord(db, 'remote:https://www.youtube.com/watch?v=locked', {
canonicalTitle: 'Locked Video',
sourcePath: null,
sourceUrl: 'https://www.youtube.com/watch?v=locked',
sourceType: SOURCE_TYPE_REMOTE,
});
const manualAnimeId = getOrCreateAnimeRecord(db, {
parsedTitle: 'Manual Collection',
canonicalTitle: 'Manual Collection',
anilistId: null,
titleRomaji: null,
titleEnglish: null,
titleNative: null,
metadataJson: null,
});
linkVideoToAnimeRecord(db, videoId, {
animeId: manualAnimeId,
parsedBasename: null,
parsedTitle: 'Manual Collection',
parsedSeason: null,
parsedEpisode: null,
parserSource: 'manual-test',
parserConfidence: 1,
parseMetadataJson: null,
});
db.prepare('UPDATE imm_videos SET anime_assignment_locked = 1 WHERE video_id = ?').run(videoId);
const linkedAnimeId = linkYoutubeVideoToAnimeRecord(db, videoId, {
youtubeVideoId: 'locked',
videoUrl: 'https://www.youtube.com/watch?v=locked',
videoTitle: 'Locked Video',
videoThumbnailUrl: null,
channelId: 'UC-locked',
channelName: 'Automatic Channel',
channelUrl: null,
channelThumbnailUrl: null,
uploaderId: null,
uploaderUrl: null,
description: null,
metadataJson: null,
});
assert.equal(linkedAnimeId, manualAnimeId);
const video = db
.prepare('SELECT anime_id, parsed_title FROM imm_videos WHERE video_id = ?')
.get(videoId) as { anime_id: number | null; parsed_title: string | null };
assert.equal(video.anime_id, manualAnimeId);
assert.equal(video.parsed_title, 'Manual Collection');
const automaticAnime = db
.prepare(`SELECT anime_id FROM imm_anime WHERE canonical_title = 'Automatic Channel'`)
.get();
assert.equal(automaticAnime, undefined);
} finally {
db.close();
cleanupDbPath(dbPath);
}
});
test('start/finalize session updates ended_at and status', () => {
const dbPath = makeDbPath();
const db = new Database(dbPath);
+156 -11
View File
@@ -1,5 +1,7 @@
import { createHash } from 'node:crypto';
import path from 'node:path';
import { parseMediaInfo } from '../../../jimaku/utils';
import { normalizeTitleIdentity } from '../../utils/title-normalization';
import type { DatabaseSync } from './sqlite';
import { nowMs } from './time';
import { SCHEMA_VERSION } from './types';
@@ -319,14 +321,7 @@ export function applyPragmas(db: DatabaseSync): void {
db.exec(`PRAGMA journal_size_limit = ${WAL_JOURNAL_SIZE_LIMIT_BYTES}`);
}
export function normalizeAnimeIdentityKey(title: string): string {
return title
.normalize('NFKC')
.toLowerCase()
.replace(/[^\p{L}\p{N}]+/gu, ' ')
.trim()
.replace(/\s+/g, ' ');
}
export const normalizeAnimeIdentityKey = normalizeTitleIdentity;
function normalizeSeasonScope(value: number | null | undefined): number | null {
if (typeof value !== 'number' || !Number.isSafeInteger(value) || value <= 0) {
@@ -530,6 +525,36 @@ function ensureStatsExcludedWordsTable(db: DatabaseSync): void {
`);
}
function ensureAnimeMergeTables(db: DatabaseSync): void {
db.exec(`
CREATE TABLE IF NOT EXISTS imm_anime_title_aliases(
normalized_title_key TEXT PRIMARY KEY,
anime_id INTEGER NOT NULL,
CREATED_DATE TEXT,
LAST_UPDATE_DATE TEXT,
FOREIGN KEY(anime_id) REFERENCES imm_anime(anime_id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_anime_title_aliases_anime_id
ON imm_anime_title_aliases(anime_id);
CREATE TABLE IF NOT EXISTS imm_anime_merge_recommendations(
recommendation_id INTEGER PRIMARY KEY AUTOINCREMENT,
first_anime_id INTEGER NOT NULL,
second_anime_id INTEGER NOT NULL,
anilist_id INTEGER NOT NULL,
status TEXT NOT NULL DEFAULT 'pending' CHECK(status IN ('pending', 'dismissed')),
CREATED_DATE TEXT,
LAST_UPDATE_DATE TEXT,
CHECK(first_anime_id < second_anime_id),
UNIQUE(first_anime_id, second_anime_id, anilist_id),
FOREIGN KEY(first_anime_id) REFERENCES imm_anime(anime_id) ON DELETE CASCADE,
FOREIGN KEY(second_anime_id) REFERENCES imm_anime(anime_id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_anime_merge_recommendations_status
ON imm_anime_merge_recommendations(status, recommendation_id);
`);
}
export function getOrCreateAnimeRecord(db: DatabaseSync, input: AnimeRecordInput): number {
const seasonScope = normalizeSeasonScope(input.seasonScope);
const identityTitle = buildSeasonScopedAnimeTitle(input.parsedTitle, seasonScope);
@@ -550,8 +575,14 @@ export function getOrCreateAnimeRecord(db: DatabaseSync, input: AnimeRecordInput
const byNormalizedTitle = db
.prepare('SELECT anime_id FROM imm_anime WHERE normalized_title_key = ?')
.get(normalizedTitleKey) as { anime_id: number } | null;
const existing = byAnilistId ?? byNormalizedTitle;
const byTitleAlias = db
.prepare('SELECT anime_id FROM imm_anime_title_aliases WHERE normalized_title_key = ?')
.get(normalizedTitleKey) as { anime_id: number } | null;
const existing = byAnilistId ?? byNormalizedTitle ?? byTitleAlias;
if (existing?.anime_id) {
// An alias remembers an intentionally merged-away spelling. Reusing it
// must not rename the survivor back to that discarded display title.
const canonicalTitleUpdate = byAnilistId || byNormalizedTitle ? canonicalTitle : null;
db.prepare(
`
UPDATE imm_anime
@@ -566,7 +597,7 @@ export function getOrCreateAnimeRecord(db: DatabaseSync, input: AnimeRecordInput
WHERE anime_id = ?
`,
).run(
canonicalTitle,
canonicalTitleUpdate,
input.anilistId,
input.titleRomaji,
input.titleEnglish,
@@ -618,7 +649,10 @@ export function linkVideoToAnimeRecord(
`
UPDATE imm_videos
SET
anime_id = ?,
anime_id = CASE
WHEN anime_assignment_locked = 1 THEN anime_id
ELSE ?
END,
parsed_basename = ?,
parsed_title = ?,
parsed_season = ?,
@@ -643,11 +677,113 @@ export function linkVideoToAnimeRecord(
);
}
function getLockedAnimeAssignment(
db: DatabaseSync,
videoId: number,
): { animeId: number | null } | null {
return db
.prepare(
`
SELECT anime_id AS animeId
FROM imm_videos
WHERE video_id = ?
AND anime_assignment_locked = 1
`,
)
.get(videoId) as { animeId: number | null } | null;
}
export function getManualAnimeAssignment(db: DatabaseSync, videoId: number): number | null {
return getLockedAnimeAssignment(db, videoId)?.animeId ?? null;
}
/**
* A manual correction in the same folder is a useful grouping hint, but only
* when every season-compatible correction agrees on the destination and the
* new file's parsed title has no established identity of its own. Stray
* per-episode titles (an episode name parsed as the series) have no library
* entry, so they follow the correction; a clean parse of a show that already
* owns an entry or alias keeps that identity instead of being captured by a
* neighbor's correction in a mixed folder (a flat downloads directory).
*/
export function findManualDirectoryAnimeAssignment(
db: DatabaseSync,
videoId: number,
mediaPath: string,
parsedTitle: string | null,
parsedSeason: number | null,
): number | null {
// Mirrors the identity key getOrCreateAnimeRecord would file this video
// under, so "established" means exactly "would have joined that entry".
const identityKey = normalizeAnimeIdentityKey(
buildSeasonScopedAnimeTitle(parsedTitle ?? '', normalizeSeasonScope(parsedSeason)),
);
if (!identityKey) {
return null;
}
const directory = path.dirname(path.resolve(mediaPath));
const rows = db
.prepare(
`
SELECT
anime_id AS animeId,
source_path AS sourcePath,
parsed_season AS parsedSeason
FROM imm_videos
WHERE video_id != ?
AND anime_assignment_locked = 1
AND anime_id IS NOT NULL
AND source_path IS NOT NULL
`,
)
.all(videoId) as Array<{
animeId: number;
sourcePath: string;
parsedSeason: number | null;
}>;
const candidates = new Set<number>();
for (const row of rows) {
if (path.dirname(path.resolve(row.sourcePath)) !== directory) {
continue;
}
if (parsedSeason !== null && row.parsedSeason !== null && parsedSeason !== row.parsedSeason) {
continue;
}
candidates.add(row.animeId);
if (candidates.size > 1) {
return null;
}
}
const candidate = candidates.values().next().value ?? null;
if (candidate === null) {
return null;
}
const established = (db
.prepare(
`
SELECT anime_id AS animeId FROM imm_anime WHERE normalized_title_key = ?
UNION ALL
SELECT anime_id AS animeId FROM imm_anime_title_aliases WHERE normalized_title_key = ?
`,
)
.get(identityKey, identityKey) ?? null) as { animeId: number } | null;
if (established && established.animeId !== candidate) {
return null;
}
return candidate;
}
export function linkYoutubeVideoToAnimeRecord(
db: DatabaseSync,
videoId: number,
metadata: YoutubeVideoMetadata,
): number | null {
const lockedAssignment = getLockedAnimeAssignment(db, videoId);
if (lockedAssignment) {
return lockedAssignment.animeId;
}
const identity = buildYoutubeChannelAnimeIdentity(metadata);
if (!identity) {
return null;
@@ -751,6 +887,7 @@ export function ensureSchema(db: DatabaseSync): void {
if (currentVersion?.schema_version === SCHEMA_VERSION) {
ensureLifetimeSummaryTables(db);
ensureStatsExcludedWordsTable(db);
ensureAnimeMergeTables(db);
return;
}
@@ -786,6 +923,7 @@ export function ensureSchema(db: DatabaseSync): void {
parser_source TEXT,
parser_confidence REAL,
parse_metadata_json TEXT,
anime_assignment_locked INTEGER NOT NULL DEFAULT 0 CHECK(anime_assignment_locked IN (0, 1)),
watched INTEGER NOT NULL DEFAULT 0,
duration_ms INTEGER NOT NULL CHECK(duration_ms>=0),
file_size_bytes INTEGER CHECK(file_size_bytes>=0),
@@ -799,6 +937,13 @@ export function ensureSchema(db: DatabaseSync): void {
FOREIGN KEY(anime_id) REFERENCES imm_anime(anime_id) ON DELETE SET NULL
);
`);
addColumnIfMissing(
db,
'imm_videos',
'anime_assignment_locked',
'INTEGER NOT NULL DEFAULT 0 CHECK(anime_assignment_locked IN (0, 1))',
);
ensureAnimeMergeTables(db);
db.exec(`
CREATE TABLE IF NOT EXISTS imm_sessions(
session_id INTEGER PRIMARY KEY AUTOINCREMENT,
+1 -1
View File
@@ -1,4 +1,4 @@
export const SCHEMA_VERSION = 19;
export const SCHEMA_VERSION = 21;
export const DEFAULT_QUEUE_CAP = 1_000;
export const DEFAULT_BATCH_SIZE = 25;
export const DEFAULT_FLUSH_INTERVAL_MS = 500;