type AnilistGuess = { title: string; episode: number | null; }; type AnilistUpdateResult = { status: 'updated' | 'skipped' | 'error'; message: string; }; type RetryQueueItem = { key: string; title: string; episode: number; }; export function buildAnilistAttemptKey(mediaKey: string, episode: number): string { return `${mediaKey}::${episode}`; } export function rememberAnilistAttemptedUpdateKey( attemptedKeys: Set, key: string, maxSize: number, ): void { attemptedKeys.add(key); if (attemptedKeys.size <= maxSize) { return; } const oldestKey = attemptedKeys.values().next().value; if (typeof oldestKey === 'string') { attemptedKeys.delete(oldestKey); } } export function createProcessNextAnilistRetryUpdateHandler(deps: { nextReady: () => RetryQueueItem | null; refreshRetryQueueState: () => void; setLastAttemptAt: (value: number) => void; setLastError: (value: string | null) => void; refreshAnilistClientSecretState: () => Promise; updateAnilistPostWatchProgress: ( accessToken: string, title: string, episode: number, ) => Promise; markSuccess: (key: string) => void; rememberAttemptedUpdateKey: (key: string) => void; markFailure: (key: string, message: string) => void; logInfo: (message: string) => void; now: () => number; }) { return async (): Promise<{ ok: boolean; message: string }> => { const queued = deps.nextReady(); deps.refreshRetryQueueState(); if (!queued) { return { ok: true, message: 'AniList queue has no ready items.' }; } deps.setLastAttemptAt(deps.now()); const accessToken = await deps.refreshAnilistClientSecretState(); if (!accessToken) { deps.setLastError('AniList token unavailable for queued retry.'); return { ok: false, message: 'AniList token unavailable for queued retry.' }; } const result = await deps.updateAnilistPostWatchProgress( accessToken, queued.title, queued.episode, ); if (result.status === 'updated' || result.status === 'skipped') { deps.markSuccess(queued.key); deps.rememberAttemptedUpdateKey(queued.key); deps.setLastError(null); deps.refreshRetryQueueState(); deps.logInfo(`[AniList queue] ${result.message}`); return { ok: true, message: result.message }; } deps.markFailure(queued.key, result.message); deps.setLastError(result.message); deps.refreshRetryQueueState(); return { ok: false, message: result.message }; }; } export function createMaybeRunAnilistPostWatchUpdateHandler(deps: { getInFlight: () => boolean; setInFlight: (value: boolean) => void; getResolvedConfig: () => unknown; isAnilistTrackingEnabled: (config: unknown) => boolean; getCurrentMediaKey: () => string | null; hasMpvClient: () => boolean; getTrackedMediaKey: () => string | null; resetTrackedMedia: (mediaKey: string | null) => void; getWatchedSeconds: () => number; maybeProbeAnilistDuration: (mediaKey: string) => Promise; ensureAnilistMediaGuess: (mediaKey: string) => Promise; hasAttemptedUpdateKey: (key: string) => boolean; processNextAnilistRetryUpdate: () => Promise<{ ok: boolean; message: string }>; refreshAnilistClientSecretState: () => Promise; enqueueRetry: (key: string, title: string, episode: number) => void; markRetryFailure: (key: string, message: string) => void; markRetrySuccess: (key: string) => void; refreshRetryQueueState: () => void; updateAnilistPostWatchProgress: ( accessToken: string, title: string, episode: number, ) => Promise; rememberAttemptedUpdateKey: (key: string) => void; showMpvOsd: (message: string) => void; logInfo: (message: string) => void; logWarn: (message: string) => void; minWatchSeconds: number; minWatchRatio: number; }) { return async (): Promise => { if (deps.getInFlight()) { return; } const resolved = deps.getResolvedConfig(); if (!deps.isAnilistTrackingEnabled(resolved)) { return; } const mediaKey = deps.getCurrentMediaKey(); if (!mediaKey || !deps.hasMpvClient()) { return; } if (deps.getTrackedMediaKey() !== mediaKey) { deps.resetTrackedMedia(mediaKey); } const watchedSeconds = deps.getWatchedSeconds(); if (!Number.isFinite(watchedSeconds) || watchedSeconds < deps.minWatchSeconds) { return; } const duration = await deps.maybeProbeAnilistDuration(mediaKey); if (!duration || duration <= 0) { return; } if (watchedSeconds / duration < deps.minWatchRatio) { return; } const guess = await deps.ensureAnilistMediaGuess(mediaKey); if (!guess?.title || !guess.episode || guess.episode <= 0) { return; } const attemptKey = buildAnilistAttemptKey(mediaKey, guess.episode); if (deps.hasAttemptedUpdateKey(attemptKey)) { return; } deps.setInFlight(true); try { await deps.processNextAnilistRetryUpdate(); const accessToken = await deps.refreshAnilistClientSecretState(); if (!accessToken) { deps.enqueueRetry(attemptKey, guess.title, guess.episode); deps.markRetryFailure(attemptKey, 'cannot authenticate without anilist.accessToken'); deps.refreshRetryQueueState(); deps.showMpvOsd('AniList: access token not configured'); return; } const result = await deps.updateAnilistPostWatchProgress( accessToken, guess.title, guess.episode, ); if (result.status === 'updated') { deps.rememberAttemptedUpdateKey(attemptKey); deps.markRetrySuccess(attemptKey); deps.refreshRetryQueueState(); deps.showMpvOsd(result.message); deps.logInfo(result.message); return; } if (result.status === 'skipped') { deps.rememberAttemptedUpdateKey(attemptKey); deps.markRetrySuccess(attemptKey); deps.refreshRetryQueueState(); deps.logInfo(result.message); return; } deps.enqueueRetry(attemptKey, guess.title, guess.episode); deps.markRetryFailure(attemptKey, result.message); deps.refreshRetryQueueState(); deps.showMpvOsd(`AniList: ${result.message}`); deps.logWarn(result.message); } finally { deps.setInFlight(false); } }; }