diff --git a/changes/anilist-final-episode-completion.md b/changes/anilist-final-episode-completion.md new file mode 100644 index 00000000..854054fe --- /dev/null +++ b/changes/anilist-final-episode-completion.md @@ -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. diff --git a/src/core/services/anilist/anilist-updater.test.ts b/src/core/services/anilist/anilist-updater.test.ts index 9a09e689..e2376973 100644 --- a/src/core/services/anilist/anilist-updater.test.ts +++ b/src/core/services/anilist/anilist-updater.test.ts @@ -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 }; + 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 }; + 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 () => { const originalFetch = globalThis.fetch; let call = 0; diff --git a/src/core/services/anilist/anilist-updater.ts b/src/core/services/anilist/anilist-updater.ts index 5956bc2e..53109eda 100644 --- a/src/core/services/anilist/anilist-updater.ts +++ b/src/core/services/anilist/anilist-updater.ts @@ -228,7 +228,7 @@ function pickBestSearchResult( native?: string | null; }; }>, -): { id: number; title: string } | null { +): { id: number; title: string; episodes: number | null } | null { const filtered = media.filter((item) => { const totalEpisodes = item.episodes; return totalEpisodes === null || totalEpisodes >= episode; @@ -247,7 +247,7 @@ function pickBestSearchResult( const selected = exact ?? candidates[0]!; const selectedTitle = 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 { @@ -259,6 +259,15 @@ function formatListStatus(status: string | null | undefined): string { 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( mediaPath: string | null, mediaTitle: string | null, @@ -394,7 +403,8 @@ export async function updateAnilistPostWatchProgress( } 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 { status: 'skipped', message: `AniList already at episode ${currentProgress} (${picked.title}).`, @@ -404,14 +414,18 @@ export async function updateAnilistPostWatchProgress( const saveResponse = await anilistGraphQl( accessToken, ` - mutation ($mediaId: Int!, $progress: Int!) { - SaveMediaListEntry(mediaId: $mediaId, progress: $progress, status: CURRENT) { + mutation ($mediaId: Int!, $progress: Int!, $status: MediaListStatus!) { + SaveMediaListEntry(mediaId: $mediaId, progress: $progress, status: $status) { progress status } } `, - { mediaId: picked.id, progress: episode }, + { + mediaId: picked.id, + progress: episode, + status: shouldMarkCompleted ? 'COMPLETED' : 'CURRENT', + }, options, ); const saveError = firstErrorMessage(saveResponse); @@ -421,6 +435,8 @@ export async function updateAnilistPostWatchProgress( return { 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}.`, }; }