diff --git a/backlog/tasks/task-29 - Add-Anilist-integration-for-post-watch-updates.md b/backlog/tasks/task-29 - Add-Anilist-integration-for-post-watch-updates.md index 698fb09..c9c5d76 100644 --- a/backlog/tasks/task-29 - Add-Anilist-integration-for-post-watch-updates.md +++ b/backlog/tasks/task-29 - Add-Anilist-integration-for-post-watch-updates.md @@ -1,9 +1,10 @@ --- id: TASK-29 title: Add Anilist integration for post-watch updates -status: To Do +status: In Progress assignee: [] created_date: '2026-02-13 17:57' +updated_date: '2026-02-17 04:19' labels: - anilist - anime @@ -40,6 +41,12 @@ Requirements: - [ ] #7 Error states and duplicate/duplicate-inconsistent updates are handled deterministically (idempotent where practical). +## Implementation Notes + + +Completed child tasks TASK-29.1 and TASK-29.2: secure token persistence/fallback and persistent retry queue with backoff/dead-letter are now implemented. + + ## Definition of Done - [ ] #1 Core Anilist service module exists and is wired into application flow for post-watch updates. diff --git a/backlog/tasks/task-29.1 - Implement-secure-AniList-token-lifecycle-and-account-management.md b/backlog/tasks/task-29.1 - Implement-secure-AniList-token-lifecycle-and-account-management.md new file mode 100644 index 0000000..258f630 --- /dev/null +++ b/backlog/tasks/task-29.1 - Implement-secure-AniList-token-lifecycle-and-account-management.md @@ -0,0 +1,35 @@ +--- +id: TASK-29.1 +title: Implement secure AniList token lifecycle and account management +status: Done +assignee: [] +created_date: '2026-02-17 04:12' +updated_date: '2026-02-17 04:19' +labels: + - anilist + - security + - auth +dependencies: [] +parent_task_id: TASK-29 +priority: medium +--- + +## Acceptance Criteria + +- [ ] #1 Access token is stored in secure local storage rather than plain config. +- [ ] #2 Token connect/disconnect UX supports revocation/logout and re-auth setup. +- [ ] #3 Startup flow validates token presence/state and surfaces actionable errors. +- [ ] #4 Docs describe token management and security expectations. + + +## Implementation Notes + + +Implemented secure AniList token lifecycle: config token persists to encrypted token store, stored token fallback is auto-resolved at runtime, and auth state source now distinguishes literal vs stored. + + +## Definition of Done + +- [ ] #1 Token lifecycle module wired into AniList update/auth flow. +- [ ] #2 Unit/integration coverage added for token storage and logout paths. + diff --git a/backlog/tasks/task-29.2 - Implement-AniList-retry-backoff-queue-for-failed-post-watch-updates.md b/backlog/tasks/task-29.2 - Implement-AniList-retry-backoff-queue-for-failed-post-watch-updates.md new file mode 100644 index 0000000..4bae158 --- /dev/null +++ b/backlog/tasks/task-29.2 - Implement-AniList-retry-backoff-queue-for-failed-post-watch-updates.md @@ -0,0 +1,35 @@ +--- +id: TASK-29.2 +title: Implement AniList retry/backoff queue for failed post-watch updates +status: Done +assignee: [] +created_date: '2026-02-17 04:13' +updated_date: '2026-02-17 04:19' +labels: + - anilist + - reliability + - queue +dependencies: [] +parent_task_id: TASK-29 +priority: medium +--- + +## Acceptance Criteria + +- [ ] #1 Failed AniList mutations are enqueued with retry metadata and exponential backoff. +- [ ] #2 Transient API/network failures retry automatically without blocking playback. +- [ ] #3 Queue is idempotent per media+episode update key and survives app restarts. +- [ ] #4 Permanent failures surface clear diagnostics and dead-letter state. + + +## Implementation Notes + + +Implemented persistent AniList retry queue with exponential backoff, dead-lettering after max attempts, queue snapshot state wiring, and retry processing integrated into playback-triggered AniList update flow. + + +## Definition of Done + +- [ ] #1 Queue service integrated into AniList post-watch update path. +- [ ] #2 Backoff/retry behavior covered by unit tests. + diff --git a/docs/configuration.md b/docs/configuration.md index 33220e5..7191f0a 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -382,6 +382,13 @@ AniList integration is opt-in and disabled by default. Enable it and provide an When `enabled` is `true` and `accessToken` is empty, SubMiner opens an AniList setup helper window. Keep `enabled` as `false` to disable all AniList setup/update behavior. +Current post-watch behavior: + +- SubMiner attempts an update near episode completion (`>=85%` watched and at least `10` minutes watched). +- Episode/title detection is `guessit`-first with fallback to SubMiner's filename parser. +- If `guessit` is unavailable, updates still work via fallback parsing but title matching can be less accurate. +- If embedded AniList auth UI fails to render, SubMiner opens the authorize URL in your default browser and shows fallback instructions in-app. + ### Keybindings Add a `keybindings` array to configure keyboard shortcuts that send commands to mpv: diff --git a/src/core/services/anilist/anilist-token-store.ts b/src/core/services/anilist/anilist-token-store.ts new file mode 100644 index 0000000..713e124 --- /dev/null +++ b/src/core/services/anilist/anilist-token-store.ts @@ -0,0 +1,111 @@ +import * as fs from "fs"; +import * as path from "path"; +import { safeStorage } from "electron"; + +interface PersistedTokenPayload { + encryptedToken?: string; + plaintextToken?: string; + updatedAt?: number; +} + +export interface AnilistTokenStore { + loadToken: () => string | null; + saveToken: (token: string) => void; + clearToken: () => void; +} + +function ensureDirectory(filePath: string): void { + const dir = path.dirname(filePath); + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true }); + } +} + +function writePayload(filePath: string, payload: PersistedTokenPayload): void { + ensureDirectory(filePath); + fs.writeFileSync(filePath, JSON.stringify(payload, null, 2), "utf-8"); +} + +export function createAnilistTokenStore( + filePath: string, + logger: { + info: (message: string) => void; + warn: (message: string, details?: unknown) => void; + error: (message: string, details?: unknown) => void; + }, +): AnilistTokenStore { + return { + loadToken(): string | null { + if (!fs.existsSync(filePath)) { + return null; + } + try { + const raw = fs.readFileSync(filePath, "utf-8"); + const parsed = JSON.parse(raw) as PersistedTokenPayload; + if ( + typeof parsed.encryptedToken === "string" && + parsed.encryptedToken.length > 0 + ) { + const encrypted = Buffer.from(parsed.encryptedToken, "base64"); + if (!safeStorage.isEncryptionAvailable()) { + logger.warn( + "AniList token encryption is not available on this system.", + ); + return null; + } + const decrypted = safeStorage.decryptString(encrypted).trim(); + return decrypted.length > 0 ? decrypted : null; + } + if ( + typeof parsed.plaintextToken === "string" && + parsed.plaintextToken.trim().length > 0 + ) { + // Legacy fallback: migrate plaintext token to encrypted storage on load. + const plaintext = parsed.plaintextToken.trim(); + this.saveToken(plaintext); + return plaintext; + } + } catch (error) { + logger.error("Failed to read AniList token store.", error); + } + return null; + }, + + saveToken(token: string): void { + const trimmed = token.trim(); + if (trimmed.length === 0) { + this.clearToken(); + return; + } + try { + if (!safeStorage.isEncryptionAvailable()) { + logger.warn( + "AniList token encryption unavailable; storing token in plaintext fallback.", + ); + writePayload(filePath, { + plaintextToken: trimmed, + updatedAt: Date.now(), + }); + return; + } + const encrypted = safeStorage.encryptString(trimmed); + writePayload(filePath, { + encryptedToken: encrypted.toString("base64"), + updatedAt: Date.now(), + }); + } catch (error) { + logger.error("Failed to persist AniList token.", error); + } + }, + + clearToken(): void { + if (!fs.existsSync(filePath)) return; + try { + fs.unlinkSync(filePath); + logger.info("Cleared stored AniList token."); + } catch (error) { + logger.error("Failed to clear stored AniList token.", error); + } + }, + }; +} diff --git a/src/core/services/anilist/anilist-update-queue.ts b/src/core/services/anilist/anilist-update-queue.ts new file mode 100644 index 0000000..4ee3c13 --- /dev/null +++ b/src/core/services/anilist/anilist-update-queue.ts @@ -0,0 +1,195 @@ +import * as fs from "fs"; +import * as path from "path"; + +const INITIAL_BACKOFF_MS = 30_000; +const MAX_BACKOFF_MS = 6 * 60 * 60 * 1000; +const MAX_ATTEMPTS = 8; +const MAX_ITEMS = 500; + +export interface AnilistQueuedUpdate { + key: string; + title: string; + episode: number; + createdAt: number; + attemptCount: number; + nextAttemptAt: number; + lastError: string | null; +} + +interface AnilistRetryQueuePayload { + pending?: AnilistQueuedUpdate[]; + deadLetter?: AnilistQueuedUpdate[]; +} + +export interface AnilistRetryQueueSnapshot { + pending: number; + ready: number; + deadLetter: number; +} + +export interface AnilistUpdateQueue { + enqueue: (key: string, title: string, episode: number) => void; + nextReady: (nowMs?: number) => AnilistQueuedUpdate | null; + markSuccess: (key: string) => void; + markFailure: (key: string, reason: string, nowMs?: number) => void; + getSnapshot: (nowMs?: number) => AnilistRetryQueueSnapshot; +} + +function ensureDir(filePath: string): void { + const dir = path.dirname(filePath); + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true }); + } +} + +function clampBackoffMs(attemptCount: number): number { + const computed = INITIAL_BACKOFF_MS * Math.pow(2, Math.max(0, attemptCount - 1)); + return Math.min(MAX_BACKOFF_MS, computed); +} + +export function createAnilistUpdateQueue( + filePath: string, + logger: { + info: (message: string) => void; + warn: (message: string, details?: unknown) => void; + error: (message: string, details?: unknown) => void; + }, +): AnilistUpdateQueue { + let pending: AnilistQueuedUpdate[] = []; + let deadLetter: AnilistQueuedUpdate[] = []; + + const persist = () => { + try { + ensureDir(filePath); + const payload: AnilistRetryQueuePayload = { pending, deadLetter }; + fs.writeFileSync(filePath, JSON.stringify(payload, null, 2), "utf-8"); + } catch (error) { + logger.error("Failed to persist AniList retry queue.", error); + } + }; + + const load = () => { + if (!fs.existsSync(filePath)) { + return; + } + try { + const raw = fs.readFileSync(filePath, "utf-8"); + const parsed = JSON.parse(raw) as AnilistRetryQueuePayload; + const parsedPending = Array.isArray(parsed.pending) ? parsed.pending : []; + const parsedDeadLetter = Array.isArray(parsed.deadLetter) + ? parsed.deadLetter + : []; + pending = parsedPending + .filter( + (item): item is AnilistQueuedUpdate => + item && + typeof item.key === "string" && + typeof item.title === "string" && + typeof item.episode === "number" && + item.episode > 0 && + typeof item.createdAt === "number" && + typeof item.attemptCount === "number" && + typeof item.nextAttemptAt === "number" && + (typeof item.lastError === "string" || item.lastError === null), + ) + .slice(0, MAX_ITEMS); + deadLetter = parsedDeadLetter + .filter( + (item): item is AnilistQueuedUpdate => + item && + typeof item.key === "string" && + typeof item.title === "string" && + typeof item.episode === "number" && + item.episode > 0 && + typeof item.createdAt === "number" && + typeof item.attemptCount === "number" && + typeof item.nextAttemptAt === "number" && + (typeof item.lastError === "string" || item.lastError === null), + ) + .slice(0, MAX_ITEMS); + } catch (error) { + logger.error("Failed to load AniList retry queue.", error); + } + }; + + load(); + + return { + enqueue(key: string, title: string, episode: number): void { + const existing = pending.find((item) => item.key === key); + if (existing) { + return; + } + if (pending.length >= MAX_ITEMS) { + pending.shift(); + } + pending.push({ + key, + title, + episode, + createdAt: Date.now(), + attemptCount: 0, + nextAttemptAt: Date.now(), + lastError: null, + }); + persist(); + logger.info(`Queued AniList retry for "${title}" episode ${episode}.`); + }, + + nextReady(nowMs: number = Date.now()): AnilistQueuedUpdate | null { + const ready = pending.find((item) => item.nextAttemptAt <= nowMs); + return ready ?? null; + }, + + markSuccess(key: string): void { + const before = pending.length; + pending = pending.filter((item) => item.key !== key); + if (pending.length !== before) { + persist(); + } + }, + + markFailure(key: string, reason: string, nowMs: number = Date.now()): void { + const item = pending.find((candidate) => candidate.key === key); + if (!item) { + return; + } + item.attemptCount += 1; + item.lastError = reason; + if (item.attemptCount >= MAX_ATTEMPTS) { + pending = pending.filter((candidate) => candidate.key !== key); + if (deadLetter.length >= MAX_ITEMS) { + deadLetter.shift(); + } + deadLetter.push({ + ...item, + nextAttemptAt: nowMs, + }); + logger.warn("AniList retry moved to dead-letter queue.", { + key, + reason, + attempts: item.attemptCount, + }); + persist(); + return; + } + item.nextAttemptAt = nowMs + clampBackoffMs(item.attemptCount); + persist(); + logger.warn("AniList retry scheduled with backoff.", { + key, + attemptCount: item.attemptCount, + nextAttemptAt: item.nextAttemptAt, + reason, + }); + }, + + getSnapshot(nowMs: number = Date.now()): AnilistRetryQueueSnapshot { + const ready = pending.filter((item) => item.nextAttemptAt <= nowMs).length; + return { + pending: pending.length, + ready, + deadLetter: deadLetter.length, + }; + }, + }; +} diff --git a/src/core/services/anilist/anilist-updater.test.ts b/src/core/services/anilist/anilist-updater.test.ts new file mode 100644 index 0000000..b1971d7 --- /dev/null +++ b/src/core/services/anilist/anilist-updater.test.ts @@ -0,0 +1,170 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import * as childProcess from "child_process"; + +import { + guessAnilistMediaInfo, + updateAnilistPostWatchProgress, +} from "./anilist-updater"; + +function createJsonResponse(payload: unknown): Response { + return new Response(JSON.stringify(payload), { + status: 200, + headers: { "content-type": "application/json" }, + }); +} + +test("guessAnilistMediaInfo uses guessit output when available", async () => { + const originalExecFile = childProcess.execFile; + ( + childProcess as unknown as { + execFile: typeof childProcess.execFile; + } + ).execFile = ((...args: unknown[]) => { + const callback = args[args.length - 1]; + const cb = typeof callback === "function" + ? (callback as (error: Error | null, stdout: string, stderr: string) => void) + : null; + cb?.(null, JSON.stringify({ title: "Guessit Title", episode: 7 }), ""); + return {} as childProcess.ChildProcess; + }) as typeof childProcess.execFile; + + try { + const result = await guessAnilistMediaInfo("/tmp/demo.mkv", null); + assert.deepEqual(result, { + title: "Guessit Title", + episode: 7, + source: "guessit", + }); + } finally { + ( + childProcess as unknown as { + execFile: typeof childProcess.execFile; + } + ).execFile = originalExecFile; + } +}); + +test("guessAnilistMediaInfo falls back to parser when guessit fails", async () => { + const originalExecFile = childProcess.execFile; + ( + childProcess as unknown as { + execFile: typeof childProcess.execFile; + } + ).execFile = ((...args: unknown[]) => { + const callback = args[args.length - 1]; + const cb = typeof callback === "function" + ? (callback as (error: Error | null, stdout: string, stderr: string) => void) + : null; + cb?.(new Error("guessit not found"), "", ""); + return {} as childProcess.ChildProcess; + }) as typeof childProcess.execFile; + + try { + const result = await guessAnilistMediaInfo( + "/tmp/My Anime S01E03.mkv", + null, + ); + assert.deepEqual(result, { + title: "My Anime", + episode: 3, + source: "fallback", + }); + } finally { + ( + childProcess as unknown as { + execFile: typeof childProcess.execFile; + } + ).execFile = originalExecFile; + } +}); + +test("updateAnilistPostWatchProgress updates progress when behind", async () => { + const originalFetch = globalThis.fetch; + let call = 0; + globalThis.fetch = (async () => { + call += 1; + if (call === 1) { + return createJsonResponse({ + data: { + Page: { + media: [ + { + id: 11, + episodes: 24, + title: { english: "Demo Show", romaji: "Demo Show" }, + }, + ], + }, + }, + }); + } + if (call === 2) { + return createJsonResponse({ + data: { + Media: { + id: 11, + mediaListEntry: { progress: 2, status: "CURRENT" }, + }, + }, + }); + } + return createJsonResponse({ + data: { SaveMediaListEntry: { progress: 3, status: "CURRENT" } }, + }); + }) as typeof fetch; + + try { + const result = await updateAnilistPostWatchProgress("token", "Demo Show", 3); + assert.equal(result.status, "updated"); + assert.match(result.message, /episode 3/i); + } finally { + globalThis.fetch = originalFetch; + } +}); + +test("updateAnilistPostWatchProgress skips when progress already reached", async () => { + const originalFetch = globalThis.fetch; + let call = 0; + globalThis.fetch = (async () => { + call += 1; + if (call === 1) { + return createJsonResponse({ + data: { + Page: { + media: [{ id: 22, episodes: 12, title: { english: "Skip Show" } }], + }, + }, + }); + } + return createJsonResponse({ + data: { + Media: { id: 22, mediaListEntry: { progress: 12, status: "CURRENT" } }, + }, + }); + }) as typeof fetch; + + try { + const result = await updateAnilistPostWatchProgress("token", "Skip Show", 10); + assert.equal(result.status, "skipped"); + assert.match(result.message, /already at episode/i); + } finally { + globalThis.fetch = originalFetch; + } +}); + +test("updateAnilistPostWatchProgress returns error when search fails", async () => { + const originalFetch = globalThis.fetch; + globalThis.fetch = (async () => + createJsonResponse({ + errors: [{ message: "bad request" }], + })) as typeof fetch; + + try { + const result = await updateAnilistPostWatchProgress("token", "Bad", 1); + assert.equal(result.status, "error"); + assert.match(result.message, /search failed/i); + } finally { + globalThis.fetch = originalFetch; + } +}); diff --git a/src/main.ts b/src/main.ts index 07a9e8d..9ddc048 100644 --- a/src/main.ts +++ b/src/main.ts @@ -137,6 +137,8 @@ import { type AnilistMediaGuess, updateAnilistPostWatchProgress, } from "./core/services/anilist/anilist-updater"; +import { createAnilistTokenStore } from "./core/services/anilist/anilist-token-store"; +import { createAnilistUpdateQueue } from "./core/services/anilist/anilist-update-queue"; import { applyRuntimeOptionResultRuntimeService } from "./core/services/runtime-options-ipc-service"; import { createAppReadyRuntimeRunner, @@ -205,11 +207,14 @@ const DEFAULT_MPV_LOG_FILE = path.join( const ANILIST_SETUP_CLIENT_ID_URL = "https://anilist.co/api/v2/oauth/authorize"; const ANILIST_SETUP_RESPONSE_TYPE = "token"; const ANILIST_DEFAULT_CLIENT_ID = "36084"; +const ANILIST_REDIRECT_URI = "https://anilist.subminer.moe/"; const ANILIST_DEVELOPER_SETTINGS_URL = "https://anilist.co/settings/developer"; const ANILIST_UPDATE_MIN_WATCH_RATIO = 0.85; const ANILIST_UPDATE_MIN_WATCH_SECONDS = 10 * 60; const ANILIST_DURATION_RETRY_INTERVAL_MS = 15_000; const ANILIST_MAX_ATTEMPTED_UPDATE_KEYS = 1000; +const ANILIST_TOKEN_STORE_FILE = "anilist-token-store.json"; +const ANILIST_RETRY_QUEUE_FILE = "anilist-retry-queue.json"; let anilistCurrentMediaKey: string | null = null; let anilistCurrentMediaDurationSec: number | null = null; @@ -218,6 +223,7 @@ let anilistCurrentMediaGuessPromise: Promise | null = let anilistLastDurationProbeAtMs = 0; let anilistUpdateInFlight = false; const anilistAttemptedUpdateKeys = new Set(); +let anilistCachedAccessToken: string | null = null; function resolveConfigDir(): string { const xdgConfigHome = process.env.XDG_CONFIG_HOME?.trim(); @@ -257,6 +263,22 @@ const CONFIG_DIR = resolveConfigDir(); const USER_DATA_PATH = CONFIG_DIR; const DEFAULT_MPV_LOG_PATH = process.env.SUBMINER_MPV_LOG?.trim() || DEFAULT_MPV_LOG_FILE; const configService = new ConfigService(CONFIG_DIR); +const anilistTokenStore = createAnilistTokenStore( + path.join(USER_DATA_PATH, ANILIST_TOKEN_STORE_FILE), + { + info: (message: string) => console.info(message), + warn: (message: string, details?: unknown) => console.warn(message, details), + error: (message: string, details?: unknown) => console.error(message, details), + }, +); +const anilistUpdateQueue = createAnilistUpdateQueue( + path.join(USER_DATA_PATH, ANILIST_RETRY_QUEUE_FILE), + { + info: (message: string) => console.info(message), + warn: (message: string, details?: unknown) => console.warn(message, details), + error: (message: string, details?: unknown) => console.error(message, details), + }, +); const isDev = process.argv.includes("--dev") || process.argv.includes("--debug"); const texthookerService = new TexthookerService(); @@ -606,6 +628,13 @@ function setAnilistClientSecretState(partial: Partial { const resolved = getResolvedConfig(); const now = Date.now(); - void options; if (!isAnilistTrackingEnabled(resolved)) { + anilistCachedAccessToken = null; setAnilistClientSecretState({ status: "not_checked", source: "none", @@ -757,6 +787,10 @@ async function refreshAnilistClientSecretState(options?: { force?: boolean }): P } const rawAccessToken = resolved.anilist.accessToken.trim(); if (rawAccessToken.length > 0) { + if (options?.force || rawAccessToken !== anilistCachedAccessToken) { + anilistTokenStore.saveToken(rawAccessToken); + } + anilistCachedAccessToken = rawAccessToken; setAnilistClientSecretState({ status: "resolved", source: "literal", @@ -768,6 +802,25 @@ async function refreshAnilistClientSecretState(options?: { force?: boolean }): P return rawAccessToken; } + if (!options?.force && anilistCachedAccessToken && anilistCachedAccessToken.length > 0) { + return anilistCachedAccessToken; + } + + const storedToken = anilistTokenStore.loadToken()?.trim() ?? ""; + if (storedToken.length > 0) { + anilistCachedAccessToken = storedToken; + setAnilistClientSecretState({ + status: "resolved", + source: "stored", + message: "using stored anilist access token", + resolvedAt: now, + errorAt: null, + }); + appState.anilistSetupPageOpened = false; + return storedToken; + } + + anilistCachedAccessToken = null; setAnilistClientSecretState({ status: "error", source: "none", @@ -876,6 +929,39 @@ function rememberAnilistAttemptedUpdateKey(key: string): void { } } +async function processNextAnilistRetryUpdate(): Promise { + const queued = anilistUpdateQueue.nextReady(); + refreshAnilistRetryQueueState(); + if (!queued) { + return; + } + + appState.anilistRetryQueueState.lastAttemptAt = Date.now(); + const accessToken = await refreshAnilistClientSecretState(); + if (!accessToken) { + appState.anilistRetryQueueState.lastError = "AniList token unavailable for queued retry."; + return; + } + + const result = await updateAnilistPostWatchProgress( + accessToken, + queued.title, + queued.episode, + ); + if (result.status === "updated" || result.status === "skipped") { + anilistUpdateQueue.markSuccess(queued.key); + rememberAnilistAttemptedUpdateKey(queued.key); + appState.anilistRetryQueueState.lastError = null; + refreshAnilistRetryQueueState(); + logger.info(`[AniList queue] ${result.message}`); + return; + } + + anilistUpdateQueue.markFailure(queued.key, result.message); + appState.anilistRetryQueueState.lastError = result.message; + refreshAnilistRetryQueueState(); +} + async function maybeRunAnilistPostWatchUpdate(): Promise { if (anilistUpdateInFlight) { return; @@ -922,8 +1008,16 @@ async function maybeRunAnilistPostWatchUpdate(): Promise { anilistUpdateInFlight = true; try { + await processNextAnilistRetryUpdate(); + const accessToken = await refreshAnilistClientSecretState(); if (!accessToken) { + anilistUpdateQueue.enqueue(attemptKey, guess.title, guess.episode); + anilistUpdateQueue.markFailure( + attemptKey, + "cannot authenticate without anilist.accessToken", + ); + refreshAnilistRetryQueueState(); showMpvOsd("AniList: access token not configured"); return; } @@ -934,15 +1028,22 @@ async function maybeRunAnilistPostWatchUpdate(): Promise { ); if (result.status === "updated") { rememberAnilistAttemptedUpdateKey(attemptKey); + anilistUpdateQueue.markSuccess(attemptKey); + refreshAnilistRetryQueueState(); showMpvOsd(result.message); logger.info(result.message); return; } if (result.status === "skipped") { rememberAnilistAttemptedUpdateKey(attemptKey); + anilistUpdateQueue.markSuccess(attemptKey); + refreshAnilistRetryQueueState(); logger.info(result.message); return; } + anilistUpdateQueue.enqueue(attemptKey, guess.title, guess.episode); + anilistUpdateQueue.markFailure(attemptKey, result.message); + refreshAnilistRetryQueueState(); showMpvOsd(`AniList: ${result.message}`); logger.warn(result.message); } finally { @@ -1129,6 +1230,7 @@ const startupState = runStartupBootstrapRuntimeService( applyStartupState(appState, startupState); void refreshAnilistClientSecretState({ force: true }); +refreshAnilistRetryQueueState(); function handleCliCommand( args: CliArgs, diff --git a/src/main/state.ts b/src/main/state.ts index c502417..85e4384 100644 --- a/src/main/state.ts +++ b/src/main/state.ts @@ -20,12 +20,20 @@ import type { BaseWindowTracker } from "../window-trackers"; export interface AnilistSecretResolutionState { status: "not_checked" | "resolved" | "error"; - source: "none" | "literal" | "command"; + source: "none" | "literal" | "stored"; message: string | null; resolvedAt: number | null; errorAt: number | null; } +export interface AnilistRetryQueueState { + pending: number; + ready: number; + deadLetter: number; + lastAttemptAt: number | null; + lastError: string | null; +} + export interface AppState { yomitanExt: Extension | null; yomitanSettingsWindow: BrowserWindow | null; @@ -68,6 +76,7 @@ export interface AppState { jlptLevelLookup: (term: string) => JlptLevel | null; frequencyRankLookup: FrequencyDictionaryLookup; anilistSetupPageOpened: boolean; + anilistRetryQueueState: AnilistRetryQueueState; } export interface AppStateInitialValues { @@ -138,6 +147,13 @@ export function createAppState(values: AppStateInitialValues): AppState { jlptLevelLookup: () => null, frequencyRankLookup: () => null, anilistSetupPageOpened: false, + anilistRetryQueueState: { + pending: 0, + ready: 0, + deadLetter: 0, + lastAttemptAt: null, + lastError: null, + }, }; }