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
@@ -24,7 +24,8 @@ export function createBuildProcessNextAnilistRetryUpdateMainDepsHandler(
title: string,
episode: number,
season?: number | null,
) => deps.updateAnilistPostWatchProgress(accessToken, title, episode, season),
mediaId?: number | null,
) => deps.updateAnilistPostWatchProgress(accessToken, title, episode, season, mediaId),
markSuccess: (key: string) => deps.markSuccess(key),
rememberAttemptedUpdateKey: (key: string) => deps.rememberAttemptedUpdateKey(key),
markFailure: (key: string, message: string) => deps.markFailure(key, message),
@@ -52,8 +53,19 @@ export function createBuildMaybeRunAnilistPostWatchUpdateMainDepsHandler(
hasAttemptedUpdateKey: (key: string) => deps.hasAttemptedUpdateKey(key),
processNextAnilistRetryUpdate: () => deps.processNextAnilistRetryUpdate(),
refreshAnilistClientSecretState: () => deps.refreshAnilistClientSecretState(),
enqueueRetry: (key: string, title: string, episode: number, season?: number | null) =>
deps.enqueueRetry(key, title, episode, season),
enqueueRetry: (
key: string,
title: string,
episode: number,
season?: number | null,
mediaId?: number | null,
) => deps.enqueueRetry(key, title, episode, season, mediaId),
getCurrentMediaTitle: deps.getCurrentMediaTitle
? () => deps.getCurrentMediaTitle!()
: undefined,
resolvePinnedAnilistMediaId: deps.resolvePinnedAnilistMediaId
? (input) => deps.resolvePinnedAnilistMediaId!(input)
: undefined,
markRetryFailure: (key: string, message: string) => deps.markRetryFailure(key, message),
markRetrySuccess: (key: string) => deps.markRetrySuccess(key),
refreshRetryQueueState: () => deps.refreshRetryQueueState(),
@@ -62,7 +74,8 @@ export function createBuildMaybeRunAnilistPostWatchUpdateMainDepsHandler(
title: string,
episode: number,
season?: number | null,
) => deps.updateAnilistPostWatchProgress(accessToken, title, episode, season),
mediaId?: number | null,
) => deps.updateAnilistPostWatchProgress(accessToken, title, episode, season, mediaId),
rememberAttemptedUpdateKey: (key: string) => deps.rememberAttemptedUpdateKey(key),
showMpvOsd: (message: string) => deps.showMpvOsd(message),
logInfo: (message: string) => deps.logInfo(message),
+152
View File
@@ -380,3 +380,155 @@ test('createMaybeRunAnilistPostWatchUpdateHandler notifies when retry already ha
assert.equal(calls.includes('mark-failure'), false);
assert.deepEqual(calls, ['inflight:true', 'process-retry', 'osd:retry ok', 'inflight:false']);
});
test('createMaybeRunAnilistPostWatchUpdateHandler passes the pinned override media id', async () => {
const updateArgs: Array<number | null | undefined> = [];
const pinInputs: Array<{ mediaPath: string | null; mediaTitle: string | null; guess: unknown }> =
[];
const handler = createMaybeRunAnilistPostWatchUpdateHandler({
getInFlight: () => false,
setInFlight: () => {},
getResolvedConfig: () => ({}),
isAnilistTrackingEnabled: () => true,
getCurrentMediaKey: () => '/tmp/video.mkv',
hasMpvClient: () => true,
getTrackedMediaKey: () => '/tmp/video.mkv',
resetTrackedMedia: () => {},
getWatchedSeconds: () => 1000,
maybeProbeAnilistDuration: async () => 1000,
ensureAnilistMediaGuess: async () => ({ title: 'Show', season: 3, episode: 1 }),
hasAttemptedUpdateKey: () => false,
processNextAnilistRetryUpdate: async () => ({ ok: true, message: 'noop' }),
refreshAnilistClientSecretState: async () => 'token',
enqueueRetry: () => {},
getCurrentMediaTitle: () => 'Show S03E01.mkv',
resolvePinnedAnilistMediaId: async (input) => {
pinInputs.push(input);
return 108489;
},
markRetryFailure: () => {},
markRetrySuccess: () => {},
refreshRetryQueueState: () => {},
updateAnilistPostWatchProgress: async (_accessToken, _title, _episode, _season, mediaId) => {
updateArgs.push(mediaId);
return { status: 'updated', message: 'ok' };
},
rememberAttemptedUpdateKey: () => {},
showMpvOsd: () => {},
logInfo: () => {},
logWarn: () => {},
minWatchSeconds: 600,
minWatchRatio: 0.85,
});
await handler();
assert.deepEqual(updateArgs, [108489]);
// Resolved against the media this run captured, not whatever is playing now.
assert.equal(pinInputs.length, 1);
assert.equal(pinInputs[0]!.mediaPath, '/tmp/video.mkv');
assert.equal(pinInputs[0]!.mediaTitle, 'Show S03E01.mkv');
assert.deepEqual(pinInputs[0]!.guess, { title: 'Show', season: 3, episode: 1 });
});
test('createMaybeRunAnilistPostWatchUpdateHandler queues the pinned media id for retry', async () => {
const enqueued: Array<number | null | undefined> = [];
const handler = createMaybeRunAnilistPostWatchUpdateHandler({
getInFlight: () => false,
setInFlight: () => {},
getResolvedConfig: () => ({}),
isAnilistTrackingEnabled: () => true,
getCurrentMediaKey: () => '/tmp/video.mkv',
hasMpvClient: () => true,
getTrackedMediaKey: () => '/tmp/video.mkv',
resetTrackedMedia: () => {},
getWatchedSeconds: () => 1000,
maybeProbeAnilistDuration: async () => 1000,
ensureAnilistMediaGuess: async () => ({ title: 'Show', season: 3, episode: 1 }),
hasAttemptedUpdateKey: () => false,
processNextAnilistRetryUpdate: async () => ({ ok: true, message: 'noop' }),
refreshAnilistClientSecretState: async () => null,
enqueueRetry: (_key, _title, _episode, _season, mediaId) => {
enqueued.push(mediaId);
},
resolvePinnedAnilistMediaId: async () => 108489,
markRetryFailure: () => {},
markRetrySuccess: () => {},
refreshRetryQueueState: () => {},
updateAnilistPostWatchProgress: async () => ({ status: 'updated', message: 'ok' }),
rememberAttemptedUpdateKey: () => {},
showMpvOsd: () => {},
logInfo: () => {},
logWarn: () => {},
minWatchSeconds: 600,
minWatchRatio: 0.85,
});
await handler();
assert.deepEqual(enqueued, [108489]);
});
test('createMaybeRunAnilistPostWatchUpdateHandler still updates when the override lookup throws', async () => {
const updateArgs: Array<number | null | undefined> = [];
const warnings: string[] = [];
const handler = createMaybeRunAnilistPostWatchUpdateHandler({
getInFlight: () => false,
setInFlight: () => {},
getResolvedConfig: () => ({}),
isAnilistTrackingEnabled: () => true,
getCurrentMediaKey: () => '/tmp/video.mkv',
hasMpvClient: () => true,
getTrackedMediaKey: () => '/tmp/video.mkv',
resetTrackedMedia: () => {},
getWatchedSeconds: () => 1000,
maybeProbeAnilistDuration: async () => 1000,
ensureAnilistMediaGuess: async () => ({ title: 'Show', season: 3, episode: 1 }),
hasAttemptedUpdateKey: () => false,
processNextAnilistRetryUpdate: async () => ({ ok: true, message: 'noop' }),
refreshAnilistClientSecretState: async () => 'token',
enqueueRetry: () => {},
resolvePinnedAnilistMediaId: async () => {
throw new Error('store unreadable');
},
markRetryFailure: () => {},
markRetrySuccess: () => {},
refreshRetryQueueState: () => {},
updateAnilistPostWatchProgress: async (_accessToken, _title, _episode, _season, mediaId) => {
updateArgs.push(mediaId);
return { status: 'updated', message: 'ok' };
},
rememberAttemptedUpdateKey: () => {},
showMpvOsd: () => {},
logInfo: () => {},
logWarn: (message) => warnings.push(message),
minWatchSeconds: 600,
minWatchRatio: 0.85,
});
await handler();
assert.deepEqual(updateArgs, [null]);
assert.equal(warnings.length, 1);
assert.match(warnings[0]!, /override lookup failed/i);
});
test('createProcessNextAnilistRetryUpdateHandler forwards the queued media id', async () => {
const received: Array<number | null | undefined> = [];
const handler = createProcessNextAnilistRetryUpdateHandler({
nextReady: () => ({ key: 'k1', title: 'Show', season: 3, mediaId: 108489, episode: 1 }),
refreshRetryQueueState: () => {},
setLastAttemptAt: () => {},
setLastError: () => {},
refreshAnilistClientSecretState: async () => 'token',
updateAnilistPostWatchProgress: async (_accessToken, _title, _episode, _season, mediaId) => {
received.push(mediaId);
return { status: 'updated', message: 'ok' };
},
markSuccess: () => {},
rememberAttemptedUpdateKey: () => {},
markFailure: () => {},
logInfo: () => {},
now: () => 1,
});
await handler();
assert.deepEqual(received, [108489]);
});
+42 -3
View File
@@ -4,6 +4,8 @@ type AnilistGuess = {
title: string;
season: number | null;
episode: number | null;
alternativeTitle?: string;
year?: number;
};
type AnilistUpdateResult = {
@@ -16,6 +18,7 @@ type RetryQueueItem = {
key: string;
title: string;
season?: number | null;
mediaId?: number | null;
episode: number;
};
@@ -58,6 +61,7 @@ export function createProcessNextAnilistRetryUpdateHandler(deps: {
title: string,
episode: number,
season?: number | null,
mediaId?: number | null,
) => Promise<AnilistUpdateResult>;
markSuccess: (key: string) => void;
rememberAttemptedUpdateKey: (key: string) => void;
@@ -84,6 +88,7 @@ export function createProcessNextAnilistRetryUpdateHandler(deps: {
queued.title,
queued.episode,
queued.season ?? null,
queued.mediaId ?? null,
);
if (result.status === 'updated' || result.status === 'skipped') {
deps.markSuccess(queued.key);
@@ -119,7 +124,23 @@ export function createMaybeRunAnilistPostWatchUpdateHandler(deps: {
hasAttemptedUpdateKey: (key: string) => boolean;
processNextAnilistRetryUpdate: () => Promise<{ ok: boolean; message: string }>;
refreshAnilistClientSecretState: () => Promise<string | null>;
enqueueRetry: (key: string, title: string, episode: number, season?: number | null) => void;
enqueueRetry: (
key: string,
title: string,
episode: number,
season?: number | null,
mediaId?: number | null,
) => void;
getCurrentMediaTitle?: () => string | null;
/**
* Pinned AniList media id from a manual dictionary override. Takes the media captured
* by this run, so a mid-run file change cannot pin the update to a different show.
*/
resolvePinnedAnilistMediaId?: (input: {
mediaPath: string | null;
mediaTitle: string | null;
guess: AnilistGuess;
}) => Promise<number | null>;
markRetryFailure: (key: string, message: string) => void;
markRetrySuccess: (key: string) => void;
refreshRetryQueueState: () => void;
@@ -128,6 +149,7 @@ export function createMaybeRunAnilistPostWatchUpdateHandler(deps: {
title: string,
episode: number,
season?: number | null,
mediaId?: number | null,
) => Promise<AnilistUpdateResult>;
rememberAttemptedUpdateKey: (key: string) => void;
showMpvOsd: (message: string) => void;
@@ -157,6 +179,8 @@ export function createMaybeRunAnilistPostWatchUpdateHandler(deps: {
if (deps.getTrackedMediaKey() !== mediaKey) {
deps.resetTrackedMedia(mediaKey);
}
// Captured before any await: playback can advance while the update is in flight.
const mediaTitle = deps.getCurrentMediaTitle?.() ?? null;
let watchedSeconds = 0;
if (!force) {
@@ -202,9 +226,23 @@ export function createMaybeRunAnilistPostWatchUpdateHandler(deps: {
return;
}
// A manual dictionary override is the user's explicit answer to "which AniList
// entry is this?", so it wins over anything the title search would resolve.
let pinnedMediaId: number | null = null;
try {
pinnedMediaId =
(await deps.resolvePinnedAnilistMediaId?.({
mediaPath: mediaKey,
mediaTitle,
guess,
})) ?? null;
} catch (error) {
deps.logWarn(`AniList override lookup failed: ${String(error)}`);
}
const accessToken = await deps.refreshAnilistClientSecretState();
if (!accessToken) {
deps.enqueueRetry(attemptKey, guess.title, guess.episode, guess.season);
deps.enqueueRetry(attemptKey, guess.title, guess.episode, guess.season, pinnedMediaId);
deps.markRetryFailure(attemptKey, 'cannot authenticate without anilist.accessToken');
deps.refreshRetryQueueState();
deps.showMpvOsd('AniList: access token not configured');
@@ -216,6 +254,7 @@ export function createMaybeRunAnilistPostWatchUpdateHandler(deps: {
guess.title,
guess.episode,
guess.season,
pinnedMediaId,
);
if (result.status === 'updated') {
deps.rememberAttemptedUpdateKey(attemptKey);
@@ -241,7 +280,7 @@ export function createMaybeRunAnilistPostWatchUpdateHandler(deps: {
return;
}
deps.enqueueRetry(attemptKey, guess.title, guess.episode, guess.season);
deps.enqueueRetry(attemptKey, guess.title, guess.episode, guess.season, pinnedMediaId);
deps.markRetryFailure(attemptKey, result.message);
deps.refreshRetryQueueState();
deps.showMpvOsd(`AniList: ${result.message}`);