mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-08-13 13:55:57 -07:00
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:
@@ -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([
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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');
|
||||||
|
});
|
||||||
@@ -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, ' ');
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user