mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-08-15 01:55:52 -07:00
fix(stats): review fuzzy AniList duplicates before merging
- Preserve merged title aliases for future episodes - Fail closed when queued writes cannot drain
This commit is contained in:
@@ -5,9 +5,14 @@ import path from 'node:path';
|
||||
import test from 'node:test';
|
||||
import { Database } from '../sqlite.js';
|
||||
import type { DatabaseSync } from '../sqlite.js';
|
||||
import { applyPragmas, ensureSchema } from '../storage.js';
|
||||
import { applyPragmas, ensureSchema, getOrCreateAnimeRecord } from '../storage.js';
|
||||
import { mergeAnimeRecords, moveVideoToAnime } from '../anime-merge.js';
|
||||
import { resolveAnimeAnilistConflict } from '../anime-season-repair.js';
|
||||
import {
|
||||
dismissAnimeMergeRecommendation,
|
||||
getAnimeMergeRecommendations,
|
||||
resolveAnimeAnilistConflict,
|
||||
} from '../anime-season-repair.js';
|
||||
import { updateAnimeAnilistInfo } from '../query-maintenance.js';
|
||||
|
||||
const BASE_MS = 1_700_000_000_000;
|
||||
|
||||
@@ -211,6 +216,54 @@ test('mergeAnimeRecords inherits metadata the target is missing without clobberi
|
||||
});
|
||||
});
|
||||
|
||||
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' });
|
||||
@@ -311,6 +364,126 @@ test('resolveAnimeAnilistConflict folds a seasonless duplicate into the entry th
|
||||
});
|
||||
});
|
||||
|
||||
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 });
|
||||
@@ -353,6 +526,76 @@ test('resolveAnimeAnilistConflict falls back to season redistribution for multi-
|
||||
});
|
||||
});
|
||||
|
||||
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('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('resolveAnimeAnilistConflict leaves an entry that already links elsewhere alone', () => {
|
||||
withDb((db) => {
|
||||
insertAnime(db, { animeId: 1, key: 'show', title: 'Show', anilistId: 163132 });
|
||||
@@ -360,9 +603,20 @@ test('resolveAnimeAnilistConflict leaves an entry that already links elsewhere a
|
||||
insertEpisode(db, { videoId: 1, animeId: 1, season: 1 });
|
||||
insertEpisode(db, { videoId: 2, animeId: 2, season: 2 });
|
||||
|
||||
resolveAnimeAnilistConflict(db, 2, 163132);
|
||||
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), []);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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(normalizeAnimeIdentityKey)
|
||||
.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;
|
||||
}
|
||||
@@ -23,6 +23,7 @@ export interface VideoMoveSummary {
|
||||
}
|
||||
|
||||
interface AnimeMetadataRow {
|
||||
normalized_title_key: string;
|
||||
anilist_id: number | null;
|
||||
title_romaji: string | null;
|
||||
title_english: string | null;
|
||||
@@ -51,7 +52,7 @@ function readAnimeMetadata(db: DatabaseSync, animeId: number): AnimeMetadataRow
|
||||
return (db
|
||||
.prepare(
|
||||
`
|
||||
SELECT 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
|
||||
FROM imm_anime
|
||||
WHERE anime_id = ?
|
||||
`,
|
||||
@@ -177,6 +178,19 @@ export function mergeAnimeRecordsInTransaction(
|
||||
'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)) {
|
||||
@@ -185,6 +199,9 @@ export function mergeAnimeRecordsInTransaction(
|
||||
}
|
||||
|
||||
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,
|
||||
);
|
||||
@@ -194,6 +211,13 @@ export function mergeAnimeRecordsInTransaction(
|
||||
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);
|
||||
|
||||
@@ -4,6 +4,13 @@ import {
|
||||
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';
|
||||
@@ -18,9 +25,13 @@ export interface AnimeSeasonRepairSummary {
|
||||
* 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 {
|
||||
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
|
||||
@@ -30,6 +41,12 @@ export interface AnimeAnilistConflictOptions {
|
||||
survivor?: 'target' | 'existing';
|
||||
}
|
||||
|
||||
export {
|
||||
dismissAnimeMergeRecommendation,
|
||||
getAnimeMergeRecommendations,
|
||||
type AnimeMergeRecommendation,
|
||||
} from './anime-merge-recommendations';
|
||||
|
||||
interface AnimeRow {
|
||||
anime_id: number;
|
||||
anilist_id: number | null;
|
||||
@@ -59,6 +76,8 @@ function emptySummary(scanned = 0): AnimeSeasonRepairSummary {
|
||||
movedVideos: 0,
|
||||
deletedAnimeRows: 0,
|
||||
survivingAnimeId: null,
|
||||
mergeRecommended: false,
|
||||
anilistAssignmentBlocked: false,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -71,6 +90,8 @@ function mergeSummary(
|
||||
target.movedVideos += source.movedVideos;
|
||||
target.deletedAnimeRows += source.deletedAnimeRows;
|
||||
target.survivingAnimeId = source.survivingAnimeId ?? target.survivingAnimeId;
|
||||
target.mergeRecommended ||= source.mergeRecommended;
|
||||
target.anilistAssignmentBlocked ||= source.anilistAssignmentBlocked;
|
||||
return target;
|
||||
}
|
||||
|
||||
@@ -324,12 +345,11 @@ export function repairLegacySeasonlessAnimeRows(db: DatabaseSync): AnimeSeasonRe
|
||||
}
|
||||
|
||||
/**
|
||||
* Two library entries cannot both hold the same AniList id (imm_anime.anilist_id
|
||||
* is UNIQUE), and two entries resolving to the same id are the same show split
|
||||
* by a title or season-suffix mismatch. Fold them together when their parsed
|
||||
* seasons agree; fall back to the legacy season redistribution when the
|
||||
* conflicting row actually spans several seasons, since merging there would
|
||||
* pile unrelated seasons onto one card.
|
||||
* 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. Fall back to legacy
|
||||
* season redistribution when the conflicting row spans several seasons.
|
||||
*/
|
||||
export function resolveAnimeAnilistConflict(
|
||||
db: DatabaseSync,
|
||||
@@ -353,6 +373,33 @@ export function resolveAnimeAnilistConflict(
|
||||
}
|
||||
|
||||
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.
|
||||
return emptySummary(1);
|
||||
}
|
||||
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 (
|
||||
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;
|
||||
@@ -371,6 +418,13 @@ export function resolveAnimeAnilistConflict(
|
||||
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,
|
||||
@@ -398,6 +452,13 @@ function canMergeAnilistConflict(
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if (
|
||||
options.matchConfidence === 'weak' ||
|
||||
(options.matchConfidence === undefined &&
|
||||
!hasExactStoredTitleMatch(db, targetAnimeId, conflictAnimeId))
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
return animeSeasonsAreMergeCompatible(
|
||||
getParsedSeasonsForAnime(db, targetAnimeId),
|
||||
getParsedSeasonsForAnime(db, conflictAnimeId),
|
||||
|
||||
@@ -418,6 +418,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 +426,10 @@ 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 === false ? 'weak' : 'exact',
|
||||
});
|
||||
if (repair.mergeRecommended || repair.anilistAssignmentBlocked) return;
|
||||
const targetRow = db
|
||||
.prepare('SELECT anime_id FROM imm_videos WHERE video_id = ?')
|
||||
.get(videoId) as {
|
||||
|
||||
@@ -530,6 +530,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 +580,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 +602,7 @@ export function getOrCreateAnimeRecord(db: DatabaseSync, input: AnimeRecordInput
|
||||
WHERE anime_id = ?
|
||||
`,
|
||||
).run(
|
||||
canonicalTitle,
|
||||
canonicalTitleUpdate,
|
||||
input.anilistId,
|
||||
input.titleRomaji,
|
||||
input.titleEnglish,
|
||||
@@ -751,6 +787,7 @@ export function ensureSchema(db: DatabaseSync): void {
|
||||
if (currentVersion?.schema_version === SCHEMA_VERSION) {
|
||||
ensureLifetimeSummaryTables(db);
|
||||
ensureStatsExcludedWordsTable(db);
|
||||
ensureAnimeMergeTables(db);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -799,6 +836,7 @@ export function ensureSchema(db: DatabaseSync): void {
|
||||
FOREIGN KEY(anime_id) REFERENCES imm_anime(anime_id) ON DELETE SET NULL
|
||||
);
|
||||
`);
|
||||
ensureAnimeMergeTables(db);
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS imm_sessions(
|
||||
session_id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
export const SCHEMA_VERSION = 19;
|
||||
export const SCHEMA_VERSION = 20;
|
||||
export const DEFAULT_QUEUE_CAP = 1_000;
|
||||
export const DEFAULT_BATCH_SIZE = 25;
|
||||
export const DEFAULT_FLUSH_INTERVAL_MS = 500;
|
||||
|
||||
Reference in New Issue
Block a user