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
@@ -143,3 +143,50 @@ test('anilist update queue persists and reloads from disk', () => {
});
assert.equal(queueB.nextReady(Number.MAX_SAFE_INTEGER)?.title, 'Persist Demo');
});
test('drops queued items whose persisted mediaId is not a positive integer', () => {
const filePath = path.join(
fs.mkdtempSync(path.join(os.tmpdir(), 'anilist-queue-mediaid-')),
'queue.json',
);
const base = {
episode: 1,
createdAt: 1,
attemptCount: 0,
nextAttemptAt: 0,
lastError: null,
};
fs.writeFileSync(
filePath,
JSON.stringify({
pending: [
{ ...base, key: 'ok-absent', title: 'A' },
{ ...base, key: 'ok-null', title: 'B', mediaId: null },
{ ...base, key: 'ok-valid', title: 'C', mediaId: 108489 },
{ ...base, key: 'bad-string', title: 'D', mediaId: '108489' },
{ ...base, key: 'bad-zero', title: 'E', mediaId: 0 },
{ ...base, key: 'bad-negative', title: 'F', mediaId: -3 },
{ ...base, key: 'bad-float', title: 'G', mediaId: 1.5 },
{ ...base, key: 'bad-object', title: 'H', mediaId: { id: 1 } },
],
deadLetter: [],
}),
'utf-8',
);
const queue = createAnilistUpdateQueue(filePath, {
info: () => {},
warn: () => {},
error: () => {},
});
const keptKeys: string[] = [];
for (let i = 0; i < 8; i += 1) {
const next = queue.nextReady(1000);
if (!next) break;
keptKeys.push(next.key);
queue.markSuccess(next.key);
}
assert.deepEqual(keptKeys, ['ok-absent', 'ok-null', 'ok-valid']);
});
@@ -10,6 +10,8 @@ export interface AnilistQueuedUpdate {
key: string;
title: string;
season?: number | null;
/** Pinned AniList media id from a manual override, when one applied at enqueue time. */
mediaId?: number | null;
episode: number;
createdAt: number;
attemptCount: number;
@@ -29,13 +31,31 @@ export interface AnilistRetryQueueSnapshot {
}
export interface AnilistUpdateQueue {
enqueue: (key: string, title: string, episode: number, season?: number | null) => void;
enqueue: (
key: string,
title: string,
episode: number,
season?: number | null,
mediaId?: number | null,
) => void;
nextReady: (nowMs?: number) => AnilistQueuedUpdate | null;
markSuccess: (key: string) => void;
markFailure: (key: string, reason: string, nowMs?: number) => void;
getSnapshot: (nowMs?: number) => AnilistRetryQueueSnapshot;
}
/**
* A persisted mediaId pins the update to a specific AniList entry, so a corrupted or
* hand-edited queue file must not be able to feed a bogus id straight to the mutation.
*/
function isValidPersistedMediaId(value: unknown): boolean {
return (
value === undefined ||
value === null ||
(typeof value === 'number' && Number.isInteger(value) && value > 0)
);
}
function clampBackoffMs(attemptCount: number): number {
const computed = INITIAL_BACKOFF_MS * Math.pow(2, Math.max(0, attemptCount - 1));
return Math.min(MAX_BACKOFF_MS, computed);
@@ -82,6 +102,7 @@ export function createAnilistUpdateQueue(
typeof item.createdAt === 'number' &&
typeof item.attemptCount === 'number' &&
typeof item.nextAttemptAt === 'number' &&
isValidPersistedMediaId(item.mediaId) &&
(typeof item.lastError === 'string' || item.lastError === null),
)
.slice(0, MAX_ITEMS);
@@ -96,6 +117,7 @@ export function createAnilistUpdateQueue(
typeof item.createdAt === 'number' &&
typeof item.attemptCount === 'number' &&
typeof item.nextAttemptAt === 'number' &&
isValidPersistedMediaId(item.mediaId) &&
(typeof item.lastError === 'string' || item.lastError === null),
)
.slice(0, MAX_ITEMS);
@@ -107,7 +129,13 @@ export function createAnilistUpdateQueue(
load();
return {
enqueue(key: string, title: string, episode: number, season: number | null = null): void {
enqueue(
key: string,
title: string,
episode: number,
season: number | null = null,
mediaId: number | null = null,
): void {
const existing =
pending.find((item) => item.key === key) || deadLetter.find((item) => item.key === key);
if (existing) {
@@ -120,6 +148,7 @@ export function createAnilistUpdateQueue(
key,
title,
season,
mediaId,
episode,
createdAt: Date.now(),
attemptCount: 0,
@@ -376,7 +376,7 @@ test('updateAnilistPostWatchProgress returns non-retryable error when media is n
}
});
test('updateAnilistPostWatchProgress prefers season-specific AniList matches', async () => {
test('updateAnilistPostWatchProgress resolves later seasons through the sequel chain', async () => {
const originalFetch = globalThis.fetch;
const searchTerms: string[] = [];
let call = 0;
@@ -389,21 +389,45 @@ test('updateAnilistPostWatchProgress prefers season-specific AniList matches', a
data: {
Page: {
media: [
{ id: 202, episodes: 12, title: { english: 'Demo Show Season 2' } },
{ id: 101, episodes: 12, title: { english: 'Demo Show' } },
{ id: 101, episodes: 12, format: 'TV', title: { english: 'Demo Show' } },
{ id: 202, episodes: 12, format: 'TV', title: { english: 'Demo Show Sequel' } },
],
},
},
});
}
if (call === 2) {
assert.equal(body.variables?.mediaId, 202);
assert.equal(body.variables?.id, 101);
return createJsonResponse({
data: {
Media: { id: 202, mediaListEntry: null },
Media: {
relations: {
edges: [
{
relationType: 'SEQUEL',
node: {
id: 202,
type: 'ANIME',
episodes: 12,
format: 'TV',
title: { english: 'Demo Show Sequel' },
},
},
],
},
},
},
});
}
if (call === 3) {
assert.equal(body.variables?.mediaId, 202);
return createJsonResponse({
data: {
Media: { id: 202, mediaListEntry: { progress: 1, status: 'CURRENT' } },
},
});
}
assert.equal(body.variables?.mediaId, 202);
return createJsonResponse({
data: {
SaveMediaListEntry: { progress: 2, status: 'CURRENT' },
@@ -415,16 +439,118 @@ test('updateAnilistPostWatchProgress prefers season-specific AniList matches', a
const result = await updateAnilistPostWatchProgress('token', 'Demo Show', 2, {
season: 2,
});
assert.deepEqual(searchTerms, ['Demo Show Season 2']);
// Searches the bare title: AniList has no "Demo Show Season 2" entry to find.
assert.deepEqual(searchTerms, ['Demo Show']);
assert.equal(result.status, 'updated');
assert.match(result.message, /Demo Show Sequel/);
assert.equal(call, 4);
} finally {
globalThis.fetch = originalFetch;
}
});
test('updateAnilistPostWatchProgress refuses to update season 1 when the season is unresolvable', async () => {
const originalFetch = globalThis.fetch;
let call = 0;
globalThis.fetch = (async () => {
call += 1;
if (call === 1) {
return createJsonResponse({
data: {
Page: {
media: [{ id: 101, episodes: 12, format: 'TV', title: { english: 'Demo Show' } }],
},
},
});
}
return createJsonResponse({ data: { Media: { relations: { edges: [] } } } });
}) as typeof fetch;
try {
const result = await updateAnilistPostWatchProgress('token', 'Demo Show', 2, { season: 3 });
assert.equal(result.status, 'error');
assert.equal(result.retryable, false);
assert.match(result.message, /not in your AniList Planning or Watching list/i);
assert.match(result.message, /could not find season 3/i);
// Stops after search + relation lookup: no entry lookup, no save.
assert.equal(call, 2);
} finally {
globalThis.fetch = originalFetch;
}
});
test('updateAnilistPostWatchProgress uses a pinned media id without searching', async () => {
const originalFetch = globalThis.fetch;
let call = 0;
globalThis.fetch = (async (_input, init) => {
call += 1;
const body = JSON.parse(String(init?.body)) as { variables?: Record<string, unknown> };
if (call === 1) {
assert.equal(body.variables?.mediaId, 108489);
return createJsonResponse({
data: {
Media: {
id: 108489,
episodes: 12,
title: { english: 'Pinned Show' },
mediaListEntry: { progress: 4, status: 'CURRENT' },
},
},
});
}
assert.equal(body.variables?.mediaId, 108489);
return createJsonResponse({
data: { SaveMediaListEntry: { progress: 5, status: 'CURRENT' } },
});
}) as typeof fetch;
try {
const result = await updateAnilistPostWatchProgress('token', 'Wrong Guess', 5, {
season: 3,
mediaId: 108489,
});
assert.equal(result.status, 'updated');
assert.match(result.message, /Pinned Show/);
assert.equal(call, 2);
} finally {
globalThis.fetch = originalFetch;
}
});
test('updateAnilistPostWatchProgress marks the final episode completed for a pinned media id', async () => {
const originalFetch = globalThis.fetch;
let call = 0;
globalThis.fetch = (async (_input, init) => {
call += 1;
const body = JSON.parse(String(init?.body)) as { variables?: Record<string, unknown> };
if (call === 1) {
return createJsonResponse({
data: {
Media: {
id: 108489,
episodes: 12,
title: { english: 'Pinned Show' },
mediaListEntry: { progress: 11, status: 'CURRENT' },
},
},
});
}
assert.equal(body.variables?.status, 'COMPLETED');
return createJsonResponse({
data: { SaveMediaListEntry: { progress: 12, status: 'COMPLETED' } },
});
}) as typeof fetch;
try {
const result = await updateAnilistPostWatchProgress('token', 'Pinned Show', 12, {
mediaId: 108489,
});
assert.equal(result.status, 'updated');
assert.match(result.message, /completed/i);
} finally {
globalThis.fetch = originalFetch;
}
});
test('updateAnilistPostWatchProgress does not update rewatching entries', async () => {
const originalFetch = globalThis.fetch;
let call = 0;
@@ -479,3 +605,33 @@ test('updateAnilistPostWatchProgress returns error when search fails', async ()
globalThis.fetch = originalFetch;
}
});
test('updateAnilistPostWatchProgress does not requeue a search that matched nothing', async () => {
const originalFetch = globalThis.fetch;
globalThis.fetch = (async () =>
createJsonResponse({ data: { Page: { media: [] } } })) as typeof fetch;
try {
const result = await updateAnilistPostWatchProgress('token', 'Unknown Show', 3);
assert.equal(result.status, 'error');
assert.equal(result.retryable, false);
assert.match(result.message, /no matches/i);
} finally {
globalThis.fetch = originalFetch;
}
});
test('updateAnilistPostWatchProgress still allows retry when the search itself fails', async () => {
const originalFetch = globalThis.fetch;
globalThis.fetch = (async () =>
createJsonResponse({ errors: [{ message: 'upstream exploded' }] })) as typeof fetch;
try {
const result = await updateAnilistPostWatchProgress('token', 'Demo Show', 3);
assert.equal(result.status, 'error');
assert.notEqual(result.retryable, false);
assert.match(result.message, /search failed/i);
} finally {
globalThis.fetch = originalFetch;
}
});
+101 -124
View File
@@ -3,6 +3,7 @@ import * as path from 'path';
import { parseMediaInfo } from '../../../jimaku/utils';
import type { AnilistRateLimiter } from './rate-limiter';
import { resolveAnilistSeasonMedia } from './season-resolver';
const ANILIST_GRAPHQL_URL = 'https://graphql.anilist.co';
@@ -24,6 +25,12 @@ export interface AnilistPostWatchUpdateResult {
export interface AnilistPostWatchUpdateOptions {
rateLimiter?: AnilistRateLimiter;
season?: number | null;
/**
* Pinned AniList media id (from a character dictionary manual override). When set,
* the search/season resolution is skipped entirely.
*/
mediaId?: number | null;
logInfo?: (message: string) => void;
}
interface AnilistGraphQlError {
@@ -35,23 +42,15 @@ interface AnilistGraphQlResponse<T> {
errors?: AnilistGraphQlError[];
}
interface AnilistSearchData {
Page?: {
media?: Array<{
id: number;
episodes: number | null;
title?: {
romaji?: string | null;
english?: string | null;
native?: string | null;
};
}>;
};
}
interface AnilistMediaEntryData {
Media?: {
id: number;
episodes?: number | null;
title?: {
romaji?: string | null;
english?: string | null;
native?: string | null;
} | null;
mediaListEntry?: {
progress?: number | null;
status?: string | null;
@@ -154,32 +153,6 @@ function buildGuessitTitle(title: string, alternativeTitle: string | null): stri
return title;
}
function normalizeTitle(text: string): string {
return text.trim().toLowerCase().replace(/\s+/g, ' ');
}
function titleMentionsSeason(title: string, season: number): boolean {
const normalized = normalizeTitle(title);
return (
normalized.includes(`season ${season}`) ||
normalized.includes(`s${String(season).padStart(2, '0')}`) ||
normalized.includes(`s${season}`)
);
}
function buildSearchCandidates(title: string, season: number | null | undefined): string[] {
const trimmed = title.trim();
if (!trimmed) return [];
const candidates =
typeof season === 'number' &&
Number.isInteger(season) &&
season > 1 &&
!titleMentionsSeason(trimmed, season)
? [`${trimmed} Season ${season}`, trimmed]
: [trimmed];
return candidates.filter((candidate, index, all) => all.indexOf(candidate) === index);
}
async function anilistGraphQl<T>(
accessToken: string,
query: string,
@@ -216,38 +189,22 @@ function firstErrorMessage<T>(response: AnilistGraphQlResponse<T>): string | nul
return firstError?.message ?? null;
}
function pickBestSearchResult(
title: string,
episode: number,
media: Array<{
id: number;
episodes: number | null;
title?: {
romaji?: string | null;
english?: string | null;
native?: string | null;
};
}>,
): { id: number; title: string; episodes: number | null } | null {
const filtered = media.filter((item) => {
const totalEpisodes = item.episodes;
return totalEpisodes === null || totalEpisodes >= episode;
});
const candidates = filtered.length > 0 ? filtered : media;
if (candidates.length === 0) return null;
const normalizedTarget = normalizeTitle(title);
const exact = candidates.find((item) => {
const titles = [item.title?.romaji, item.title?.english, item.title?.native]
.filter((value): value is string => typeof value === 'string')
.map((value) => normalizeTitle(value));
return titles.includes(normalizedTarget);
});
const selected = exact ?? candidates[0]!;
const selectedTitle =
selected.title?.english || selected.title?.romaji || selected.title?.native || title;
return { id: selected.id, title: selectedTitle, episodes: selected.episodes };
/** The season resolver signals failure by throwing; anilistGraphQl reports it in-band. */
function createAnilistSeasonQueryExecutor(
accessToken: string,
options: AnilistPostWatchUpdateOptions,
) {
return async <T>(query: string, variables: Record<string, unknown>): Promise<T> => {
const response = await anilistGraphQl<T>(accessToken, query, variables, options);
const error = firstErrorMessage(response);
if (error) {
throw new Error(error);
}
if (!response.data) {
throw new Error('AniList response missing data');
}
return response.data;
};
}
function isUpdateableListStatus(status: string | null | undefined): boolean {
@@ -321,52 +278,53 @@ export async function updateAnilistPostWatchProgress(
episode: number,
options: AnilistPostWatchUpdateOptions = {},
): Promise<AnilistPostWatchUpdateResult> {
let media: NonNullable<NonNullable<AnilistSearchData['Page']>['media']> = [];
let searchError: string | null = null;
let pickTitle = title;
const searchCandidates = buildSearchCandidates(title, options.season);
for (const search of searchCandidates) {
const searchResponse = await anilistGraphQl<AnilistSearchData>(
accessToken,
`
query ($search: String!) {
Page(perPage: 5) {
media(search: $search, type: ANIME) {
id
episodes
title {
romaji
english
native
}
}
}
}
`,
{ search },
options,
);
searchError = firstErrorMessage(searchResponse);
if (searchError) {
break;
}
media = searchResponse.data?.Page?.media ?? [];
if (media.length > 0) {
pickTitle = search;
break;
}
}
const pinnedMediaId =
typeof options.mediaId === 'number' && Number.isInteger(options.mediaId) && options.mediaId > 0
? options.mediaId
: null;
if (searchError) {
return {
status: 'error',
message: `AniList search failed: ${searchError}`,
};
}
let mediaId = pinnedMediaId;
let resolvedTitle: string | null = null;
let resolvedEpisodes: number | null = null;
const picked = pickBestSearchResult(pickTitle, episode, media);
if (!picked) {
return { status: 'error', message: 'AniList search returned no matches.' };
if (mediaId === null) {
let resolution: Awaited<ReturnType<typeof resolveAnilistSeasonMedia>>;
try {
resolution = await resolveAnilistSeasonMedia(
{ title, season: options.season, episode },
{
execute: createAnilistSeasonQueryExecutor(accessToken, options),
logInfo: options.logInfo,
},
);
} catch (error) {
return {
status: 'error',
message: `AniList search failed: ${error instanceof Error ? error.message : String(error)}`,
};
}
if (!resolution) {
// A well-formed search that matched nothing is deterministic for this title, so
// requeueing it just burns rate limit. Transient failures throw and are caught above.
return {
status: 'error',
retryable: false,
message: 'AniList search returned no matches.',
};
}
if (!resolution.seasonResolved) {
// Updating the season 1 entry here is worse than not updating at all.
return {
status: 'error',
retryable: false,
message: `AniList update skipped: could not find season ${resolution.requestedSeason} of "${title}" (only matched "${resolution.title}"). Pick the right entry with the character dictionary AniList override.`,
};
}
mediaId = resolution.id;
resolvedTitle = resolution.title;
resolvedEpisodes = resolution.episodes;
}
const entryResponse = await anilistGraphQl<AnilistMediaEntryData>(
@@ -375,6 +333,12 @@ export async function updateAnilistPostWatchProgress(
query ($mediaId: Int!) {
Media(id: $mediaId, type: ANIME) {
id
episodes
title {
romaji
english
native
}
mediaListEntry {
progress
status
@@ -382,7 +346,7 @@ export async function updateAnilistPostWatchProgress(
}
}
`,
{ mediaId: picked.id },
{ mediaId },
options,
);
const entryError = firstErrorMessage(entryResponse);
@@ -393,21 +357,34 @@ export async function updateAnilistPostWatchProgress(
};
}
const entry = entryResponse.data?.Media?.mediaListEntry ?? null;
const entryMedia = entryResponse.data?.Media ?? null;
const pickedTitle =
resolvedTitle ||
entryMedia?.title?.english?.trim() ||
entryMedia?.title?.romaji?.trim() ||
entryMedia?.title?.native?.trim() ||
title;
const pickedEpisodes =
resolvedEpisodes ??
(typeof entryMedia?.episodes === 'number' && entryMedia.episodes > 0
? entryMedia.episodes
: null);
const entry = entryMedia?.mediaListEntry ?? null;
if (!entry || !isUpdateableListStatus(entry.status)) {
return {
status: 'error',
retryable: false,
message: `AniList update not possible: "${picked.title}" is ${formatListStatus(entry?.status)}. Add it to Planning or Watching, then mark watched again.`,
message: `AniList update not possible: "${pickedTitle}" is ${formatListStatus(entry?.status)}. Add it to Planning or Watching, then mark watched again.`,
};
}
const currentProgress = entry.progress ?? 0;
const shouldMarkCompleted = isKnownFinalEpisode(picked.episodes, episode);
const shouldMarkCompleted = isKnownFinalEpisode(pickedEpisodes, episode);
if (typeof currentProgress === 'number' && currentProgress >= episode && !shouldMarkCompleted) {
return {
status: 'skipped',
message: `AniList already at episode ${currentProgress} (${picked.title}).`,
message: `AniList already at episode ${currentProgress} (${pickedTitle}).`,
};
}
@@ -422,7 +399,7 @@ export async function updateAnilistPostWatchProgress(
}
`,
{
mediaId: picked.id,
mediaId,
progress: episode,
status: shouldMarkCompleted ? 'COMPLETED' : 'CURRENT',
},
@@ -436,7 +413,7 @@ export async function updateAnilistPostWatchProgress(
return {
status: 'updated',
message: shouldMarkCompleted
? `AniList updated "${picked.title}" to episode ${episode} and marked it completed.`
: `AniList updated "${picked.title}" to episode ${episode}.`,
? `AniList updated "${pickedTitle}" to episode ${episode} and marked it completed.`
: `AniList updated "${pickedTitle}" to episode ${episode}.`,
};
}
@@ -201,17 +201,45 @@ test('fetchIfMissing uses guessit primary title and season when available', asyn
});
const searchCalls: Array<{ search: string }> = [];
const relationCalls: number[] = [];
const originalFetch = globalThis.fetch;
globalThis.fetch = ((input: RequestInfo | URL, init?: RequestInit) => {
const raw = (init?.body as string | undefined) ?? '';
const payload = JSON.parse(raw) as { variables: { search: string } };
const search = payload.variables.search;
searchCalls.push({ search });
const payload = JSON.parse(raw) as { variables: { search?: string; id?: number } };
if (search.includes('Season 2')) {
return Promise.resolve(createJsonResponse({ data: { Page: { media: [] } } }));
if (typeof payload.variables.id === 'number') {
relationCalls.push(payload.variables.id);
return Promise.resolve(
createJsonResponse({
data: {
Media: {
relations: {
edges: [
{
relationType: 'SEQUEL',
node: {
id: 20,
type: 'ANIME',
episodes: 25,
format: 'TV',
seasonYear: 2017,
coverImage: { large: 'https://images.test/cover-s2.jpg', medium: null },
title: {
romaji: 'Little Witch Academia 2',
english: 'Little Witch Academia 2',
native: null,
},
},
},
],
},
},
},
}),
);
}
searchCalls.push({ search: String(payload.variables.search) });
return Promise.resolve(
createJsonResponse({
data: {
@@ -220,6 +248,8 @@ test('fetchIfMissing uses guessit primary title and season when available', asyn
{
id: 19,
episodes: 24,
format: 'TV',
seasonYear: 2013,
coverImage: { large: 'https://images.test/cover.jpg', medium: null },
title: {
romaji: 'Little Witch Academia',
@@ -251,9 +281,11 @@ test('fetchIfMissing uses guessit primary title and season when available', asyn
const stored = getCoverArt(db, videoId);
assert.equal(fetched, true);
assert.equal(searchCalls.length, 2);
assert.equal(searchCalls[0]!.search, 'Little Witch Academia Season 2');
assert.equal(stored?.anilistId, 19);
// One search on the bare title, then a sequel hop to reach season 2.
assert.equal(searchCalls.length, 1);
assert.equal(searchCalls[0]!.search, 'Little Witch Academia');
assert.deepEqual(relationCalls, [19]);
assert.equal(stored?.anilistId, 20);
} finally {
globalThis.fetch = originalFetch;
db.close();
@@ -324,3 +356,187 @@ test('fetchIfMissing falls back to internal parser when guessit throws', async (
cleanupDbPath(dbPath);
}
});
test('fetchIfMissing caches a no-match when the season cannot be resolved', async () => {
const dbPath = makeDbPath();
const db = new Database(dbPath);
ensureSchema(db);
const videoId = getOrCreateVideoRecord(db, 'local:/tmp/cover-fetcher-unresolved.mkv', {
canonicalTitle: 'Unresolved Show (2013) - S03E01 - Something [1080p].mkv',
sourcePath: '/tmp/cover-fetcher-unresolved.mkv',
sourceType: SOURCE_TYPE_LOCAL,
sourceUrl: null,
});
const originalFetch = globalThis.fetch;
globalThis.fetch = ((input: RequestInfo | URL, init?: RequestInit) => {
const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url;
if (!url.includes('graphql')) {
return Promise.resolve(
new Response(Buffer.from('01020304'), {
status: 200,
headers: { 'content-type': 'image/png' },
}),
);
}
const payload = JSON.parse(String(init?.body ?? '{}')) as {
variables?: { search?: string; id?: number };
};
if (typeof payload.variables?.id === 'number') {
// No sequel edges, so season 3 cannot be reached from the season 1 anchor.
return Promise.resolve(createJsonResponse({ data: { Media: { relations: { edges: [] } } } }));
}
return Promise.resolve(
createJsonResponse({
data: {
Page: {
media: [
{
id: 55,
episodes: 13,
format: 'TV',
seasonYear: 2013,
coverImage: { large: 'https://images.test/s1.jpg', medium: null },
title: { romaji: 'Unresolved Show', english: 'Unresolved Show', native: null },
},
],
},
},
}),
);
}) as typeof fetch;
try {
const fetcher = createCoverArtFetcher(
{ acquire: async () => {}, recordResponse: () => {} },
console,
{
runGuessit: async () =>
JSON.stringify({ title: 'Unresolved Show', season: 3, episode: 1, year: 2013 }),
},
);
const fetched = await fetcher.fetchIfMissing(db, videoId, 'Unresolved Show');
const stored = getCoverArt(db, videoId);
assert.equal(fetched, false);
// Storing the season 1 artwork would leave a blob with no AniList id, which the
// `existing.coverBlob` early return serves forever - the season could then never
// re-resolve. A plain no-match keeps the existing retry window in play instead.
assert.equal(stored?.coverBlob, null);
assert.equal(stored?.coverUrl, null);
assert.equal(stored?.anilistId, null);
assert.equal(stored?.episodesTotal, null);
} finally {
globalThis.fetch = originalFetch;
db.close();
cleanupDbPath(dbPath);
}
});
test('fetchIfMissing re-resolves an unresolved season once AniList publishes the relation', async () => {
const dbPath = makeDbPath();
const db = new Database(dbPath);
ensureSchema(db);
const videoId = getOrCreateVideoRecord(db, 'local:/tmp/cover-fetcher-recovers.mkv', {
canonicalTitle: 'Recovering Show (2013) - S02E01 - Something [1080p].mkv',
sourcePath: '/tmp/cover-fetcher-recovers.mkv',
sourceType: SOURCE_TYPE_LOCAL,
sourceUrl: null,
});
let sequelPublished = false;
const originalFetch = globalThis.fetch;
const originalNow = Date.now;
globalThis.fetch = ((input: RequestInfo | URL, init?: RequestInit) => {
const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url;
if (!url.includes('graphql')) {
return Promise.resolve(
new Response(Buffer.from('01020304'), {
status: 200,
headers: { 'content-type': 'image/png' },
}),
);
}
const payload = JSON.parse(String(init?.body ?? '{}')) as {
variables?: { search?: string; id?: number };
};
if (typeof payload.variables?.id === 'number') {
return Promise.resolve(
createJsonResponse({
data: {
Media: {
relations: {
edges: sequelPublished
? [
{
relationType: 'SEQUEL',
node: {
id: 66,
type: 'ANIME',
episodes: 12,
format: 'TV',
seasonYear: 2015,
coverImage: { large: 'https://images.test/s2.jpg', medium: null },
title: { romaji: 'Recovering Show 2', english: null, native: null },
},
},
]
: [],
},
},
},
}),
);
}
return Promise.resolve(
createJsonResponse({
data: {
Page: {
media: [
{
id: 65,
episodes: 13,
format: 'TV',
seasonYear: 2013,
coverImage: { large: 'https://images.test/s1.jpg', medium: null },
title: { romaji: 'Recovering Show', english: null, native: null },
},
],
},
},
}),
);
}) as typeof fetch;
try {
const fetcher = createCoverArtFetcher(
{ acquire: async () => {}, recordResponse: () => {} },
console,
{
runGuessit: async () =>
JSON.stringify({ title: 'Recovering Show', season: 2, episode: 1, year: 2013 }),
},
);
assert.equal(await fetcher.fetchIfMissing(db, videoId, 'Recovering Show'), false);
assert.equal(getCoverArt(db, videoId)?.anilistId, null);
// AniList publishes the sequel relation, and the no-match retry window elapses.
sequelPublished = true;
const base = originalNow();
Date.now = () => base + 10 * 60 * 1000;
assert.equal(await fetcher.fetchIfMissing(db, videoId, 'Recovering Show'), true);
const recovered = getCoverArt(db, videoId);
assert.equal(recovered?.anilistId, 66);
assert.equal(recovered?.episodesTotal, 12);
} finally {
Date.now = originalNow;
globalThis.fetch = originalFetch;
db.close();
cleanupDbPath(dbPath);
}
});
+66 -192
View File
@@ -11,43 +11,15 @@ import {
runGuessit,
type GuessAnilistMediaInfoDeps,
} from './anilist-updater';
import {
resolveAnilistSeasonMedia,
type AnilistQueryExecutor,
type AnilistSeasonResolution,
} from './season-resolver';
const ANILIST_GRAPHQL_URL = 'https://graphql.anilist.co';
const NO_MATCH_RETRY_MS = 5 * 60 * 1000;
const SEARCH_QUERY = `
query ($search: String!) {
Page(perPage: 5) {
media(search: $search, type: ANIME) {
id
episodes
season
seasonYear
coverImage { large medium }
title { romaji english native }
}
}
}
`;
interface AnilistMedia {
id: number;
episodes: number | null;
season: string | null;
seasonYear: number | null;
coverImage: { large: string | null; medium: string | null } | null;
title: { romaji: string | null; english: string | null; native: string | null } | null;
}
interface AnilistSearchResponse {
data?: {
Page?: {
media?: AnilistMedia[];
};
};
errors?: Array<{ message?: string }>;
}
export interface CoverArtFetcher {
fetchIfMissing(db: DatabaseSync, videoId: number, canonicalTitle: string): Promise<boolean>;
}
@@ -99,151 +71,45 @@ export function stripFilenameTags(raw: string): string {
return title.trim().replace(/\s{2,}/g, ' ');
}
function removeSeasonHint(title: string): string {
return title
.replace(/\bseason\s*\d+\b/gi, '')
.replace(/\s{2,}/g, ' ')
.trim();
}
function normalizeTitle(text: string): string {
return text.trim().toLowerCase().replace(/\s+/g, ' ');
}
function extractCandidateSeasonHints(text: string): Set<number> {
const normalized = normalizeTitle(text);
const matches = [
...normalized.matchAll(/\bseason\s*(\d{1,2})\b/gi),
...normalized.matchAll(/\bs(\d{1,2})(?:\b|\D)/gi),
];
const values = new Set<number>();
for (const match of matches) {
const value = Number.parseInt(match[1]!, 10);
if (Number.isInteger(value)) {
values.add(value);
}
class AnilistRateLimitedError extends Error {
constructor() {
super('Anilist rate limit reached');
this.name = 'AnilistRateLimitedError';
}
return values;
}
function isSeasonMentioned(titles: string[], season: number | null): boolean {
if (!season) {
return false;
}
const hints = titles.flatMap((title) => [...extractCandidateSeasonHints(title)]);
return hints.includes(season);
}
function pickBestSearchResult(
title: string,
episode: number | null,
season: number | null,
media: AnilistMedia[],
): { id: number; title: string } | null {
const cleanedTitle = removeSeasonHint(title);
const targets = [title, cleanedTitle]
.map(normalizeTitle)
.map((value) => value.trim())
.filter((value, index, all) => value.length > 0 && all.indexOf(value) === index);
const filtered =
episode === null
? media
: media.filter((item) => {
const total = item.episodes;
return total === null || total >= episode;
});
const candidates = filtered.length > 0 ? filtered : media;
if (candidates.length === 0) {
return null;
}
const scored = candidates.map((item) => {
const candidateTitles = [item.title?.romaji, item.title?.english, item.title?.native]
.filter((value): value is string => typeof value === 'string')
.map((value) => normalizeTitle(value));
let score = 0;
for (const target of targets) {
if (candidateTitles.includes(target)) {
score += 120;
continue;
}
if (candidateTitles.some((itemTitle) => itemTitle.includes(target))) {
score += 30;
}
if (candidateTitles.some((itemTitle) => target.includes(itemTitle))) {
score += 10;
}
}
if (episode !== null && item.episodes === episode) {
score += 20;
}
if (season !== null && isSeasonMentioned(candidateTitles, season)) {
score += 15;
}
return { item, score };
});
scored.sort((a, b) => {
if (b.score !== a.score) return b.score - a.score;
return b.item.id - a.item.id;
});
const selected = scored[0]!;
const selectedTitle =
selected.item.title?.english ??
selected.item.title?.romaji ??
selected.item.title?.native ??
title;
return { id: selected.item.id, title: selectedTitle };
}
function buildSearchCandidates(parsed: CoverArtCandidate): string[] {
const candidateTitles = [
...(parsed.source === 'guessit' && parsed.season !== null && parsed.season > 1
? [`${parsed.title} Season ${parsed.season}`]
: []),
parsed.title,
];
return candidateTitles
.map((title) => title.trim())
.filter((title, index, all) => title.length > 0 && all.indexOf(title) === index);
}
async function searchAnilist(
async function executeAnilistQuery<T>(
rateLimiter: AnilistRateLimiter,
title: string,
): Promise<{ media: AnilistMedia[]; rateLimited: boolean }> {
query: string,
variables: Record<string, unknown>,
): Promise<T> {
await rateLimiter.acquire();
const res = await fetch(ANILIST_GRAPHQL_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
body: JSON.stringify({ query: SEARCH_QUERY, variables: { search: title } }),
body: JSON.stringify({ query, variables }),
});
rateLimiter.recordResponse(res.headers);
if (res.status === 429) {
return { media: [], rateLimited: true };
throw new AnilistRateLimitedError();
}
if (!res.ok) {
throw new Error(`Anilist search failed: ${res.status} ${res.statusText}`);
}
const json = (await res.json()) as AnilistSearchResponse;
const mediaList = json.data?.Page?.media;
if (!mediaList || mediaList.length === 0) {
return { media: [], rateLimited: false };
const json = (await res.json()) as { data?: T; errors?: Array<{ message?: string }> };
const firstError = json.errors?.find((entry) => Boolean(entry?.message));
if (firstError?.message) {
throw new Error(firstError.message);
}
return { media: mediaList, rateLimited: false };
if (!json.data) {
throw new Error('Anilist response missing data');
}
return json.data;
}
async function downloadImage(url: string): Promise<Buffer | null> {
@@ -376,49 +242,57 @@ export function createCoverArtFetcher(
const parsedInfo = await resolveMediaInfo(db, videoId, canonicalTitle);
const searchBase = parsedInfo?.title ?? cleaned;
const searchCandidates = parsedInfo ? buildSearchCandidates(parsedInfo) : [cleaned];
const searchTitles = searchBase === cleaned ? [searchBase] : ([searchBase, cleaned] as const);
const effectiveCandidates = searchCandidates.includes(cleaned)
? searchCandidates
: [...searchCandidates, cleaned];
const execute: AnilistQueryExecutor = (query, variables) =>
executeAnilistQuery(rateLimiter, query, variables);
let selected: AnilistMedia | null = null;
let rateLimited = false;
for (const candidate of effectiveCandidates) {
logger.info('cover-art: searching Anilist for "%s" (videoId=%d)', candidate, videoId);
try {
const result = await searchAnilist(rateLimiter, candidate);
rateLimited = result.rateLimited;
if (result.media.length === 0) {
continue;
}
const picked = pickBestSearchResult(
searchBase,
parsedInfo?.episode ?? null,
parsedInfo?.season ?? null,
result.media,
let resolution: AnilistSeasonResolution | null = null;
try {
for (const searchTitle of searchTitles) {
logger.info('cover-art: searching Anilist for "%s" (videoId=%d)', searchTitle, videoId);
resolution = await resolveAnilistSeasonMedia(
{
title: searchTitle,
season: parsedInfo?.season ?? null,
episode: parsedInfo?.episode ?? null,
},
{ execute, logInfo: (message) => logger.info('%s', message) },
);
if (picked) {
const match = result.media.find((media) => media.id === picked.id);
if (match) {
selected = match;
break;
}
}
} catch (err) {
logger.error('cover-art: Anilist search error for "%s": %s', candidate, err);
if (resolution) break;
}
} catch (err) {
if (err instanceof AnilistRateLimitedError) {
logger.warn('cover-art: rate-limited by Anilist, skipping videoId=%d', videoId);
return false;
}
}
if (rateLimited) {
logger.warn('cover-art: rate-limited by Anilist, skipping videoId=%d', videoId);
logger.error('cover-art: Anilist search error for "%s": %s', searchBase, err);
return false;
}
if (resolution && !resolution.seasonResolved) {
// Only the season 1 entry was found. Storing its artwork would leave a cover with
// no AniList id, which the `existing.coverBlob` early return above serves forever,
// so the season could never re-resolve once AniList publishes the relation.
// Caching a plain no-match instead reuses the NO_MATCH_RETRY_MS retry window.
logger.warn(
'cover-art: could not find season %d of "%s" (only matched "%s"), caching no-match',
resolution.requestedSeason,
searchBase,
resolution.title,
);
upsertCoverArt(db, videoId, {
anilistId: null,
coverUrl: null,
coverBlob: null,
titleRomaji: null,
titleEnglish: null,
episodesTotal: null,
});
return false;
}
const selected = resolution?.media ?? null;
if (!selected) {
logger.info('cover-art: no Anilist results for "%s", caching no-match', searchBase);
upsertCoverArt(db, videoId, {
@@ -0,0 +1,307 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
ANILIST_SEASON_RELATIONS_QUERY,
ANILIST_SEASON_SEARCH_QUERY,
pickByAirOrder,
resolveAnilistSeasonMedia,
stripSeasonSuffix,
type AnilistSeasonMedia,
} from './season-resolver';
/** Real AniList payloads for "My Teen Romantic Comedy SNAFU", trimmed to used fields. */
const OREGAIRU_SEARCH: AnilistSeasonMedia[] = [
{
id: 14813,
episodes: 13,
format: 'TV',
seasonYear: 2013,
title: {
romaji: 'Yahari Ore no Seishun Love Come wa Machigatteiru.',
english: 'My Teen Romantic Comedy SNAFU',
native: null,
},
},
{
id: 18753,
episodes: 1,
format: 'OVA',
seasonYear: 2013,
title: { romaji: null, english: 'My Teen Romantic Comedy SNAFU OVA', native: null },
},
{
id: 108489,
episodes: 12,
format: 'TV',
seasonYear: 2020,
title: {
romaji: 'Yahari Ore no Seishun Love Come wa Machigatteiru. Kan',
english: 'My Teen Romantic Comedy SNAFU Climax!',
native: null,
},
},
{
id: 20698,
episodes: 13,
format: 'TV',
seasonYear: 2015,
title: {
romaji: 'Yahari Ore no Seishun Love Come wa Machigatteiru. Zoku',
english: 'My Teen Romantic Comedy SNAFU TOO!',
native: null,
},
},
];
const OREGAIRU_RELATIONS: Record<
number,
Array<{ relationType: string; node: AnilistSeasonMedia }>
> = {
14813: [
{
relationType: 'SIDE_STORY',
node: OREGAIRU_SEARCH[1]!,
},
{
relationType: 'SEQUEL',
node: OREGAIRU_SEARCH[3]!,
},
],
20698: [
{
relationType: 'PREQUEL',
node: OREGAIRU_SEARCH[0]!,
},
{
relationType: 'SEQUEL',
node: OREGAIRU_SEARCH[2]!,
},
],
108489: [
{
relationType: 'PREQUEL',
node: OREGAIRU_SEARCH[3]!,
},
],
};
function createExecutor(
search: AnilistSeasonMedia[],
relations: Record<number, Array<{ relationType: string; node: AnilistSeasonMedia }>> = {},
) {
const searches: string[] = [];
const relationLookups: number[] = [];
const execute = async <T>(query: string, variables: Record<string, unknown>): Promise<T> => {
if (query === ANILIST_SEASON_SEARCH_QUERY) {
searches.push(String(variables.search));
return { Page: { media: search } } as T;
}
if (query === ANILIST_SEASON_RELATIONS_QUERY) {
const id = Number(variables.id);
relationLookups.push(id);
return {
Media: {
relations: {
edges: (relations[id] ?? []).map((edge) => ({
relationType: edge.relationType,
node: { ...edge.node, type: 'ANIME' },
})),
},
},
} as T;
}
throw new Error(`unexpected query: ${query}`);
};
return { execute, searches, relationLookups };
}
test('stripSeasonSuffix drops release-name season markers', () => {
assert.equal(stripSeasonSuffix('Some Show Season 3'), 'Some Show');
assert.equal(stripSeasonSuffix('Some Show S3'), 'Some Show');
assert.equal(stripSeasonSuffix('Some Show 2nd Season'), 'Some Show');
assert.equal(stripSeasonSuffix('Some Show'), 'Some Show');
});
test('resolves season 3 by walking sequel relations', async () => {
const { execute, searches, relationLookups } = createExecutor(
OREGAIRU_SEARCH,
OREGAIRU_RELATIONS,
);
const result = await resolveAnilistSeasonMedia(
{ title: 'My Teen Romantic Comedy SNAFU', season: 3, episode: 1 },
{ execute },
);
assert.equal(result?.id, 108489);
assert.equal(result?.title, 'My Teen Romantic Comedy SNAFU Climax!');
assert.equal(result?.episodes, 12);
assert.equal(result?.seasonResolved, true);
assert.equal(result?.via, 'sequel-chain');
// Searches the bare title only; "Season 3" returns nothing on AniList.
assert.deepEqual(searches, ['My Teen Romantic Comedy SNAFU']);
assert.deepEqual(relationLookups, [14813, 20698]);
});
test('season 1 resolves to the anchor without relation lookups', async () => {
const { execute, relationLookups } = createExecutor(OREGAIRU_SEARCH, OREGAIRU_RELATIONS);
const result = await resolveAnilistSeasonMedia(
{ title: 'My Teen Romantic Comedy SNAFU', season: 1, episode: 5 },
{ execute },
);
assert.equal(result?.id, 14813);
assert.equal(result?.seasonResolved, true);
assert.equal(result?.via, 'anchor');
assert.deepEqual(relationLookups, []);
});
test('strips a season marker already present in the parsed title', async () => {
const { execute, searches } = createExecutor(OREGAIRU_SEARCH, OREGAIRU_RELATIONS);
const result = await resolveAnilistSeasonMedia(
{ title: 'My Teen Romantic Comedy SNAFU Season 2', season: 2, episode: 3 },
{ execute },
);
assert.deepEqual(searches, ['My Teen Romantic Comedy SNAFU']);
assert.equal(result?.id, 20698);
assert.equal(result?.seasonResolved, true);
});
test('falls back to air order when the sequel chain is broken', async () => {
const { execute } = createExecutor(OREGAIRU_SEARCH, {});
const result = await resolveAnilistSeasonMedia(
{ title: 'My Teen Romantic Comedy SNAFU', season: 3, episode: 1 },
{ execute },
);
assert.equal(result?.id, 108489);
assert.equal(result?.via, 'air-order');
assert.equal(result?.seasonResolved, true);
});
test('reports seasonResolved false rather than silently using season 1', async () => {
const { execute } = createExecutor([OREGAIRU_SEARCH[0]!], {});
const result = await resolveAnilistSeasonMedia(
{ title: 'My Teen Romantic Comedy SNAFU', season: 3, episode: 1 },
{ execute },
);
assert.equal(result?.id, 14813);
assert.equal(result?.seasonResolved, false);
assert.equal(result?.via, 'anchor');
assert.equal(result?.requestedSeason, 3);
});
test('does not filter the anchor by an episode count the season 1 entry cannot have', async () => {
const { execute } = createExecutor(OREGAIRU_SEARCH, OREGAIRU_RELATIONS);
// Season 2 episode 13 exceeds nothing, but season 3 episode 13 exceeds Climax!'s 12.
const result = await resolveAnilistSeasonMedia(
{ title: 'My Teen Romantic Comedy SNAFU', season: 2, episode: 13 },
{ execute },
);
assert.equal(result?.id, 20698);
assert.equal(result?.seasonResolved, true);
});
test('prefers the TV sequel when a franchise branches into other formats', async () => {
const branching: Record<number, Array<{ relationType: string; node: AnilistSeasonMedia }>> = {
1: [
{
relationType: 'SEQUEL',
node: {
id: 3,
episodes: 6,
format: 'ONA',
seasonYear: 2019,
title: { english: 'Spinoff' },
},
},
{
relationType: 'SEQUEL',
node: { id: 2, episodes: 12, format: 'TV', seasonYear: 2020, title: { english: 'Show 2' } },
},
],
};
const { execute } = createExecutor(
[{ id: 1, episodes: 12, format: 'TV', seasonYear: 2018, title: { english: 'Show' } }],
branching,
);
const result = await resolveAnilistSeasonMedia({ title: 'Show', season: 2 }, { execute });
assert.equal(result?.id, 2);
assert.equal(result?.via, 'sequel-chain');
});
test('air-order fallback refuses when the anchor is not the earliest entry', () => {
const anchor: AnilistSeasonMedia = {
id: 2,
format: 'TV',
seasonYear: 2020,
title: { english: 'Show Later' },
};
const picked = pickByAirOrder(anchor, 2, [
anchor,
{ id: 1, format: 'TV', seasonYear: 2015, title: { english: 'Show Earlier' } },
]);
assert.equal(picked, null);
});
test('returns null when the search yields nothing', async () => {
const { execute } = createExecutor([]);
const result = await resolveAnilistSeasonMedia({ title: 'Nothing', season: 2 }, { execute });
assert.equal(result, null);
});
test('refuses a season beyond the sequel-hop cap instead of returning a partial walk', async () => {
// A 30-entry sequel chain: hopping the capped number of times would land on the wrong
// season and report it resolved, so the walk must decline outright.
const chain: Record<number, Array<{ relationType: string; node: AnilistSeasonMedia }>> = {};
for (let id = 1; id < 30; id += 1) {
chain[id] = [
{
relationType: 'SEQUEL',
node: {
id: id + 1,
episodes: 12,
format: 'TV',
seasonYear: 2000 + id,
title: { english: `Show ${id + 1}` },
},
},
];
}
const { execute, relationLookups } = createExecutor(
[{ id: 1, episodes: 12, format: 'TV', seasonYear: 2000, title: { english: 'Show' } }],
chain,
);
const result = await resolveAnilistSeasonMedia({ title: 'Show', season: 25 }, { execute });
assert.equal(result?.id, 1);
assert.equal(result?.seasonResolved, false);
assert.equal(result?.via, 'anchor');
// Declines before spending any relation requests.
assert.deepEqual(relationLookups, []);
});
test('air-order fallback declines when a franchise entry has no air year', async () => {
// The middle season has no year, so ordering it would shift every later season by one.
const { execute } = createExecutor(
[
{ id: 1, episodes: 12, format: 'TV', seasonYear: 2013, title: { english: 'Show' } },
{ id: 2, episodes: 12, format: 'TV', title: { english: 'Show Zoku' } },
{ id: 3, episodes: 12, format: 'TV', seasonYear: 2020, title: { english: 'Show Kan' } },
],
{},
);
const result = await resolveAnilistSeasonMedia({ title: 'Show', season: 2 }, { execute });
assert.equal(result?.id, 1);
assert.equal(result?.seasonResolved, false);
assert.equal(result?.via, 'anchor');
});
@@ -0,0 +1,407 @@
/**
* AniList has no concept of "season N": sequels are separate media with their own
* titles (Zoku, Kan, 2nd Season, ...). Searching "<title> Season 3" therefore returns
* nothing and callers silently fall back to the season 1 entry.
*
* This module resolves a parsed (title, season) pair to the right media by locating a
* season 1 anchor and then walking AniList SEQUEL relations, with an air-order fallback
* when the relation chain is incomplete. When the seasonal entry cannot be located it
* reports `seasonResolved: false` so callers can refuse to act instead of guessing.
*/
export interface AnilistSeasonMediaTitle {
romaji?: string | null;
english?: string | null;
native?: string | null;
}
export interface AnilistSeasonMedia {
id: number;
episodes?: number | null;
format?: string | null;
seasonYear?: number | null;
startDate?: { year?: number | null } | null;
synonyms?: Array<string | null> | null;
coverImage?: { large?: string | null; medium?: string | null } | null;
title?: AnilistSeasonMediaTitle | null;
}
export type AnilistQueryExecutor = <T>(
query: string,
variables: Record<string, unknown>,
) => Promise<T>;
export type AnilistSeasonResolutionVia = 'anchor' | 'sequel-chain' | 'air-order';
export interface AnilistSeasonResolution {
id: number;
title: string;
episodes: number | null;
media: AnilistSeasonMedia;
/** False when a season >= 2 was requested but no seasonal entry could be located. */
seasonResolved: boolean;
requestedSeason: number | null;
via: AnilistSeasonResolutionVia;
}
export interface ResolveAnilistSeasonMediaInput {
title: string;
season?: number | null;
episode?: number | null;
}
export interface ResolveAnilistSeasonMediaDeps {
execute: AnilistQueryExecutor;
logInfo?: (message: string) => void;
}
const MEDIA_FIELDS = `
id
episodes
format
seasonYear
startDate { year }
synonyms
coverImage { large medium }
title { romaji english native }
`;
export const ANILIST_SEASON_SEARCH_QUERY = `
query ($search: String!) {
Page(perPage: 10) {
media(search: $search, type: ANIME, sort: [SEARCH_MATCH, POPULARITY_DESC]) {
${MEDIA_FIELDS}
}
}
}
`;
export const ANILIST_SEASON_RELATIONS_QUERY = `
query ($id: Int!) {
Media(id: $id, type: ANIME) {
id
relations {
edges {
relationType
node {
type
${MEDIA_FIELDS}
}
}
}
}
}
`;
interface AnilistSeasonSearchResponse {
Page?: {
media?: AnilistSeasonMedia[] | null;
} | null;
}
interface AnilistSeasonRelationsResponse {
Media?: {
relations?: {
edges?: Array<{
relationType?: string | null;
node?: (AnilistSeasonMedia & { type?: string | null }) | null;
} | null> | null;
} | null;
} | null;
}
/** Formats that can carry a numbered TV season, best first. */
const SEASONAL_FORMAT_PRIORITY = ['TV', 'TV_SHORT', 'ONA'];
const MAX_SEQUEL_HOPS = 12;
function normalizeTitle(value: string): string {
return value.trim().toLowerCase().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".
*/
export function stripSeasonSuffix(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, ' ')
.replace(/\s{2,}/g, ' ')
.trim();
}
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));
}
function displayTitle(media: AnilistSeasonMedia, fallback: string): string {
return (
media.title?.english?.trim() ||
media.title?.romaji?.trim() ||
media.title?.native?.trim() ||
fallback.trim()
);
}
function episodeCount(media: AnilistSeasonMedia): number | null {
return typeof media.episodes === 'number' && media.episodes > 0 ? media.episodes : null;
}
function airYear(media: AnilistSeasonMedia): number | null {
if (typeof media.seasonYear === 'number' && media.seasonYear > 0) {
return media.seasonYear;
}
const startYear = media.startDate?.year;
return typeof startYear === 'number' && startYear > 0 ? startYear : null;
}
function isSeasonalFormat(media: AnilistSeasonMedia): boolean {
const format = (media.format || '').toUpperCase();
return SEASONAL_FORMAT_PRIORITY.includes(format);
}
function formatRank(media: AnilistSeasonMedia): number {
const index = SEASONAL_FORMAT_PRIORITY.indexOf((media.format || '').toUpperCase());
return index < 0 ? SEASONAL_FORMAT_PRIORITY.length : index;
}
function toResolution(
media: AnilistSeasonMedia,
fallbackTitle: string,
season: number | null,
via: AnilistSeasonResolutionVia,
seasonResolved: boolean,
): AnilistSeasonResolution {
return {
id: media.id,
title: displayTitle(media, fallbackTitle),
episodes: episodeCount(media),
media,
seasonResolved,
requestedSeason: season,
via,
};
}
/**
* Picks the franchise anchor (season 1 entry) for a search result set. The anchor is
* matched on title alone - season is handled by walking relations from here.
*/
export function pickAnchorMedia(
title: string,
media: AnilistSeasonMedia[],
options: { episode?: number | null } = {},
): AnilistSeasonMedia | null {
if (media.length === 0) return null;
const episode = options.episode;
const episodeFiltered =
typeof episode === 'number' && episode > 0
? media.filter((entry) => {
const total = episodeCount(entry);
return total === null || total >= episode;
})
: 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 scored = pool.map((entry, index) => {
const candidateTitles = mediaTitles(entry);
let score = 0;
for (const target of targets) {
if (candidateTitles.includes(target)) {
score += 120;
continue;
}
if (candidateTitles.some((candidate) => candidate.startsWith(target))) {
score += 40;
} else if (candidateTitles.some((candidate) => candidate.includes(target))) {
score += 25;
}
if (candidateTitles.some((candidate) => target.includes(candidate))) {
score += 10;
}
}
if (isSeasonalFormat(entry)) {
score += 30;
}
return { entry, score, index };
});
scored.sort((a, b) => {
if (b.score !== a.score) return b.score - a.score;
if (a.index !== b.index) return a.index - b.index;
return a.entry.id - b.entry.id;
});
return scored[0]?.entry ?? null;
}
async function walkSequelChain(
anchor: AnilistSeasonMedia,
season: number,
deps: ResolveAnilistSeasonMediaDeps,
): Promise<AnilistSeasonMedia | null> {
// Walking fewer hops than requested would land on the wrong season and report it as
// resolved, so refuse instead and let the caller fall through to its guarded fallback.
const hops = season - 1;
if (hops > MAX_SEQUEL_HOPS) {
return null;
}
const visited = new Set<number>([anchor.id]);
let current = anchor;
for (let hop = 0; hop < hops; hop += 1) {
// Transport errors propagate: a failed hop must not be mistaken for "no sequel exists".
const response = await deps.execute<AnilistSeasonRelationsResponse>(
ANILIST_SEASON_RELATIONS_QUERY,
{ id: current.id },
);
const sequels = (response.Media?.relations?.edges ?? [])
.map((edge) => edge ?? null)
.filter(
(
edge,
): edge is {
relationType?: string | null;
node: AnilistSeasonMedia & { type?: string | null };
} =>
Boolean(edge?.node) &&
(edge?.relationType || '').toUpperCase() === 'SEQUEL' &&
(edge?.node?.type || 'ANIME').toUpperCase() === 'ANIME',
)
.map((edge) => edge.node)
.filter((node) => !visited.has(node.id));
if (sequels.length === 0) {
return null;
}
// A franchise can branch (a TV sequel plus an ONA spinoff); prefer the TV line.
sequels.sort((a, b) => {
const rankDelta = formatRank(a) - formatRank(b);
if (rankDelta !== 0) return rankDelta;
const yearDelta =
(airYear(a) ?? Number.MAX_SAFE_INTEGER) - (airYear(b) ?? Number.MAX_SAFE_INTEGER);
if (yearDelta !== 0) return yearDelta;
return a.id - b.id;
});
current = sequels[0]!;
visited.add(current.id);
}
return current.id === anchor.id ? null : current;
}
/**
* Fallback for franchises whose SEQUEL edges are missing or route through a format we
* skipped: order the franchise's seasonal entries by air date and index by season.
*/
export function pickByAirOrder(
anchor: AnilistSeasonMedia,
season: number,
media: AnilistSeasonMedia[],
): AnilistSeasonMedia | null {
const anchorTitles = mediaTitles(anchor).map((value) => stripSeasonSuffix(value));
const anchorBase = anchorTitles.sort((a, b) => a.length - b.length)[0];
if (!anchorBase) return null;
const franchise = media.filter((entry) => {
if (!isSeasonalFormat(entry)) return false;
if (entry.id === anchor.id) return true;
return mediaTitles(entry).some((candidate) =>
stripSeasonSuffix(candidate).includes(anchorBase),
);
});
if (franchise.length < season) return null;
// Ordering by air date is only meaningful when every entry has one: an unknown year
// would sort last and shift the season index while still reporting a resolved match.
if (franchise.some((entry) => airYear(entry) === null)) return null;
const ordered = [...franchise].sort((a, b) => {
const yearDelta = (airYear(a) ?? 0) - (airYear(b) ?? 0);
if (yearDelta !== 0) return yearDelta;
return a.id - b.id;
});
// The anchor has to be the first entry, otherwise this ordering is not a season list.
if (ordered[0]?.id !== anchor.id) return null;
return ordered[season - 1] ?? null;
}
export async function resolveAnilistSeasonMedia(
input: ResolveAnilistSeasonMediaInput,
deps: ResolveAnilistSeasonMediaDeps,
): Promise<AnilistSeasonResolution | null> {
const searchTitle = stripSeasonSuffix(input.title).trim() || input.title.trim();
if (!searchTitle) return null;
const season =
typeof input.season === 'number' && Number.isInteger(input.season) && input.season > 0
? input.season
: null;
const response = await deps.execute<AnilistSeasonSearchResponse>(ANILIST_SEASON_SEARCH_QUERY, {
search: searchTitle,
});
const media = (response.Page?.media ?? []).filter(
(entry): entry is AnilistSeasonMedia => Boolean(entry) && typeof entry.id === 'number',
);
if (media.length === 0) return null;
// Season 1 (or unknown) resolves against the episode count; later seasons must not,
// because the anchor is season 1 and may be shorter than the requested episode.
const anchor = pickAnchorMedia(searchTitle, media, {
episode: season === null || season <= 1 ? input.episode : null,
});
if (!anchor) return null;
if (season === null || season <= 1) {
return toResolution(anchor, searchTitle, season, 'anchor', true);
}
let chainError: unknown = null;
let viaChain: AnilistSeasonMedia | null = null;
try {
viaChain = await walkSequelChain(anchor, season, deps);
} catch (error) {
chainError = error;
}
if (viaChain) {
deps.logInfo?.(
`[anilist] season ${season} of "${searchTitle}" resolved via sequel chain: ${displayTitle(viaChain, searchTitle)} (${viaChain.id})`,
);
return toResolution(viaChain, searchTitle, season, 'sequel-chain', true);
}
const viaAirOrder = pickByAirOrder(anchor, season, media);
if (viaAirOrder) {
deps.logInfo?.(
`[anilist] season ${season} of "${searchTitle}" resolved via air order: ${displayTitle(viaAirOrder, searchTitle)} (${viaAirOrder.id})`,
);
return toResolution(viaAirOrder, searchTitle, season, 'air-order', true);
}
// The chain failed for transport reasons rather than because the season is absent;
// surface that so callers retry instead of reporting an unresolvable season.
if (chainError) {
throw chainError;
}
deps.logInfo?.(
`[anilist] could not resolve season ${season} of "${searchTitle}"; falling back to ${displayTitle(anchor, searchTitle)} (${anchor.id})`,
);
return toResolution(anchor, searchTitle, season, 'anchor', false);
}