mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-08-01 07:21:33 -07:00
fix(anilist): resolve later seasons via sequel relations, not title guessing (#173)
This commit is contained in:
@@ -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),
|
||||
|
||||
@@ -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]);
|
||||
});
|
||||
|
||||
@@ -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}`);
|
||||
|
||||
Reference in New Issue
Block a user