fix(anilist): mark entry completed when final episode is reached (#115)

This commit is contained in:
2026-06-07 23:45:09 -07:00
committed by GitHub
parent af67c53dd6
commit e6a16a069b
3 changed files with 107 additions and 7 deletions
@@ -0,0 +1,4 @@
type: fixed
area: anilist
- Marked AniList entries completed when a post-watch update reaches the final known episode of the season.
@@ -235,6 +235,86 @@ test('updateAnilistPostWatchProgress uses the configured AniList rate limiter',
} }
}); });
test('updateAnilistPostWatchProgress marks the final season episode completed', 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: {
Page: {
media: [{ id: 12, episodes: 12, title: { english: 'Final Show' } }],
},
},
});
}
if (call === 2) {
return createJsonResponse({
data: {
Media: { id: 12, mediaListEntry: { progress: 11, status: 'CURRENT' } },
},
});
}
assert.equal(body.variables?.progress, 12);
assert.equal(body.variables?.status, 'COMPLETED');
return createJsonResponse({
data: { SaveMediaListEntry: { progress: 12, status: 'COMPLETED' } },
});
}) as typeof fetch;
try {
const result = await updateAnilistPostWatchProgress('token', 'Final Show', 12);
assert.equal(result.status, 'updated');
assert.match(result.message, /completed/i);
assert.equal(call, 3);
} finally {
globalThis.fetch = originalFetch;
}
});
test('updateAnilistPostWatchProgress marks an already watched final season episode completed', 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: {
Page: {
media: [{ id: 12, episodes: 12, title: { english: 'Final Show' } }],
},
},
});
}
if (call === 2) {
return createJsonResponse({
data: {
Media: { id: 12, mediaListEntry: { progress: 12, status: 'CURRENT' } },
},
});
}
assert.equal(body.variables?.progress, 12);
assert.equal(body.variables?.status, 'COMPLETED');
return createJsonResponse({
data: { SaveMediaListEntry: { progress: 12, status: 'COMPLETED' } },
});
}) as typeof fetch;
try {
const result = await updateAnilistPostWatchProgress('token', 'Final Show', 12);
assert.equal(result.status, 'updated');
assert.match(result.message, /completed/i);
assert.equal(call, 3);
} finally {
globalThis.fetch = originalFetch;
}
});
test('updateAnilistPostWatchProgress skips when progress already reached', async () => { test('updateAnilistPostWatchProgress skips when progress already reached', async () => {
const originalFetch = globalThis.fetch; const originalFetch = globalThis.fetch;
let call = 0; let call = 0;
+23 -7
View File
@@ -228,7 +228,7 @@ function pickBestSearchResult(
native?: string | null; native?: string | null;
}; };
}>, }>,
): { id: number; title: string } | null { ): { id: number; title: string; episodes: number | null } | null {
const filtered = media.filter((item) => { const filtered = media.filter((item) => {
const totalEpisodes = item.episodes; const totalEpisodes = item.episodes;
return totalEpisodes === null || totalEpisodes >= episode; return totalEpisodes === null || totalEpisodes >= episode;
@@ -247,7 +247,7 @@ function pickBestSearchResult(
const selected = exact ?? candidates[0]!; const selected = exact ?? candidates[0]!;
const selectedTitle = const selectedTitle =
selected.title?.english || selected.title?.romaji || selected.title?.native || title; selected.title?.english || selected.title?.romaji || selected.title?.native || title;
return { id: selected.id, title: selectedTitle }; return { id: selected.id, title: selectedTitle, episodes: selected.episodes };
} }
function isUpdateableListStatus(status: string | null | undefined): boolean { function isUpdateableListStatus(status: string | null | undefined): boolean {
@@ -259,6 +259,15 @@ function formatListStatus(status: string | null | undefined): string {
return `marked ${status.toLowerCase().replace(/_/g, ' ')} on AniList`; return `marked ${status.toLowerCase().replace(/_/g, ' ')} on AniList`;
} }
function isKnownFinalEpisode(totalEpisodes: number | null, episode: number): boolean {
return (
typeof totalEpisodes === 'number' &&
Number.isInteger(totalEpisodes) &&
totalEpisodes > 0 &&
episode === totalEpisodes
);
}
export async function guessAnilistMediaInfo( export async function guessAnilistMediaInfo(
mediaPath: string | null, mediaPath: string | null,
mediaTitle: string | null, mediaTitle: string | null,
@@ -394,7 +403,8 @@ export async function updateAnilistPostWatchProgress(
} }
const currentProgress = entry.progress ?? 0; const currentProgress = entry.progress ?? 0;
if (typeof currentProgress === 'number' && currentProgress >= episode) { const shouldMarkCompleted = isKnownFinalEpisode(picked.episodes, episode);
if (typeof currentProgress === 'number' && currentProgress >= episode && !shouldMarkCompleted) {
return { return {
status: 'skipped', status: 'skipped',
message: `AniList already at episode ${currentProgress} (${picked.title}).`, message: `AniList already at episode ${currentProgress} (${picked.title}).`,
@@ -404,14 +414,18 @@ export async function updateAnilistPostWatchProgress(
const saveResponse = await anilistGraphQl<AnilistSaveEntryData>( const saveResponse = await anilistGraphQl<AnilistSaveEntryData>(
accessToken, accessToken,
` `
mutation ($mediaId: Int!, $progress: Int!) { mutation ($mediaId: Int!, $progress: Int!, $status: MediaListStatus!) {
SaveMediaListEntry(mediaId: $mediaId, progress: $progress, status: CURRENT) { SaveMediaListEntry(mediaId: $mediaId, progress: $progress, status: $status) {
progress progress
status status
} }
} }
`, `,
{ mediaId: picked.id, progress: episode }, {
mediaId: picked.id,
progress: episode,
status: shouldMarkCompleted ? 'COMPLETED' : 'CURRENT',
},
options, options,
); );
const saveError = firstErrorMessage(saveResponse); const saveError = firstErrorMessage(saveResponse);
@@ -421,6 +435,8 @@ export async function updateAnilistPostWatchProgress(
return { return {
status: 'updated', status: 'updated',
message: `AniList updated "${picked.title}" to episode ${episode}.`, message: shouldMarkCompleted
? `AniList updated "${picked.title}" to episode ${episode} and marked it completed.`
: `AniList updated "${picked.title}" to episode ${episode}.`,
}; };
} }