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

+5
View File
@@ -0,0 +1,5 @@
type: fixed
area: anilist
- AniList progress sync now adds a show to your Watching list (or Completed on the final episode) when it is not on your list yet, instead of failing with "AniList update not possible". This only happens when the file title exactly matches an AniList title or the entry is pinned with the character dictionary override; loose matches still require adding the show yourself.
- Entries marked Completed, Paused, Dropped, or Rewatching are still never changed.
+2 -2
View File
@@ -29,7 +29,7 @@ An episode counts as watched after 85% of its length and at least 10 minutes of
2. Finds the matching AniList entry. For season 2 and later, it follows the show's sequels.
3. Sets your progress to that episode and marks the entry Watching, or Completed on the final episode.
The show must already be on your Planning or Watching list. SubMiner does not add new entries, and it never lowers your progress.
If the show is not on your list yet, SubMiner adds it as Watching, but only when the file title exactly matches an AniList title or you pinned the entry with the character dictionary's AniList override. For a looser match, add the show to Planning or Watching yourself. SubMiner never changes entries marked Completed, Paused, Dropped, or Rewatching, and it never lowers your progress.
Failed updates are saved and retried in the background, up to 8 times with growing delays. The queue survives restarts.
@@ -61,7 +61,7 @@ Character dictionary settings live under `anilist.characterDictionary` and are c
**No update after an episode.** Check that `anilist.enabled` is `true` and that you watched at least 85% of the episode.
**"AniList update not possible."** Add the show to your Planning or Watching list, then mark the episode watched again.
**"AniList update not possible."** The show is marked Completed, Paused, Dropped, or Rewatching, or it is not on your list and the title match was not exact. Add the show to your Planning or Watching list, or pin it with the character dictionary's AniList override, then mark the episode watched again.
**Wrong show or episode.** Install guessit and make sure it is on your `PATH`. Unusual file names parse poorly without it.
@@ -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