fix(anilist): resolve later seasons via sequel relations, not title guessing (#173)

This commit is contained in:
2026-07-28 22:56:26 -07:00
committed by GitHub
parent 95e0abc7b7
commit 0d7084c8aa
22 changed files with 1987 additions and 437 deletions
+13 -74
View File
@@ -1,4 +1,5 @@
import type { AnilistMediaGuess } from '../../core/services/anilist/anilist-updater';
import { resolveAnilistSeasonMedia } from '../../core/services/anilist/season-resolver';
import { ANILIST_GRAPHQL_URL } from './constants';
import type {
AniListMediaCandidate,
@@ -73,57 +74,6 @@ type AniListCharacterPageResponse = {
} | null;
};
function normalizeTitle(value: string): string {
return value.trim().toLowerCase().replace(/\s+/g, ' ');
}
function pickAniListSearchResult(
title: string,
episode: number | null,
media: Array<{
id: number;
episodes?: number | null;
title?: {
romaji?: string | null;
english?: string | null;
native?: string | null;
};
}>,
): ResolvedAniListMedia | null {
if (media.length === 0) return null;
const episodeFiltered =
episode && episode > 0
? media.filter((entry) => {
const totalEpisodes = entry.episodes;
return (
typeof totalEpisodes !== 'number' || totalEpisodes <= 0 || episode <= totalEpisodes
);
})
: media;
const candidates = episodeFiltered.length > 0 ? episodeFiltered : media;
const normalizedTitle = normalizeTitle(title);
const exact = candidates.find((entry) => {
const titles = [entry.title?.english, entry.title?.romaji, entry.title?.native]
.filter((value): value is string => typeof value === 'string')
.map((value) => normalizeTitle(value));
return titles.includes(normalizedTitle);
});
const selected = exact ?? candidates[0] ?? media[0];
if (!selected) return null;
const selectedTitle =
selected.title?.english?.trim() ||
selected.title?.romaji?.trim() ||
selected.title?.native?.trim() ||
title.trim();
return {
id: selected.id,
title: selectedTitle,
};
}
function toAniListMediaCandidate(
entry: {
id: number;
@@ -242,35 +192,24 @@ function inferImageExt(contentType: string | null, bytes: Buffer): string {
export async function resolveAniListMediaIdFromGuess(
guess: AnilistMediaGuess,
beforeRequest?: () => Promise<void>,
logInfo?: (message: string) => void,
): Promise<ResolvedAniListMedia> {
const data = await fetchAniList<AniListSearchResponse>(
`
query($search: String!) {
Page(perPage: 10) {
media(search: $search, type: ANIME, sort: [SEARCH_MATCH, POPULARITY_DESC]) {
id
episodes
title {
romaji
english
native
}
}
}
}
`,
const resolution = await resolveAnilistSeasonMedia(
{ title: guess.title, season: guess.season, episode: guess.episode },
{
search: guess.title,
execute: (query, variables) => fetchAniList(query, variables, beforeRequest),
logInfo,
},
beforeRequest,
);
const media = data.Page?.media ?? [];
const resolved = pickAniListSearchResult(guess.title, guess.episode, media);
if (!resolved) {
if (!resolution) {
throw new Error(`No AniList media match found for "${guess.title}".`);
}
return resolved;
return {
id: resolution.id,
title: resolution.title,
seasonResolved: resolution.seasonResolved,
requestedSeason: resolution.requestedSeason,
};
}
export async function searchAniListMediaCandidates(
@@ -160,3 +160,73 @@ test('getManualSelectionSnapshot hydrates override episode count from searched c
globalThis.fetch = originalFetch;
}
});
test('resolvePinnedMediaId returns the manual override for the current season', async () => {
const userDataPath = makeTempDir();
const seasonThreePath = '/anime/Oregairu/My Teen Romantic Comedy SNAFU (2013) - S03E01.mkv';
const guess = {
title: 'My Teen Romantic Comedy SNAFU',
year: 2013,
season: 3,
episode: 1,
source: 'guessit' as const,
};
const runtime = createCharacterDictionaryRuntimeService({
userDataPath,
getCurrentMediaPath: () => seasonThreePath,
getCurrentMediaTitle: () => null,
resolveMediaPathForJimaku: (mediaPath) => mediaPath,
guessAnilistMediaInfo: async () => guess,
now: () => 1_700_000_000_000,
});
assert.equal(
await runtime.resolvePinnedMediaId({
mediaPath: seasonThreePath,
mediaTitle: null,
guess,
}),
null,
);
const overridesPath = path.join(userDataPath, 'character-dictionaries', 'anilist-overrides.json');
fs.mkdirSync(path.dirname(overridesPath), { recursive: true });
fs.writeFileSync(
overridesPath,
JSON.stringify({
overrides: [
{
seriesKey: buildCharacterDictionarySeriesKey({
mediaPath: seasonThreePath,
mediaTitle: null,
guess,
}),
mediaId: 108489,
mediaTitle: 'My Teen Romantic Comedy SNAFU Climax!',
staleMediaIds: [],
},
],
}),
'utf8',
);
assert.equal(
await runtime.resolvePinnedMediaId({
mediaPath: seasonThreePath,
mediaTitle: null,
guess,
}),
108489,
);
// The season 1 file in the same folder must not inherit the season 3 override.
assert.equal(
await runtime.resolvePinnedMediaId({
mediaPath: '/anime/Oregairu/My Teen Romantic Comedy SNAFU (2013) - S01E01.mkv',
mediaTitle: null,
guess: { ...guess, season: 1 },
}),
null,
);
});
@@ -312,3 +312,131 @@ test('manual selection store keeps overrides separate for different season direc
assert.notEqual(secondSeasonKey, firstSeasonKey);
assert.equal(await store.getOverride(secondSeasonKey), null);
});
test('buildCharacterDictionarySeriesKey tags seasons past the first', () => {
const base = {
title: 'My Teen Romantic Comedy SNAFU',
year: 2013,
episode: 1,
source: 'guessit' as const,
};
const seasonOne = buildCharacterDictionarySeriesKey({
mediaPath: '/anime/Oregairu/My Teen Romantic Comedy SNAFU (2013) - S01E01.mkv',
mediaTitle: null,
guess: { ...base, season: 1 },
});
const seasonThree = buildCharacterDictionarySeriesKey({
mediaPath: '/anime/Oregairu/My Teen Romantic Comedy SNAFU (2013) - S03E01.mkv',
mediaTitle: null,
guess: { ...base, season: 3 },
});
// Season 1 keeps the pre-existing key shape so cached snapshots stay valid.
assert.equal(seasonOne, 'anime-oregairu--my-teen-romantic-comedy-snafu-2013');
assert.equal(seasonThree, 'anime-oregairu--my-teen-romantic-comedy-snafu-s3-2013');
});
test('manual selection store keeps seasons apart inside one flat directory', async () => {
const userDataPath = makeTempDir();
const store = createCharacterDictionaryManualSelectionStore({ userDataPath });
const base = {
title: 'My Teen Romantic Comedy SNAFU',
year: 2013,
episode: 1,
source: 'guessit' as const,
};
const seasonOneKey = buildCharacterDictionarySeriesKey({
mediaPath: '/anime/Oregairu/My Teen Romantic Comedy SNAFU (2013) - S01E01.mkv',
mediaTitle: null,
guess: { ...base, season: 1 },
});
const seasonThreeKey = buildCharacterDictionarySeriesKey({
mediaPath: '/anime/Oregairu/My Teen Romantic Comedy SNAFU (2013) - S03E01.mkv',
mediaTitle: null,
guess: { ...base, season: 3 },
});
await store.setOverride({
seriesKey: seasonOneKey,
mediaId: 14813,
mediaTitle: 'My Teen Romantic Comedy SNAFU',
staleMediaIds: [],
});
// Same directory, different season: the season 1 override must not leak across.
assert.equal(await store.getOverride(seasonThreeKey), null);
await store.setOverride({
seriesKey: seasonThreeKey,
mediaId: 108489,
mediaTitle: 'My Teen Romantic Comedy SNAFU Climax!',
staleMediaIds: [],
});
assert.equal((await store.getOverride(seasonOneKey))?.mediaId, 14813);
assert.equal((await store.getOverride(seasonThreeKey))?.mediaId, 108489);
});
test('override scope uses the recorded season, not season-like text in the title', async () => {
const userDataPath = makeTempDir();
const store = createCharacterDictionaryManualSelectionStore({ userDataPath });
// A title that itself normalizes to a trailing "-s2" would be misparsed as season 2.
const trickyTitleKey = buildCharacterDictionarySeriesKey({
mediaPath: '/anime/Mixed/Some Show S2 - S01E01.mkv',
mediaTitle: null,
guess: { title: 'Some Show S2', season: 1, episode: 1, source: 'guessit' },
});
const realSeasonTwoKey = buildCharacterDictionarySeriesKey({
mediaPath: '/anime/Mixed/Other Show - S02E01.mkv',
mediaTitle: null,
guess: { title: 'Other Show', season: 2, episode: 1, source: 'guessit' },
});
await store.setOverride({
seriesKey: trickyTitleKey,
mediaId: 111,
mediaTitle: 'Some Show S2',
staleMediaIds: [],
season: 1,
});
await store.setOverride({
seriesKey: realSeasonTwoKey,
mediaId: 222,
mediaTitle: 'Other Show 2',
staleMediaIds: [],
season: 2,
});
// Same directory, genuinely different seasons: neither override may replace the other.
assert.equal((await store.getOverride(trickyTitleKey, 1))?.mediaId, 111);
assert.equal((await store.getOverride(realSeasonTwoKey, 2))?.mediaId, 222);
});
test('override records without a stored season still resolve via the key', async () => {
const userDataPath = makeTempDir();
const store = createCharacterDictionaryManualSelectionStore({ userDataPath });
const legacyKey = buildCharacterDictionarySeriesKey({
mediaPath: REZERO_EP1,
mediaTitle: null,
guess: {
title: 'Re ZERO, Starting Life in Another World',
year: 2016,
season: 1,
episode: 1,
source: 'guessit',
},
});
const overridesPath = path.join(userDataPath, 'character-dictionaries', 'anilist-overrides.json');
fs.mkdirSync(path.dirname(overridesPath), { recursive: true });
fs.writeFileSync(
overridesPath,
JSON.stringify({
overrides: [
{ seriesKey: legacyKey, mediaId: 21355, mediaTitle: 'Re:ZERO', staleMediaIds: [] },
],
}),
'utf8',
);
assert.equal((await store.getOverride(legacyKey, 1))?.mediaId, 21355);
});
@@ -8,6 +8,11 @@ export type CharacterDictionaryManualSelection = {
mediaId: number;
mediaTitle: string;
staleMediaIds: number[];
/**
* Season this override was saved for. Recorded explicitly because inferring it from the
* key text is ambiguous for titles that themselves end in a season-like token.
*/
season?: number | null;
};
type ManualSelectionStoreFile = {
@@ -20,6 +25,11 @@ function normalizeManualMediaId(value: unknown): number | null {
return mediaId > 0 ? mediaId : null;
}
function normalizeSeason(value: unknown): number | null {
if (typeof value !== 'number' || !Number.isInteger(value) || value <= 0) return null;
return value;
}
function normalizeSeriesKeyPart(value: string): string {
return value
.normalize('NFKD')
@@ -73,11 +83,13 @@ function normalizeOverride(value: unknown): CharacterDictionaryManualSelection |
const mediaId = normalizeManualMediaId(raw.mediaId);
const mediaTitle = typeof raw.mediaTitle === 'string' ? raw.mediaTitle.trim() : '';
if (!seriesKey || mediaId === null || !mediaTitle) return null;
const season = normalizeSeason(raw.season);
return {
seriesKey,
mediaId,
mediaTitle,
staleMediaIds: dedupeNumbers(Array.isArray(raw.staleMediaIds) ? raw.staleMediaIds : []),
...(season === null ? {} : { season }),
};
}
@@ -112,10 +124,29 @@ function getDirectoryScope(seriesKey: string): string | null {
return scopedSeparatorIndex < 0 ? null : seriesKey.slice(0, scopedSeparatorIndex);
}
/**
* Fallback season for override records saved before the season was stored explicitly:
* reads the "-s<N>" segment emitted by buildCharacterDictionarySeriesKey, which sits just
* before the optional trailing year. Season 1 keys carry no segment and parse as 1.
*/
function getSeasonScopeFromKey(seriesKey: string): number {
const match = /-s(\d{1,2})(?:-\d{4})?$/.exec(seriesKey);
if (!match) return 1;
const season = Number.parseInt(match[1]!, 10);
return Number.isInteger(season) && season > 0 ? season : 1;
}
/**
* The subset of a parsed guess the series key is built from. Kept structural so callers
* holding a narrower guess (the AniList post-watch runtime) can build the same key.
*/
export type CharacterDictionarySeriesKeyGuess = Pick<AnilistMediaGuess, 'title' | 'season'> &
Partial<Omit<AnilistMediaGuess, 'title' | 'season'>>;
export function buildCharacterDictionarySeriesKey(input: {
mediaPath: string | null;
mediaTitle: string | null;
guess: AnilistMediaGuess | null;
guess: CharacterDictionarySeriesKeyGuess | null;
}): string {
const guessedTitle = input.guess?.title.trim() || input.guess?.alternativeTitle?.trim() || '';
const sourceTitle =
@@ -130,22 +161,40 @@ export function buildCharacterDictionarySeriesKey(input: {
const base = normalizeSeriesKeyPart(withoutEpisode) || 'unknown';
const directoryKey = getMediaDirectoryKey(input.mediaPath);
const scopedBase = directoryKey ? `${directoryKey}--${base}` : base;
return input.guess?.year ? `${scopedBase}-${input.guess.year}` : scopedBase;
// Season 1 stays unsuffixed so existing keys, snapshots and overrides keep matching.
const season = input.guess?.season;
const seasonSegment =
typeof season === 'number' && Number.isInteger(season) && season > 1 ? `-s${season}` : '';
const withSeason = `${scopedBase}${seasonSegment}`;
return input.guess?.year ? `${withSeason}-${input.guess.year}` : withSeason;
}
/** Season an override applies to: the recorded value, else parsed from its key. */
function getOverrideSeason(entry: CharacterDictionaryManualSelection): number {
return normalizeSeason(entry.season) ?? getSeasonScopeFromKey(entry.seriesKey);
}
export function createCharacterDictionaryManualSelectionStore(deps: { userDataPath: string }) {
const filePath = path.join(deps.userDataPath, 'character-dictionaries', 'anilist-overrides.json');
return {
getOverride: async (seriesKey: string): Promise<CharacterDictionaryManualSelection | null> => {
getOverride: async (
seriesKey: string,
season?: number | null,
): Promise<CharacterDictionaryManualSelection | null> => {
const candidates = getLegacySeriesKeyCandidates(seriesKey);
const overrides = readOverrides(filePath);
const exactMatch = overrides.find((entry) => entry.seriesKey === candidates[0]);
if (exactMatch) return exactMatch;
const directoryScope = getDirectoryScope(seriesKey);
if (directoryScope) {
// Same folder is only evidence of the same show when it is also the same season;
// a flat multi-season folder would otherwise spread one override across all of them.
const seasonScope = normalizeSeason(season) ?? getSeasonScopeFromKey(seriesKey);
const scopedMatches = overrides.filter(
(entry) => getDirectoryScope(entry.seriesKey) === directoryScope,
(entry) =>
getDirectoryScope(entry.seriesKey) === directoryScope &&
getOverrideSeason(entry) === seasonScope,
);
const selectedMediaIds = new Set(scopedMatches.map((entry) => entry.mediaId));
if (scopedMatches.length > 0 && selectedMediaIds.size === 1) {
@@ -168,9 +217,11 @@ export function createCharacterDictionaryManualSelectionStore(deps: { userDataPa
throw new Error('Invalid character dictionary manual selection.');
}
const directoryScope = getDirectoryScope(normalized.seriesKey);
const seasonScope = getOverrideSeason(normalized);
const remaining = readOverrides(filePath).filter((entry) =>
directoryScope
? getDirectoryScope(entry.seriesKey) !== directoryScope
? getDirectoryScope(entry.seriesKey) !== directoryScope ||
getOverrideSeason(entry) !== seasonScope
: entry.seriesKey !== normalized.seriesKey,
);
writeOverrides(filePath, [...remaining, normalized]);
@@ -324,3 +324,102 @@ test('generateForCurrentMedia keeps same-version snapshots without images when i
globalThis.fetch = originalFetch;
}
});
test('an unresolvable season is not cached as a normal AniList match', async () => {
const userDataPath = makeTempDir();
const originalFetch = globalThis.fetch;
let searchCalls = 0;
globalThis.fetch = (async (input: string | URL | Request, init?: RequestInit) => {
const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url;
if (url !== GRAPHQL_URL) {
return new Response(PNG_1X1, { status: 200, headers: { 'content-type': 'image/png' } });
}
const body = JSON.parse(String(init?.body ?? '{}')) as {
query?: string;
variables?: { search?: string; id?: number };
};
if (body.query?.includes('characters(page: $page')) {
return new Response(
JSON.stringify({
data: {
Media: {
title: { english: 'My Teen Romantic Comedy SNAFU' },
characters: {
pageInfo: { hasNextPage: false },
edges: [
{
role: 'MAIN',
node: { id: 1, name: { full: 'Hachiman Hikigaya', native: '比企谷八幡' } },
},
],
},
},
},
}),
{ status: 200, headers: { 'content-type': 'application/json' } },
);
}
if (typeof body.variables?.id === 'number') {
// No sequel edges: season 3 is unreachable from the season 1 anchor.
return new Response(JSON.stringify({ data: { Media: { relations: { edges: [] } } } }), {
status: 200,
headers: { 'content-type': 'application/json' },
});
}
searchCalls += 1;
return new Response(
JSON.stringify({
data: {
Page: {
media: [
{
id: 14813,
episodes: 13,
format: 'TV',
seasonYear: 2013,
title: { romaji: null, english: 'My Teen Romantic Comedy SNAFU', native: null },
},
],
},
},
}),
{ status: 200, headers: { 'content-type': 'application/json' } },
);
}) as typeof globalThis.fetch;
const warnings: string[] = [];
try {
const runtime = createCharacterDictionaryRuntimeService({
userDataPath,
getCurrentMediaPath: () =>
'/anime/Oregairu/My Teen Romantic Comedy SNAFU (2013) - S03E01.mkv',
getCurrentMediaTitle: () => null,
resolveMediaPathForJimaku: (mediaPath) => mediaPath,
guessAnilistMediaInfo: async () => ({
title: 'My Teen Romantic Comedy SNAFU',
year: 2013,
season: 3,
episode: 1,
source: 'guessit',
}),
now: () => 1_700_000_000_000,
sleep: async () => {},
logWarn: (message) => warnings.push(message),
});
await runtime.getOrCreateCurrentSnapshot();
const searchesAfterFirst = searchCalls;
await runtime.getOrCreateCurrentSnapshot();
// Re-resolved rather than served from the resolution cache, and warned both times.
assert.ok(searchCalls > searchesAfterFirst);
assert.equal(warnings.filter((m) => /could not find season 3/i.test(m)).length, 2);
} finally {
globalThis.fetch = originalFetch;
}
});
@@ -181,4 +181,7 @@ export type ResolvedAniListMedia = {
id: number;
title: string;
staleMediaIds?: number[];
/** False when a season >= 2 was requested but only the season 1 entry could be found. */
seasonResolved?: boolean;
requestedSeason?: number | null;
};