fix(stats): preserve season boundaries during AniList merge resolution

- Allow manual cross-season reassignment without merging entries
- Require validated title matches for automatic merges
- Share title normalization across AniList and stats flows
This commit is contained in:
2026-08-11 23:40:27 -07:00
parent d8c5fc321a
commit abbe0ee829
9 changed files with 119 additions and 24 deletions
@@ -156,6 +156,18 @@ test('season 1 resolves to the anchor without relation lookups', async () => {
assert.deepEqual(relationLookups, []); assert.deepEqual(relationLookups, []);
}); });
test('season 2 preserves the anchor exact-title evidence through a sequel resolution', async () => {
const { execute } = createExecutor(OREGAIRU_SEARCH, OREGAIRU_RELATIONS);
const result = await resolveAnilistSeasonMedia(
{ title: 'My Teen Romantic Comedy SNAFU', season: 2, episode: 1 },
{ execute },
);
assert.equal(result?.id, 20698);
assert.equal(result?.via, 'sequel-chain');
assert.equal(result?.exactTitleMatch, true);
});
test('reports an exact normalized synonym match as strong evidence', async () => { test('reports an exact normalized synonym match as strong evidence', async () => {
const { execute } = createExecutor([ const { execute } = createExecutor([
{ {
+8 -14
View File
@@ -9,6 +9,8 @@
* reports `seasonResolved: false` so callers can refuse to act instead of guessing. * reports `seasonResolved: false` so callers can refuse to act instead of guessing.
*/ */
import { normalizeTitleIdentity } from '../../utils/title-normalization';
export interface AnilistSeasonMediaTitle { export interface AnilistSeasonMediaTitle {
romaji?: string | null; romaji?: string | null;
english?: string | null; english?: string | null;
@@ -117,15 +119,6 @@ const SEASONAL_FORMAT_PRIORITY = ['TV', 'TV_SHORT', 'ONA'];
const MAX_SEQUEL_HOPS = 12; const MAX_SEQUEL_HOPS = 12;
function normalizeTitle(value: string): string {
return value
.normalize('NFKC')
.toLowerCase()
.replace(/[^\p{L}\p{N}]+/gu, ' ')
.trim()
.replace(/\s+/g, ' ');
}
/** /**
* Drops season markers a release name carries but AniList titles never do, * Drops season markers a release name carries but AniList titles never do,
* so "Some Show Season 3" and "Some Show S3" both search as "Some Show". * so "Some Show Season 3" and "Some Show S3" both search as "Some Show".
@@ -143,7 +136,7 @@ function mediaTitles(media: AnilistSeasonMedia): string[] {
const synonyms = Array.isArray(media.synonyms) ? media.synonyms : []; const synonyms = Array.isArray(media.synonyms) ? media.synonyms : [];
return [media.title?.english, media.title?.romaji, media.title?.native, ...synonyms] return [media.title?.english, media.title?.romaji, media.title?.native, ...synonyms]
.filter((value): value is string => typeof value === 'string' && value.trim().length > 0) .filter((value): value is string => typeof value === 'string' && value.trim().length > 0)
.map((value) => normalizeTitle(value)); .map((value) => normalizeTitleIdentity(value));
} }
function displayTitle(media: AnilistSeasonMedia, fallback: string): string { function displayTitle(media: AnilistSeasonMedia, fallback: string): string {
@@ -218,9 +211,10 @@ export function pickAnchorMedia(
: media; : media;
const pool = episodeFiltered.length > 0 ? episodeFiltered : media; const pool = episodeFiltered.length > 0 ? episodeFiltered : media;
const targets = [normalizeTitle(title), normalizeTitle(stripSeasonSuffix(title))].filter( const targets = [
(value, index, all) => value.length > 0 && all.indexOf(value) === index, normalizeTitleIdentity(title),
); normalizeTitleIdentity(stripSeasonSuffix(title)),
].filter((value, index, all) => value.length > 0 && all.indexOf(value) === index);
const scored = pool.map((entry, index) => { const scored = pool.map((entry, index) => {
const candidateTitles = mediaTitles(entry); const candidateTitles = mediaTitles(entry);
@@ -376,7 +370,7 @@ export async function resolveAnilistSeasonMedia(
episode: season === null || season <= 1 ? input.episode : null, episode: season === null || season <= 1 ? input.episode : null,
}); });
if (!anchor) return null; if (!anchor) return null;
const exactTitleMatch = mediaTitles(anchor).includes(normalizeTitle(searchTitle)); const exactTitleMatch = mediaTitles(anchor).includes(normalizeTitleIdentity(searchTitle));
if (season === null || season <= 1) { if (season === null || season <= 1) {
return toResolution(anchor, searchTitle, season, 'anchor', true, exactTitleMatch); return toResolution(anchor, searchTitle, season, 'anchor', true, exactTitleMatch);
@@ -560,6 +560,35 @@ test('resolveAnimeAnilistConflict leaves explicit incompatible seasons and assig
}); });
}); });
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', () => { test('automatic AniList update does not transfer an assignment across explicit seasons', () => {
withDb((db) => { withDb((db) => {
insertAnime(db, { insertAnime(db, {
@@ -596,6 +625,55 @@ test('automatic AniList update does not transfer an assignment across explicit s
}); });
}); });
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', () => { test('resolveAnimeAnilistConflict leaves an entry that already links elsewhere alone', () => {
withDb((db) => { withDb((db) => {
insertAnime(db, { animeId: 1, key: 'show', title: 'Show', anilistId: 163132 }); insertAnime(db, { animeId: 1, key: 'show', title: 'Show', anilistId: 163132 });
@@ -66,7 +66,7 @@ export function hasExactStoredTitleMatch(
conflict.canonical_title, conflict.canonical_title,
] ]
.filter((title): title is string => Boolean(title?.trim())) .filter((title): title is string => Boolean(title?.trim()))
.map(normalizeAnimeIdentityKey) .map((title) => normalizeAnimeIdentityKey(stripSeasonIdentitySuffix(title)))
.filter(Boolean); .filter(Boolean);
return targetKeys.some((key) => anilistTitleKeys.includes(key)); return targetKeys.some((key) => anilistTitleKeys.includes(key));
} }
@@ -392,6 +392,7 @@ export function resolveAnimeAnilistConflict(
const targetSeasons = getParsedSeasonsForAnime(db, targetAnimeId); const targetSeasons = getParsedSeasonsForAnime(db, targetAnimeId);
const conflictSeasons = getParsedSeasonsForAnime(db, conflict.animeId); const conflictSeasons = getParsedSeasonsForAnime(db, conflict.animeId);
if ( if (
!isManual &&
targetSeasons.size === 1 && targetSeasons.size === 1 &&
conflictSeasons.size === 1 && conflictSeasons.size === 1 &&
[...targetSeasons][0] !== [...conflictSeasons][0] [...targetSeasons][0] !== [...conflictSeasons][0]
@@ -427,7 +427,8 @@ export function updateAnimeAnilistInfo(
if (!row?.anime_id) return; 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', matchConfidence:
info.exactTitleMatch === true ? 'exact' : info.exactTitleMatch === false ? 'weak' : undefined,
}); });
if (repair.mergeRecommended || repair.anilistAssignmentBlocked) return; if (repair.mergeRecommended || repair.anilistAssignmentBlocked) return;
const targetRow = db const targetRow = db
@@ -1,5 +1,6 @@
import { createHash } from 'node:crypto'; import { createHash } from 'node:crypto';
import { parseMediaInfo } from '../../../jimaku/utils'; import { parseMediaInfo } from '../../../jimaku/utils';
import { normalizeTitleIdentity } from '../../utils/title-normalization';
import type { DatabaseSync } from './sqlite'; import type { DatabaseSync } from './sqlite';
import { nowMs } from './time'; import { nowMs } from './time';
import { SCHEMA_VERSION } from './types'; import { SCHEMA_VERSION } from './types';
@@ -319,14 +320,7 @@ export function applyPragmas(db: DatabaseSync): void {
db.exec(`PRAGMA journal_size_limit = ${WAL_JOURNAL_SIZE_LIMIT_BYTES}`); db.exec(`PRAGMA journal_size_limit = ${WAL_JOURNAL_SIZE_LIMIT_BYTES}`);
} }
export function normalizeAnimeIdentityKey(title: string): string { export const normalizeAnimeIdentityKey = normalizeTitleIdentity;
return title
.normalize('NFKC')
.toLowerCase()
.replace(/[^\p{L}\p{N}]+/gu, ' ')
.trim()
.replace(/\s+/g, ' ');
}
function normalizeSeasonScope(value: number | null | undefined): number | null { function normalizeSeasonScope(value: number | null | undefined): number | null {
if (typeof value !== 'number' || !Number.isSafeInteger(value) || value <= 0) { if (typeof value !== 'number' || !Number.isSafeInteger(value) || value <= 0) {
@@ -0,0 +1,7 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { normalizeTitleIdentity } from './title-normalization';
test('normalizeTitleIdentity produces a Unicode-aware comparison key', () => {
assert.equal(normalizeTitleIdentity(' BOCCHI・The ROCK!! '), 'bocchi the rock');
});
+8
View File
@@ -0,0 +1,8 @@
export function normalizeTitleIdentity(title: string): string {
return title
.normalize('NFKC')
.toLowerCase()
.replace(/[^\p{L}\p{N}]+/gu, ' ')
.trim()
.replace(/\s+/g, ' ');
}