diff --git a/src/core/services/anilist/season-resolver.test.ts b/src/core/services/anilist/season-resolver.test.ts index ac0df8f0..a5d12964 100644 --- a/src/core/services/anilist/season-resolver.test.ts +++ b/src/core/services/anilist/season-resolver.test.ts @@ -156,6 +156,18 @@ test('season 1 resolves to the anchor without relation lookups', async () => { 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 () => { const { execute } = createExecutor([ { diff --git a/src/core/services/anilist/season-resolver.ts b/src/core/services/anilist/season-resolver.ts index e2cd4e05..c57d0342 100644 --- a/src/core/services/anilist/season-resolver.ts +++ b/src/core/services/anilist/season-resolver.ts @@ -9,6 +9,8 @@ * reports `seasonResolved: false` so callers can refuse to act instead of guessing. */ +import { normalizeTitleIdentity } from '../../utils/title-normalization'; + export interface AnilistSeasonMediaTitle { romaji?: string | null; english?: string | null; @@ -117,15 +119,6 @@ const SEASONAL_FORMAT_PRIORITY = ['TV', 'TV_SHORT', 'ONA']; 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, * 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 : []; return [media.title?.english, media.title?.romaji, media.title?.native, ...synonyms] .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 { @@ -218,9 +211,10 @@ export function pickAnchorMedia( : media; const pool = episodeFiltered.length > 0 ? episodeFiltered : media; - const targets = [normalizeTitle(title), normalizeTitle(stripSeasonSuffix(title))].filter( - (value, index, all) => value.length > 0 && all.indexOf(value) === index, - ); + const targets = [ + normalizeTitleIdentity(title), + normalizeTitleIdentity(stripSeasonSuffix(title)), + ].filter((value, index, all) => value.length > 0 && all.indexOf(value) === index); const scored = pool.map((entry, index) => { const candidateTitles = mediaTitles(entry); @@ -376,7 +370,7 @@ export async function resolveAnilistSeasonMedia( episode: season === null || season <= 1 ? input.episode : null, }); if (!anchor) return null; - const exactTitleMatch = mediaTitles(anchor).includes(normalizeTitle(searchTitle)); + const exactTitleMatch = mediaTitles(anchor).includes(normalizeTitleIdentity(searchTitle)); if (season === null || season <= 1) { return toResolution(anchor, searchTitle, season, 'anchor', true, exactTitleMatch); diff --git a/src/core/services/immersion-tracker/__tests__/anime-merge.test.ts b/src/core/services/immersion-tracker/__tests__/anime-merge.test.ts index 33f41821..a65b367a 100644 --- a/src/core/services/immersion-tracker/__tests__/anime-merge.test.ts +++ b/src/core/services/immersion-tracker/__tests__/anime-merge.test.ts @@ -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', () => { withDb((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', () => { withDb((db) => { insertAnime(db, { animeId: 1, key: 'show', title: 'Show', anilistId: 163132 }); diff --git a/src/core/services/immersion-tracker/anime-merge-recommendations.ts b/src/core/services/immersion-tracker/anime-merge-recommendations.ts index 82b5ea7c..6ac0c096 100644 --- a/src/core/services/immersion-tracker/anime-merge-recommendations.ts +++ b/src/core/services/immersion-tracker/anime-merge-recommendations.ts @@ -66,7 +66,7 @@ export function hasExactStoredTitleMatch( conflict.canonical_title, ] .filter((title): title is string => Boolean(title?.trim())) - .map(normalizeAnimeIdentityKey) + .map((title) => normalizeAnimeIdentityKey(stripSeasonIdentitySuffix(title))) .filter(Boolean); return targetKeys.some((key) => anilistTitleKeys.includes(key)); } diff --git a/src/core/services/immersion-tracker/anime-season-repair.ts b/src/core/services/immersion-tracker/anime-season-repair.ts index 1eb170a7..dae92003 100644 --- a/src/core/services/immersion-tracker/anime-season-repair.ts +++ b/src/core/services/immersion-tracker/anime-season-repair.ts @@ -392,6 +392,7 @@ export function resolveAnimeAnilistConflict( const targetSeasons = getParsedSeasonsForAnime(db, targetAnimeId); const conflictSeasons = getParsedSeasonsForAnime(db, conflict.animeId); if ( + !isManual && targetSeasons.size === 1 && conflictSeasons.size === 1 && [...targetSeasons][0] !== [...conflictSeasons][0] diff --git a/src/core/services/immersion-tracker/query-maintenance.ts b/src/core/services/immersion-tracker/query-maintenance.ts index f0eb45d3..84c9e91b 100644 --- a/src/core/services/immersion-tracker/query-maintenance.ts +++ b/src/core/services/immersion-tracker/query-maintenance.ts @@ -427,7 +427,8 @@ export function updateAnimeAnilistInfo( if (!row?.anime_id) return; 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; const targetRow = db diff --git a/src/core/services/immersion-tracker/storage.ts b/src/core/services/immersion-tracker/storage.ts index 1a712577..124a8437 100644 --- a/src/core/services/immersion-tracker/storage.ts +++ b/src/core/services/immersion-tracker/storage.ts @@ -1,5 +1,6 @@ import { createHash } from 'node:crypto'; 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 +320,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) { diff --git a/src/core/utils/title-normalization.test.ts b/src/core/utils/title-normalization.test.ts new file mode 100644 index 00000000..8e5a01b1 --- /dev/null +++ b/src/core/utils/title-normalization.test.ts @@ -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'); +}); diff --git a/src/core/utils/title-normalization.ts b/src/core/utils/title-normalization.ts new file mode 100644 index 00000000..0bb7c446 --- /dev/null +++ b/src/core/utils/title-normalization.ts @@ -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, ' '); +}