fix(anilist): add missing list entry on confident match (#305)

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Ivan Melck <ivan@spec-kitty.ai>
This commit is contained in:
authored and GitHub committed 2026-10-28 17:20:03 -07:00
1 parent 203a61a20f
commit e59b78dca0
4 files changed
+139 -7

No files matched your search

@@ -401,7 +401,83 @@ test('updateAnilistPostWatchProgress skips when progress already reached', async
}
});
test('updateAnilistPostWatchProgress returns non-retryable error when media is not planning or watching', async () => {
test('updateAnilistPostWatchProgress adds an exactly matched show missing from the list', async () => {
const originalFetch = globalThis.fetch;
let call = 0;
let saveVariables: Record<string, unknown> | undefined;
globalThis.fetch = (async (_input, init) => {
call += 1;
if (call === 1) {
return createJsonResponse({
data: {
Page: {
media: [{ id: 33, episodes: 12, title: { english: 'New Show' } }],
},
},
});
}
if (call === 2) {
return createJsonResponse({
data: {
Media: { id: 33, mediaListEntry: null },
},
});
}
saveVariables = (JSON.parse(String(init?.body)) as { variables?: Record<string, unknown> })
.variables;
return createJsonResponse({
data: { SaveMediaListEntry: { progress: 1, status: 'CURRENT' } },
});
}) as typeof fetch;
try {
const result = await updateAnilistPostWatchProgress('token', 'New Show', 1);
assert.equal(result.status, 'updated');
assert.match(result.message, /added "New Show" to Watching at episode 1/i);
assert.deepEqual(saveVariables, { mediaId: 33, progress: 1, status: 'CURRENT' });
} finally {
globalThis.fetch = originalFetch;
}
});
test('updateAnilistPostWatchProgress adds a pinned media id missing from the list', async () => {
const originalFetch = globalThis.fetch;
let call = 0;
let saveVariables: Record<string, unknown> | undefined;
globalThis.fetch = (async (_input, init) => {
call += 1;
if (call === 1) {
return createJsonResponse({
data: {
Media: {
id: 108489,
episodes: 12,
title: { english: 'Pinned Show' },
mediaListEntry: null,
},
},
});
}
saveVariables = (JSON.parse(String(init?.body)) as { variables?: Record<string, unknown> })
.variables;
return createJsonResponse({
data: { SaveMediaListEntry: { progress: 12, status: 'COMPLETED' } },
});
}) as typeof fetch;
try {
const result = await updateAnilistPostWatchProgress('token', 'Wrong Guess', 12, {
mediaId: 108489,
});
assert.equal(result.status, 'updated');
assert.match(result.message, /added "Pinned Show" at episode 12 and marked it completed/i);
assert.deepEqual(saveVariables, { mediaId: 108489, progress: 12, status: 'COMPLETED' });
} finally {
globalThis.fetch = originalFetch;
}
});
test('updateAnilistPostWatchProgress does not add a loosely matched show missing from the list', async () => {
const originalFetch = globalThis.fetch;
let call = 0;
globalThis.fetch = (async () => {
@@ -410,7 +486,7 @@ test('updateAnilistPostWatchProgress returns non-retryable error when media is n
return createJsonResponse({
data: {
Page: {
media: [{ id: 33, episodes: 12, title: { english: 'Missing Show' } }],
media: [{ id: 33, episodes: 12, title: { english: 'Missing Show Abridged Edition' } }],
},
},
});
@@ -427,6 +503,39 @@ test('updateAnilistPostWatchProgress returns non-retryable error when media is n
assert.equal(result.status, 'error');
assert.equal(result.retryable, false);
assert.match(result.message, /not in your AniList Planning or Watching list/i);
assert.equal(call, 2);
} finally {
globalThis.fetch = originalFetch;
}
});
test('updateAnilistPostWatchProgress does not add the season 1 entry when the title carries an unparsed season marker', async () => {
const originalFetch = globalThis.fetch;
let call = 0;
globalThis.fetch = (async () => {
call += 1;
if (call === 1) {
return createJsonResponse({
data: {
Page: {
media: [{ id: 33, episodes: 12, format: 'TV', title: { english: 'Demo Show' } }],
},
},
});
}
return createJsonResponse({
data: {
Media: { id: 33, mediaListEntry: null },
},
});
}) as typeof fetch;
try {
const result = await updateAnilistPostWatchProgress('token', 'Demo Show S2', 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.equal(call, 2);
} finally {
globalThis.fetch = originalFetch;
}
+21 -3
View File
@@ -4,7 +4,7 @@ import * as path from 'path';
import { parseMediaInfo } from '../../../jimaku/utils';
import { resolveMediaLookupTarget, sanitizeMediaTitle } from '../../../shared/media-identity';
import type { AnilistRateLimiter } from './rate-limiter';
import { resolveAnilistSeasonMedia } from './season-resolver';
import { resolveAnilistSeasonMedia, stripSeasonSuffix } from './season-resolver';
const ANILIST_GRAPHQL_URL = 'https://graphql.anilist.co';
@@ -273,6 +273,9 @@ export async function updateAnilistPostWatchProgress(
let mediaId = pinnedMediaId;
let resolvedTitle: string | null = null;
let resolvedEpisodes: number | null = null;
// A missing list entry is only created when we trust the match; otherwise a fuzzy
// search hit would add an unrelated show to the user's list.
let confidentMatch = pinnedMediaId !== null;
if (mediaId === null) {
let resolution: Awaited<ReturnType<typeof resolveAnilistSeasonMedia>>;
@@ -312,6 +315,11 @@ export async function updateAnilistPostWatchProgress(
mediaId = resolution.id;
resolvedTitle = resolution.title;
resolvedEpisodes = resolution.episodes;
// With no parsed season, an exact match on a season-stripped title ("Show S2" ->
// "Show") only certifies the season 1 anchor, not the entry being watched.
const seasonMarkerStripped = stripSeasonSuffix(title).trim() !== title.trim();
confidentMatch =
resolution.exactTitleMatch && (resolution.requestedSeason !== null || !seasonMarkerStripped);
}
const entryResponse = await anilistGraphQl<AnilistMediaEntryData>(
@@ -358,7 +366,8 @@ export async function updateAnilistPostWatchProgress(
: null);
const entry = entryMedia?.mediaListEntry ?? null;
if (!entry || !isUpdateableListStatus(entry.status)) {
const createEntry = !entry && confidentMatch;
if (!createEntry && (!entry || !isUpdateableListStatus(entry.status))) {
return {
status: 'error',
retryable: false,
@@ -366,7 +375,7 @@ export async function updateAnilistPostWatchProgress(
};
}
const currentProgress = entry.progress ?? 0;
const currentProgress = entry?.progress ?? 0;
const shouldMarkCompleted = isKnownFinalEpisode(pickedEpisodes, episode);
if (typeof currentProgress === 'number' && currentProgress >= episode && !shouldMarkCompleted) {
return {
@@ -397,6 +406,15 @@ export async function updateAnilistPostWatchProgress(
return { status: 'error', message: `AniList update failed: ${saveError}` };
}
if (createEntry) {
return {
status: 'updated',
message: shouldMarkCompleted
? `AniList added "${pickedTitle}" at episode ${episode} and marked it completed.`
: `AniList added "${pickedTitle}" to Watching at episode ${episode}.`,
};
}
return {
status: 'updated',
message: shouldMarkCompleted