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 c9c5d76..aeea7cf 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,10 +1,10 @@ --- id: TASK-29 title: Add Anilist integration for post-watch updates -status: In Progress +status: Done assignee: [] created_date: '2026-02-13 17:57' -updated_date: '2026-02-17 04:19' +updated_date: '2026-02-17 09:27' labels: - anilist - anime @@ -32,26 +32,36 @@ Requirements: ## Acceptance Criteria -- [ ] #1 Application can authenticate with Anilist and store tokens securely for desktop user sessions. -- [ ] #2 Anilist update flow from existing local watch/session metadata can update animes watched progress after watching. -- [ ] #3 Core functionality equivalent to mpv-anilist-updater is implemented for this use case (progress/status sync) inside the Electron app. -- [ ] #4 A background/in-process queue handles transient API failures and retries without losing updates. -- [ ] #5 Sync updates are non-blocking and do not degrade normal playback/mining behavior. -- [ ] #6 Anilist integration code is modularized to allow future feature additions without major refactor (clear service boundaries/interfaces). -- [ ] #7 Error states and duplicate/duplicate-inconsistent updates are handled deterministically (idempotent where practical). +- [x] #1 Application can authenticate with Anilist and store tokens securely for desktop user sessions. +- [x] #2 Anilist update flow from existing local watch/session metadata can update animes watched progress after watching. +- [x] #3 Core functionality equivalent to mpv-anilist-updater is implemented for this use case (progress/status sync) inside the Electron app. +- [x] #4 A background/in-process queue handles transient API failures and retries without losing updates. +- [x] #5 Sync updates are non-blocking and do not degrade normal playback/mining behavior. +- [x] #6 Anilist integration code is modularized to allow future feature additions without major refactor (clear service boundaries/interfaces). +- [x] #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. + +Implemented AniList control surfaces across CLI and IPC. CLI: added `--anilist-status`, `--anilist-logout`, `--anilist-setup`, `--anilist-retry-queue` parsing/help/dispatch/runtime wiring in `src/cli/args.ts`, `src/cli/help.ts`, `src/core/services/cli-command.ts`, `src/main/cli-runtime.ts`, and `src/main.ts`. IPC: added `anilist:get-status`, `anilist:clear-token`, `anilist:open-setup`, `anilist:get-queue-status`, `anilist:retry-now` handlers and dependency wiring in `src/core/services/ipc.ts`, `src/main/dependencies.ts`, and `src/main.ts`. Added retry queue tests in `src/core/services/anilist/anilist-update-queue.test.ts`; added token-store tests in `src/core/services/anilist/anilist-token-store.test.ts` with runtime guard for environments without Electron safeStorage; updated `package.json` test list and AniList command/channel docs in `docs/configuration.md`. + +Validation run: `pnpm run test:fast` passed (config + core dist suite). New AniList queue tests run in core suite; token-store tests are auto-skipped in plain Node environments where Electron `safeStorage` is unavailable (still active when safeStorage exists). +## Final Summary + + +Completed AniList post-watch integration in the Electron app with secure token lifecycle, post-watch progress sync, and persistent retry/backoff queue with dead-letter behavior. Added user-facing control surfaces via CLI (`--anilist-status`, `--anilist-logout`, `--anilist-setup`, `--anilist-retry-queue`) and IPC (`anilist:get-status`, `anilist:clear-token`, `anilist:open-setup`, `anilist:get-queue-status`, `anilist:retry-now`), plus docs and test coverage; validated via `pnpm run test:fast`. + + ## Definition of Done -- [ ] #1 Core Anilist service module exists and is wired into application flow for post-watch updates. -- [ ] #2 OAuth/token lifecycle is implemented with safe local persistence and revocation/logout behavior. -- [ ] #3 A retry/backoff and dead-letter strategy for failed syncs is implemented. -- [ ] #4 User-visible settings/docs explain how to connect/manage Anilist and what data is synced. -- [ ] #5 At least smoke/integration coverage (or validated test plan) for mapping and sync flow is in place. +- [x] #1 Core Anilist service module exists and is wired into application flow for post-watch updates. +- [x] #2 OAuth/token lifecycle is implemented with safe local persistence and revocation/logout behavior. +- [x] #3 A retry/backoff and dead-letter strategy for failed syncs is implemented. +- [x] #4 User-visible settings/docs explain how to connect/manage Anilist and what data is synced. +- [x] #5 At least smoke/integration coverage (or validated test plan) for mapping and sync flow is in place. diff --git a/docs/configuration.md b/docs/configuration.md index ec384c1..719c529 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -427,6 +427,21 @@ Token + detection notes: - Detection quality is best when `guessit` is installed and available on `PATH`. - When `guessit` cannot parse or is missing, SubMiner falls back automatically to internal filename parsing. +AniList CLI commands: + +- `--anilist-status`: print current AniList token resolution state and retry queue counters. +- `--anilist-logout`: clear stored AniList token from local persisted state. +- `--anilist-setup`: open AniList setup/auth flow helper window. +- `--anilist-retry-queue`: process one ready retry queue item immediately. + +AniList IPC channels: + +- `anilist:get-status`: return token status + retry queue state snapshot. +- `anilist:clear-token`: clear stored AniList token and reset token status state. +- `anilist:open-setup`: open AniList setup/auth flow helper window. +- `anilist:get-queue-status`: return retry queue state snapshot. +- `anilist:retry-now`: process one ready retry queue item immediately. + ### Keybindings Add a `keybindings` array to configure keyboard shortcuts that send commands to mpv: diff --git a/package.json b/package.json index 034d056..8249fa6 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,7 @@ "docs:build": "VITE_EXTRA_EXTENSIONS=jsonc vitepress build docs", "docs:preview": "VITE_EXTRA_EXTENSIONS=jsonc vitepress preview docs --host 0.0.0.0 --port 4173 --strictPort", "test:config:dist": "node --test dist/config/config.test.js", - "test:core:dist": "node --test dist/cli/args.test.js dist/cli/help.test.js dist/core/services/cli-command.test.js dist/core/services/field-grouping-overlay.test.js dist/core/services/numeric-shortcut-session.test.js dist/core/services/secondary-subtitle.test.js dist/core/services/mpv-render-metrics.test.js dist/core/services/overlay-content-measurement.test.js dist/core/services/mpv-control.test.js dist/core/services/mpv.test.js dist/core/services/runtime-options-ipc.test.js dist/core/services/runtime-config.test.js dist/core/services/tokenizer.test.js dist/core/services/subsync.test.js dist/core/services/overlay-bridge.test.js dist/core/services/overlay-manager.test.js dist/core/services/overlay-shortcut-handler.test.js dist/core/services/mining.test.js dist/core/services/anki-jimaku.test.js dist/core/services/immersion-tracker-service.test.js dist/core/services/app-ready.test.js dist/core/services/startup-bootstrap.test.js dist/subsync/utils.test.js dist/main/anilist-url-guard.test.js", + "test:core:dist": "node --test dist/cli/args.test.js dist/cli/help.test.js dist/core/services/cli-command.test.js dist/core/services/ipc.test.js dist/core/services/field-grouping-overlay.test.js dist/core/services/numeric-shortcut-session.test.js dist/core/services/secondary-subtitle.test.js dist/core/services/mpv-render-metrics.test.js dist/core/services/overlay-content-measurement.test.js dist/core/services/mpv-control.test.js dist/core/services/mpv.test.js dist/core/services/runtime-options-ipc.test.js dist/core/services/runtime-config.test.js dist/core/services/tokenizer.test.js dist/core/services/subsync.test.js dist/core/services/overlay-bridge.test.js dist/core/services/overlay-manager.test.js dist/core/services/overlay-shortcut-handler.test.js dist/core/services/mining.test.js dist/core/services/anki-jimaku.test.js dist/core/services/immersion-tracker-service.test.js dist/core/services/app-ready.test.js dist/core/services/startup-bootstrap.test.js dist/core/services/anilist/anilist-token-store.test.js dist/core/services/anilist/anilist-update-queue.test.js dist/subsync/utils.test.js dist/main/anilist-url-guard.test.js", "test:subtitle:dist": "echo \"Subtitle tests are currently not configured\"", "test": "pnpm run test:config && pnpm run test:core", "test:config": "pnpm run build && pnpm run test:config:dist", diff --git a/src/cli/args.test.ts b/src/cli/args.test.ts index 52f8378..abddace 100644 --- a/src/cli/args.test.ts +++ b/src/cli/args.test.ts @@ -46,4 +46,14 @@ test("hasExplicitCommand and shouldStartApp preserve command intent", () => { assert.equal(refreshKnownWords.help, false); assert.equal(hasExplicitCommand(refreshKnownWords), true); assert.equal(shouldStartApp(refreshKnownWords), false); + + const anilistStatus = parseArgs(["--anilist-status"]); + assert.equal(anilistStatus.anilistStatus, true); + assert.equal(hasExplicitCommand(anilistStatus), true); + assert.equal(shouldStartApp(anilistStatus), false); + + const anilistRetryQueue = parseArgs(["--anilist-retry-queue"]); + assert.equal(anilistRetryQueue.anilistRetryQueue, true); + assert.equal(hasExplicitCommand(anilistRetryQueue), true); + assert.equal(shouldStartApp(anilistRetryQueue), false); }); diff --git a/src/cli/args.ts b/src/cli/args.ts index 2654d45..09ca8d9 100644 --- a/src/cli/args.ts +++ b/src/cli/args.ts @@ -22,6 +22,10 @@ export interface CliArgs { triggerSubsync: boolean; markAudioCard: boolean; openRuntimeOptions: boolean; + anilistStatus: boolean; + anilistLogout: boolean; + anilistSetup: boolean; + anilistRetryQueue: boolean; texthooker: boolean; help: boolean; autoStartOverlay: boolean; @@ -62,6 +66,10 @@ export function parseArgs(argv: string[]): CliArgs { triggerSubsync: false, markAudioCard: false, openRuntimeOptions: false, + anilistStatus: false, + anilistLogout: false, + anilistSetup: false, + anilistRetryQueue: false, texthooker: false, help: false, autoStartOverlay: false, @@ -109,6 +117,10 @@ export function parseArgs(argv: string[]): CliArgs { else if (arg === "--trigger-subsync") args.triggerSubsync = true; else if (arg === "--mark-audio-card") args.markAudioCard = true; else if (arg === "--open-runtime-options") args.openRuntimeOptions = true; + else if (arg === "--anilist-status") args.anilistStatus = true; + else if (arg === "--anilist-logout") args.anilistLogout = true; + else if (arg === "--anilist-setup") args.anilistSetup = true; + else if (arg === "--anilist-retry-queue") args.anilistRetryQueue = true; else if (arg === "--texthooker") args.texthooker = true; else if (arg === "--auto-start-overlay") args.autoStartOverlay = true; else if (arg === "--generate-config") args.generateConfig = true; @@ -190,6 +202,10 @@ export function hasExplicitCommand(args: CliArgs): boolean { args.triggerSubsync || args.markAudioCard || args.openRuntimeOptions || + args.anilistStatus || + args.anilistLogout || + args.anilistSetup || + args.anilistRetryQueue || args.texthooker || args.generateConfig || args.help diff --git a/src/cli/help.test.ts b/src/cli/help.test.ts index a4f0923..b562489 100644 --- a/src/cli/help.test.ts +++ b/src/cli/help.test.ts @@ -18,4 +18,6 @@ test("printHelp includes configured texthooker port", () => { assert.match(output, /--help\s+Show this help/); assert.match(output, /default: 7777/); assert.match(output, /--refresh-known-words/); + assert.match(output, /--anilist-status/); + assert.match(output, /--anilist-retry-queue/); }); diff --git a/src/cli/help.ts b/src/cli/help.ts index 4494ae3..7d5ae17 100644 --- a/src/cli/help.ts +++ b/src/cli/help.ts @@ -25,6 +25,10 @@ SubMiner CLI commands: --trigger-subsync Run subtitle sync --mark-audio-card Mark last card as audio card --open-runtime-options Open runtime options palette + --anilist-status Show AniList token and retry queue status + --anilist-logout Clear stored AniList token + --anilist-setup Open AniList setup flow in app/browser + --anilist-retry-queue Retry next ready AniList queue item now --auto-start-overlay Auto-hide mpv subtitles on connect (show overlay) --socket PATH Override MPV IPC socket/pipe path --backend BACKEND Override window tracker backend (auto, hyprland, sway, x11, macos) diff --git a/src/core/services/anilist/anilist-token-store.test.ts b/src/core/services/anilist/anilist-token-store.test.ts new file mode 100644 index 0000000..d6022ca --- /dev/null +++ b/src/core/services/anilist/anilist-token-store.test.ts @@ -0,0 +1,162 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import * as fs from "fs"; +import * as os from "os"; +import * as path from "path"; +import { safeStorage } from "electron"; + +import { createAnilistTokenStore } from "./anilist-token-store"; + +function createTempTokenFile(): string { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "subminer-anilist-token-")); + return path.join(dir, "token.json"); +} + +function createLogger() { + return { + info: (_message: string) => {}, + warn: (_message: string) => {}, + error: (_message: string) => {}, + }; +} + +type SafeStorageLike = { + isEncryptionAvailable: () => boolean; + encryptString: (value: string) => Buffer; + decryptString: (value: Buffer) => string; +}; + +const safeStorageApi = safeStorage as unknown as Partial; +const hasSafeStorage = + typeof safeStorageApi?.isEncryptionAvailable === "function" && + typeof safeStorageApi?.encryptString === "function" && + typeof safeStorageApi?.decryptString === "function"; + +const originalSafeStorage: SafeStorageLike | null = hasSafeStorage + ? { + isEncryptionAvailable: safeStorageApi.isEncryptionAvailable as () => boolean, + encryptString: safeStorageApi.encryptString as (value: string) => Buffer, + decryptString: safeStorageApi.decryptString as (value: Buffer) => string, + } + : null; + +function mockSafeStorage(encryptionAvailable: boolean): void { + if (!hasSafeStorage) return; + ( + safeStorage as unknown as { + isEncryptionAvailable: typeof safeStorage.isEncryptionAvailable; + encryptString: typeof safeStorage.encryptString; + decryptString: typeof safeStorage.decryptString; + } + ).isEncryptionAvailable = () => encryptionAvailable; + ( + safeStorage as unknown as { + encryptString: typeof safeStorage.encryptString; + decryptString: typeof safeStorage.decryptString; + } + ).encryptString = (value: string) => Buffer.from(`enc:${value}`, "utf-8"); + ( + safeStorage as unknown as { + decryptString: typeof safeStorage.decryptString; + } + ).decryptString = (value: Buffer) => { + const raw = value.toString("utf-8"); + return raw.startsWith("enc:") ? raw.slice(4) : raw; + }; +} + +function restoreSafeStorage(): void { + if (!hasSafeStorage || !originalSafeStorage) return; + ( + safeStorage as unknown as { + isEncryptionAvailable: typeof safeStorage.isEncryptionAvailable; + encryptString: typeof safeStorage.encryptString; + decryptString: typeof safeStorage.decryptString; + } + ).isEncryptionAvailable = originalSafeStorage.isEncryptionAvailable; + ( + safeStorage as unknown as { + encryptString: typeof safeStorage.encryptString; + decryptString: typeof safeStorage.decryptString; + } + ).encryptString = originalSafeStorage.encryptString; + ( + safeStorage as unknown as { + decryptString: typeof safeStorage.decryptString; + } + ).decryptString = originalSafeStorage.decryptString; +} + +test("anilist token store saves and loads encrypted token", { skip: !hasSafeStorage }, () => { + mockSafeStorage(true); + try { + const filePath = createTempTokenFile(); + const store = createAnilistTokenStore(filePath, createLogger()); + store.saveToken(" demo-token "); + + const payload = JSON.parse(fs.readFileSync(filePath, "utf-8")) as { + encryptedToken?: string; + plaintextToken?: string; + }; + assert.equal(typeof payload.encryptedToken, "string"); + assert.equal(payload.plaintextToken, undefined); + assert.equal(store.loadToken(), "demo-token"); + } finally { + restoreSafeStorage(); + } +}); + +test("anilist token store falls back to plaintext when encryption unavailable", { skip: !hasSafeStorage }, () => { + mockSafeStorage(false); + try { + const filePath = createTempTokenFile(); + const store = createAnilistTokenStore(filePath, createLogger()); + store.saveToken("plain-token"); + + const payload = JSON.parse(fs.readFileSync(filePath, "utf-8")) as { + plaintextToken?: string; + }; + assert.equal(payload.plaintextToken, "plain-token"); + assert.equal(store.loadToken(), "plain-token"); + } finally { + restoreSafeStorage(); + } +}); + +test("anilist token store migrates legacy plaintext to encrypted", { skip: !hasSafeStorage }, () => { + const filePath = createTempTokenFile(); + fs.writeFileSync( + filePath, + JSON.stringify({ plaintextToken: "legacy-token", updatedAt: Date.now() }), + "utf-8", + ); + + mockSafeStorage(true); + try { + const store = createAnilistTokenStore(filePath, createLogger()); + assert.equal(store.loadToken(), "legacy-token"); + + const payload = JSON.parse(fs.readFileSync(filePath, "utf-8")) as { + encryptedToken?: string; + plaintextToken?: string; + }; + assert.equal(typeof payload.encryptedToken, "string"); + assert.equal(payload.plaintextToken, undefined); + } finally { + restoreSafeStorage(); + } +}); + +test("anilist token store clears persisted token file", { skip: !hasSafeStorage }, () => { + mockSafeStorage(true); + try { + const filePath = createTempTokenFile(); + const store = createAnilistTokenStore(filePath, createLogger()); + store.saveToken("to-clear"); + assert.equal(fs.existsSync(filePath), true); + store.clearToken(); + assert.equal(fs.existsSync(filePath), false); + } finally { + restoreSafeStorage(); + } +}); diff --git a/src/core/services/anilist/anilist-update-queue.test.ts b/src/core/services/anilist/anilist-update-queue.test.ts new file mode 100644 index 0000000..a10da10 --- /dev/null +++ b/src/core/services/anilist/anilist-update-queue.test.ts @@ -0,0 +1,93 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import * as fs from "fs"; +import * as os from "os"; +import * as path from "path"; + +import { createAnilistUpdateQueue } from "./anilist-update-queue"; + +function createTempQueueFile(): string { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "subminer-anilist-queue-")); + return path.join(dir, "queue.json"); +} + +function createLogger() { + const info: string[] = []; + const warn: string[] = []; + const error: string[] = []; + return { + info, + warn, + error, + logger: { + info: (message: string) => info.push(message), + warn: (message: string) => warn.push(message), + error: (message: string) => error.push(message), + }, + }; +} + +test("anilist update queue enqueues, snapshots, and dequeues success", () => { + const queueFile = createTempQueueFile(); + const loggerState = createLogger(); + const queue = createAnilistUpdateQueue(queueFile, loggerState.logger); + + queue.enqueue("k1", "Demo", 1); + const snapshot = queue.getSnapshot(Number.MAX_SAFE_INTEGER); + assert.deepEqual(snapshot, { pending: 1, ready: 1, deadLetter: 0 }); + assert.equal(queue.nextReady(Number.MAX_SAFE_INTEGER)?.key, "k1"); + + queue.markSuccess("k1"); + assert.deepEqual(queue.getSnapshot(Number.MAX_SAFE_INTEGER), { + pending: 0, + ready: 0, + deadLetter: 0, + }); + assert.ok(loggerState.info.some((message) => message.includes("Queued AniList retry"))); +}); + +test("anilist update queue applies retry backoff and dead-letter", () => { + const queueFile = createTempQueueFile(); + const loggerState = createLogger(); + const queue = createAnilistUpdateQueue(queueFile, loggerState.logger); + + const now = 1_700_000_000_000; + queue.enqueue("k2", "Backoff Demo", 2); + + queue.markFailure("k2", "fail-1", now); + const firstRetry = queue.nextReady(now); + assert.equal(firstRetry, null); + + const pendingPayload = JSON.parse(fs.readFileSync(queueFile, "utf-8")) as { + pending: Array<{ attemptCount: number; nextAttemptAt: number }>; + }; + assert.equal(pendingPayload.pending[0]?.attemptCount, 1); + assert.equal(pendingPayload.pending[0]?.nextAttemptAt, now + 30_000); + + for (let attempt = 2; attempt <= 8; attempt += 1) { + queue.markFailure("k2", `fail-${attempt}`, now); + } + + const snapshot = queue.getSnapshot(Number.MAX_SAFE_INTEGER); + assert.deepEqual(snapshot, { pending: 0, ready: 0, deadLetter: 1 }); + assert.ok( + loggerState.warn.some((message) => + message.includes("AniList retry moved to dead-letter queue."), + ), + ); +}); + +test("anilist update queue persists and reloads from disk", () => { + const queueFile = createTempQueueFile(); + const loggerState = createLogger(); + const queueA = createAnilistUpdateQueue(queueFile, loggerState.logger); + queueA.enqueue("k3", "Persist Demo", 3); + + const queueB = createAnilistUpdateQueue(queueFile, loggerState.logger); + assert.deepEqual(queueB.getSnapshot(Number.MAX_SAFE_INTEGER), { + pending: 1, + ready: 1, + deadLetter: 0, + }); + assert.equal(queueB.nextReady(Number.MAX_SAFE_INTEGER)?.title, "Persist Demo"); +}); diff --git a/src/core/services/cli-command.test.ts b/src/core/services/cli-command.test.ts index 46bb351..b37b53c 100644 --- a/src/core/services/cli-command.test.ts +++ b/src/core/services/cli-command.test.ts @@ -28,6 +28,10 @@ function makeArgs(overrides: Partial = {}): CliArgs { markAudioCard: false, refreshKnownWords: false, openRuntimeOptions: false, + anilistStatus: false, + anilistLogout: false, + anilistSetup: false, + anilistRetryQueue: false, texthooker: false, help: false, autoStartOverlay: false, @@ -125,6 +129,35 @@ function createDeps(overrides: Partial = {}) { openRuntimeOptionsPalette: () => { calls.push("openRuntimeOptionsPalette"); }, + getAnilistStatus: () => ({ + tokenStatus: "resolved", + tokenSource: "stored", + tokenMessage: null, + tokenResolvedAt: 1, + tokenErrorAt: null, + queuePending: 2, + queueReady: 1, + queueDeadLetter: 0, + queueLastAttemptAt: 2, + queueLastError: null, + }), + clearAnilistToken: () => { + calls.push("clearAnilistToken"); + }, + openAnilistSetup: () => { + calls.push("openAnilistSetup"); + }, + getAnilistQueueStatus: () => ({ + pending: 2, + ready: 1, + deadLetter: 0, + lastAttemptAt: null, + lastError: null, + }), + retryAnilistQueue: async () => { + calls.push("retryAnilistQueue"); + return { ok: true, message: "AniList retry processed." }; + }, printHelp: () => { calls.push("printHelp"); }, @@ -281,6 +314,8 @@ test("handleCliCommand handles visibility and utility command dispatches", () => }, { args: { toggleSecondarySub: true }, expected: "cycleSecondarySubMode" }, { args: { openRuntimeOptions: true }, expected: "openRuntimeOptionsPalette" }, + { args: { anilistLogout: true }, expected: "clearAnilistToken" }, + { args: { anilistSetup: true }, expected: "openAnilistSetup" }, ]; for (const entry of cases) { @@ -293,6 +328,21 @@ test("handleCliCommand handles visibility and utility command dispatches", () => } }); +test("handleCliCommand logs AniList status details", () => { + const { deps, calls } = createDeps(); + handleCliCommand(makeArgs({ anilistStatus: true }), "initial", deps); + assert.ok(calls.some((value) => value.startsWith("log:AniList token status:"))); + assert.ok(calls.some((value) => value.startsWith("log:AniList queue:"))); +}); + +test("handleCliCommand runs AniList retry command", async () => { + const { deps, calls } = createDeps(); + handleCliCommand(makeArgs({ anilistRetryQueue: true }), "initial", deps); + await new Promise((resolve) => setImmediate(resolve)); + assert.ok(calls.includes("retryAnilistQueue")); + assert.ok(calls.includes("log:AniList retry processed.")); +}); + test("handleCliCommand runs refresh-known-words command", () => { const { deps, calls } = createDeps(); diff --git a/src/core/services/cli-command.ts b/src/core/services/cli-command.ts index cd05d4e..94ac374 100644 --- a/src/core/services/cli-command.ts +++ b/src/core/services/cli-command.ts @@ -35,6 +35,28 @@ export interface CliCommandServiceDeps { triggerSubsyncFromConfig: () => Promise; markLastCardAsAudioCard: () => Promise; openRuntimeOptionsPalette: () => void; + getAnilistStatus: () => { + tokenStatus: "not_checked" | "resolved" | "error"; + tokenSource: "none" | "literal" | "stored"; + tokenMessage: string | null; + tokenResolvedAt: number | null; + tokenErrorAt: number | null; + queuePending: number; + queueReady: number; + queueDeadLetter: number; + queueLastAttemptAt: number | null; + queueLastError: string | null; + }; + clearAnilistToken: () => void; + openAnilistSetup: () => void; + getAnilistQueueStatus: () => { + pending: number; + ready: number; + deadLetter: number; + lastAttemptAt: number | null; + lastError: string | null; + }; + retryAnilistQueue: () => Promise<{ ok: boolean; message: string }>; printHelp: () => void; hasMainWindow: () => boolean; getMultiCopyTimeoutMs: () => number; @@ -97,6 +119,14 @@ interface UiCliRuntime { printHelp: () => void; } +interface AnilistCliRuntime { + getStatus: CliCommandServiceDeps["getAnilistStatus"]; + clearToken: CliCommandServiceDeps["clearAnilistToken"]; + openSetup: CliCommandServiceDeps["openAnilistSetup"]; + getQueueStatus: CliCommandServiceDeps["getAnilistQueueStatus"]; + retryQueueNow: CliCommandServiceDeps["retryAnilistQueue"]; +} + interface AppCliRuntime { stop: () => void; hasMainWindow: () => boolean; @@ -107,6 +137,7 @@ export interface CliCommandDepsRuntimeOptions { texthooker: TexthookerCliRuntime; overlay: OverlayCliRuntime; mining: MiningCliRuntime; + anilist: AnilistCliRuntime; ui: UiCliRuntime; app: AppCliRuntime; getMultiCopyTimeoutMs: () => number; @@ -167,6 +198,11 @@ export function createCliCommandDepsRuntime( triggerSubsyncFromConfig: options.mining.triggerSubsyncFromConfig, markLastCardAsAudioCard: options.mining.markLastCardAsAudioCard, openRuntimeOptionsPalette: options.ui.openRuntimeOptionsPalette, + getAnilistStatus: options.anilist.getStatus, + clearAnilistToken: options.anilist.clearToken, + openAnilistSetup: options.anilist.openSetup, + getAnilistQueueStatus: options.anilist.getQueueStatus, + retryAnilistQueue: options.anilist.retryQueueNow, printHelp: options.ui.printHelp, hasMainWindow: options.app.hasMainWindow, getMultiCopyTimeoutMs: options.getMultiCopyTimeoutMs, @@ -177,6 +213,11 @@ export function createCliCommandDepsRuntime( }; } +function formatTimestamp(value: number | null): string { + if (!value) return "never"; + return new Date(value).toISOString(); +} + function runAsyncWithOsd( task: () => Promise, deps: CliCommandServiceDeps, @@ -217,6 +258,10 @@ export function handleCliCommand( args.triggerSubsync || args.markAudioCard || args.openRuntimeOptions || + args.anilistStatus || + args.anilistLogout || + args.anilistSetup || + args.anilistRetryQueue || args.texthooker || args.help; const ignoreStartOnly = source === "second-instance" && args.start && !hasNonStartAction; @@ -331,6 +376,47 @@ export function handleCliCommand( ); } else if (args.openRuntimeOptions) { deps.openRuntimeOptionsPalette(); + } else if (args.anilistStatus) { + const status = deps.getAnilistStatus(); + deps.log( + `AniList token status: ${status.tokenStatus} (source=${status.tokenSource})`, + ); + if (status.tokenMessage) { + deps.log(`AniList token message: ${status.tokenMessage}`); + } + deps.log( + `AniList token timestamps: resolved=${formatTimestamp(status.tokenResolvedAt)}, error=${formatTimestamp(status.tokenErrorAt)}`, + ); + deps.log( + `AniList queue: pending=${status.queuePending}, ready=${status.queueReady}, deadLetter=${status.queueDeadLetter}`, + ); + deps.log( + `AniList queue timestamps: lastAttempt=${formatTimestamp(status.queueLastAttemptAt)}`, + ); + if (status.queueLastError) { + deps.warn(`AniList queue last error: ${status.queueLastError}`); + } + } else if (args.anilistLogout) { + deps.clearAnilistToken(); + deps.log("Cleared stored AniList token."); + } else if (args.anilistSetup) { + deps.openAnilistSetup(); + deps.log("Opened AniList setup flow."); + } else if (args.anilistRetryQueue) { + const queueStatus = deps.getAnilistQueueStatus(); + deps.log( + `AniList queue before retry: pending=${queueStatus.pending}, ready=${queueStatus.ready}, deadLetter=${queueStatus.deadLetter}`, + ); + runAsyncWithOsd( + async () => { + const result = await deps.retryAnilistQueue(); + if (result.ok) deps.log(result.message); + else deps.warn(result.message); + }, + deps, + "retryAnilistQueue", + "AniList retry failed", + ); } else if (args.texthooker) { const texthookerPort = deps.getTexthookerPort(); deps.ensureTexthookerRunning(texthookerPort); diff --git a/src/core/services/ipc.test.ts b/src/core/services/ipc.test.ts new file mode 100644 index 0000000..5d4cef1 --- /dev/null +++ b/src/core/services/ipc.test.ts @@ -0,0 +1,60 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { createIpcDepsRuntime } from "./ipc"; + +test("createIpcDepsRuntime wires AniList handlers", async () => { + const calls: string[] = []; + const deps = createIpcDepsRuntime({ + getInvisibleWindow: () => null, + getMainWindow: () => null, + getVisibleOverlayVisibility: () => false, + getInvisibleOverlayVisibility: () => false, + onOverlayModalClosed: () => {}, + openYomitanSettings: () => {}, + quitApp: () => {}, + toggleVisibleOverlay: () => {}, + tokenizeCurrentSubtitle: async () => null, + getCurrentSubtitleAss: () => "", + getMpvSubtitleRenderMetrics: () => null, + getSubtitlePosition: () => null, + getSubtitleStyle: () => null, + saveSubtitlePosition: () => {}, + getMecabTokenizer: () => null, + handleMpvCommand: () => {}, + getKeybindings: () => [], + getConfiguredShortcuts: () => ({}), + getSecondarySubMode: () => "hover", + getMpvClient: () => null, + focusMainWindow: () => {}, + runSubsyncManual: async () => ({}), + getAnkiConnectStatus: () => false, + getRuntimeOptions: () => ({}), + setRuntimeOption: () => ({ ok: true }), + cycleRuntimeOption: () => ({ ok: true }), + reportOverlayContentBounds: () => {}, + getAnilistStatus: () => ({ tokenStatus: "resolved" }), + clearAnilistToken: () => { + calls.push("clearAnilistToken"); + }, + openAnilistSetup: () => { + calls.push("openAnilistSetup"); + }, + getAnilistQueueStatus: () => ({ pending: 1, ready: 0, deadLetter: 0 }), + retryAnilistQueueNow: async () => { + calls.push("retryAnilistQueueNow"); + return { ok: true, message: "done" }; + }, + }); + + assert.deepEqual(deps.getAnilistStatus(), { tokenStatus: "resolved" }); + deps.clearAnilistToken(); + deps.openAnilistSetup(); + assert.deepEqual(deps.getAnilistQueueStatus(), { + pending: 1, + ready: 0, + deadLetter: 0, + }); + assert.deepEqual(await deps.retryAnilistQueueNow(), { ok: true, message: "done" }); + assert.deepEqual(calls, ["clearAnilistToken", "openAnilistSetup", "retryAnilistQueueNow"]); +}); diff --git a/src/core/services/ipc.ts b/src/core/services/ipc.ts index ce8309b..f0fcb34 100644 --- a/src/core/services/ipc.ts +++ b/src/core/services/ipc.ts @@ -31,6 +31,11 @@ export interface IpcServiceDeps { setRuntimeOption: (id: string, value: unknown) => unknown; cycleRuntimeOption: (id: string, direction: 1 | -1) => unknown; reportOverlayContentBounds: (payload: unknown) => void; + getAnilistStatus: () => unknown; + clearAnilistToken: () => void; + openAnilistSetup: () => void; + getAnilistQueueStatus: () => unknown; + retryAnilistQueueNow: () => Promise<{ ok: boolean; message: string }>; } interface WindowLike { @@ -82,6 +87,11 @@ export interface IpcDepsRuntimeOptions { setRuntimeOption: (id: string, value: unknown) => unknown; cycleRuntimeOption: (id: string, direction: 1 | -1) => unknown; reportOverlayContentBounds: (payload: unknown) => void; + getAnilistStatus: () => unknown; + clearAnilistToken: () => void; + openAnilistSetup: () => void; + getAnilistQueueStatus: () => unknown; + retryAnilistQueueNow: () => Promise<{ ok: boolean; message: string }>; } export function createIpcDepsRuntime( @@ -140,6 +150,11 @@ export function createIpcDepsRuntime( setRuntimeOption: options.setRuntimeOption, cycleRuntimeOption: options.cycleRuntimeOption, reportOverlayContentBounds: options.reportOverlayContentBounds, + getAnilistStatus: options.getAnilistStatus, + clearAnilistToken: options.clearAnilistToken, + openAnilistSetup: options.openAnilistSetup, + getAnilistQueueStatus: options.getAnilistQueueStatus, + retryAnilistQueueNow: options.retryAnilistQueueNow, }; } @@ -279,4 +294,26 @@ export function registerIpcHandlers(deps: IpcServiceDeps): void { ipcMain.on("overlay-content-bounds:report", (_event: IpcMainEvent, payload: unknown) => { deps.reportOverlayContentBounds(payload); }); + + ipcMain.handle("anilist:get-status", () => { + return deps.getAnilistStatus(); + }); + + ipcMain.handle("anilist:clear-token", () => { + deps.clearAnilistToken(); + return { ok: true }; + }); + + ipcMain.handle("anilist:open-setup", () => { + deps.openAnilistSetup(); + return { ok: true }; + }); + + ipcMain.handle("anilist:get-queue-status", () => { + return deps.getAnilistQueueStatus(); + }); + + ipcMain.handle("anilist:retry-now", async () => { + return await deps.retryAnilistQueueNow(); + }); } diff --git a/src/core/services/startup-bootstrap.test.ts b/src/core/services/startup-bootstrap.test.ts index 25d08b3..fc363e0 100644 --- a/src/core/services/startup-bootstrap.test.ts +++ b/src/core/services/startup-bootstrap.test.ts @@ -30,6 +30,10 @@ function makeArgs(overrides: Partial = {}): CliArgs { triggerSubsync: false, markAudioCard: false, openRuntimeOptions: false, + anilistStatus: false, + anilistLogout: false, + anilistSetup: false, + anilistRetryQueue: false, texthooker: false, help: false, autoStartOverlay: false, diff --git a/src/main.ts b/src/main.ts index ace7b73..59ad7b4 100644 --- a/src/main.ts +++ b/src/main.ts @@ -776,6 +776,44 @@ function refreshAnilistRetryQueueState(): void { }; } +function getAnilistStatusSnapshot() { + return { + tokenStatus: appState.anilistClientSecretState.status, + tokenSource: appState.anilistClientSecretState.source, + tokenMessage: appState.anilistClientSecretState.message, + tokenResolvedAt: appState.anilistClientSecretState.resolvedAt, + tokenErrorAt: appState.anilistClientSecretState.errorAt, + queuePending: appState.anilistRetryQueueState.pending, + queueReady: appState.anilistRetryQueueState.ready, + queueDeadLetter: appState.anilistRetryQueueState.deadLetter, + queueLastAttemptAt: appState.anilistRetryQueueState.lastAttemptAt, + queueLastError: appState.anilistRetryQueueState.lastError, + }; +} + +function getAnilistQueueStatusSnapshot() { + refreshAnilistRetryQueueState(); + return { + pending: appState.anilistRetryQueueState.pending, + ready: appState.anilistRetryQueueState.ready, + deadLetter: appState.anilistRetryQueueState.deadLetter, + lastAttemptAt: appState.anilistRetryQueueState.lastAttemptAt, + lastError: appState.anilistRetryQueueState.lastError, + }; +} + +function clearAnilistTokenState(): void { + anilistTokenStore.clearToken(); + anilistCachedAccessToken = null; + setAnilistClientSecretState({ + status: "not_checked", + source: "none", + message: "stored token cleared", + resolvedAt: null, + errorAt: null, + }); +} + function isAnilistTrackingEnabled(resolved: ResolvedConfig): boolean { return resolved.anilist.enabled; } @@ -1070,18 +1108,21 @@ function rememberAnilistAttemptedUpdateKey(key: string): void { } } -async function processNextAnilistRetryUpdate(): Promise { +async function processNextAnilistRetryUpdate(): Promise<{ + ok: boolean; + message: string; +}> { const queued = anilistUpdateQueue.nextReady(); refreshAnilistRetryQueueState(); if (!queued) { - return; + return { ok: true, message: "AniList queue has no ready items." }; } appState.anilistRetryQueueState.lastAttemptAt = Date.now(); const accessToken = await refreshAnilistClientSecretState(); if (!accessToken) { appState.anilistRetryQueueState.lastError = "AniList token unavailable for queued retry."; - return; + return { ok: false, message: appState.anilistRetryQueueState.lastError }; } const result = await updateAnilistPostWatchProgress( @@ -1095,12 +1136,13 @@ async function processNextAnilistRetryUpdate(): Promise { appState.anilistRetryQueueState.lastError = null; refreshAnilistRetryQueueState(); logger.info(`[AniList queue] ${result.message}`); - return; + return { ok: true, message: result.message }; } anilistUpdateQueue.markFailure(queued.key, result.message); appState.anilistRetryQueueState.lastError = result.message; refreshAnilistRetryQueueState(); + return { ok: false, message: result.message }; } async function maybeRunAnilistPostWatchUpdate(): Promise { @@ -1441,6 +1483,11 @@ function handleCliCommand( triggerFieldGrouping: () => triggerFieldGrouping(), triggerSubsyncFromConfig: () => triggerSubsyncFromConfig(), markLastCardAsAudioCard: () => markLastCardAsAudioCard(), + getAnilistStatus: () => getAnilistStatusSnapshot(), + clearAnilistToken: () => clearAnilistTokenState(), + openAnilistSetup: () => openAnilistSetupWindow(), + getAnilistQueueStatus: () => getAnilistQueueStatusSnapshot(), + retryAnilistQueueNow: () => processNextAnilistRetryUpdate(), openYomitanSettings: () => openYomitanSettings(), cycleSecondarySubMode: () => cycleSecondarySubMode(), openRuntimeOptionsPalette: () => openRuntimeOptionsPalette(), @@ -2115,6 +2162,11 @@ registerIpcRuntimeServices({ reportOverlayContentBounds: (payload: unknown) => { overlayContentMeasurementStore.report(payload); }, + getAnilistStatus: () => getAnilistStatusSnapshot(), + clearAnilistToken: () => clearAnilistTokenState(), + openAnilistSetup: () => openAnilistSetupWindow(), + getAnilistQueueStatus: () => getAnilistQueueStatusSnapshot(), + retryAnilistQueueNow: () => processNextAnilistRetryUpdate(), }, ankiJimakuDeps: createAnkiJimakuIpcRuntimeServiceDeps({ patchAnkiConnectEnabled: (enabled: boolean) => { diff --git a/src/main/cli-runtime.ts b/src/main/cli-runtime.ts index e55a361..036fec5 100644 --- a/src/main/cli-runtime.ts +++ b/src/main/cli-runtime.ts @@ -26,6 +26,11 @@ export interface CliCommandRuntimeServiceContext { triggerFieldGrouping: () => Promise; triggerSubsyncFromConfig: () => Promise; markLastCardAsAudioCard: () => Promise; + getAnilistStatus: CliCommandRuntimeServiceDepsParams["anilist"]["getStatus"]; + clearAnilistToken: CliCommandRuntimeServiceDepsParams["anilist"]["clearToken"]; + openAnilistSetup: CliCommandRuntimeServiceDepsParams["anilist"]["openSetup"]; + getAnilistQueueStatus: CliCommandRuntimeServiceDepsParams["anilist"]["getQueueStatus"]; + retryAnilistQueueNow: CliCommandRuntimeServiceDepsParams["anilist"]["retryQueueNow"]; openYomitanSettings: () => void; cycleSecondarySubMode: () => void; openRuntimeOptionsPalette: () => void; @@ -71,13 +76,20 @@ function createCliCommandDepsFromContext( mining: { copyCurrentSubtitle: context.copyCurrentSubtitle, startPendingMultiCopy: context.startPendingMultiCopy, - mineSentenceCard: context.mineSentenceCard, - startPendingMineSentenceMultiple: context.startPendingMineSentenceMultiple, - updateLastCardFromClipboard: context.updateLastCardFromClipboard, - refreshKnownWords: context.refreshKnownWordCache, - triggerFieldGrouping: context.triggerFieldGrouping, - triggerSubsyncFromConfig: context.triggerSubsyncFromConfig, - markLastCardAsAudioCard: context.markLastCardAsAudioCard, + mineSentenceCard: context.mineSentenceCard, + startPendingMineSentenceMultiple: context.startPendingMineSentenceMultiple, + updateLastCardFromClipboard: context.updateLastCardFromClipboard, + refreshKnownWords: context.refreshKnownWordCache, + triggerFieldGrouping: context.triggerFieldGrouping, + triggerSubsyncFromConfig: context.triggerSubsyncFromConfig, + markLastCardAsAudioCard: context.markLastCardAsAudioCard, + }, + anilist: { + getStatus: context.getAnilistStatus, + clearToken: context.clearAnilistToken, + openSetup: context.openAnilistSetup, + getQueueStatus: context.getAnilistQueueStatus, + retryQueueNow: context.retryAnilistQueueNow, }, ui: { openYomitanSettings: context.openYomitanSettings, diff --git a/src/main/dependencies.ts b/src/main/dependencies.ts index 144bd90..e1a5c6d 100644 --- a/src/main/dependencies.ts +++ b/src/main/dependencies.ts @@ -90,6 +90,11 @@ export interface MainIpcRuntimeServiceDepsParams { setRuntimeOption: IpcDepsRuntimeOptions["setRuntimeOption"]; cycleRuntimeOption: IpcDepsRuntimeOptions["cycleRuntimeOption"]; reportOverlayContentBounds: IpcDepsRuntimeOptions["reportOverlayContentBounds"]; + getAnilistStatus: IpcDepsRuntimeOptions["getAnilistStatus"]; + clearAnilistToken: IpcDepsRuntimeOptions["clearAnilistToken"]; + openAnilistSetup: IpcDepsRuntimeOptions["openAnilistSetup"]; + getAnilistQueueStatus: IpcDepsRuntimeOptions["getAnilistQueueStatus"]; + retryAnilistQueueNow: IpcDepsRuntimeOptions["retryAnilistQueueNow"]; } export interface AnkiJimakuIpcRuntimeServiceDepsParams { @@ -154,6 +159,13 @@ export interface CliCommandRuntimeServiceDepsParams { markLastCardAsAudioCard: CliCommandDepsRuntimeOptions["mining"]["markLastCardAsAudioCard"]; }; + anilist: { + getStatus: CliCommandDepsRuntimeOptions["anilist"]["getStatus"]; + clearToken: CliCommandDepsRuntimeOptions["anilist"]["clearToken"]; + openSetup: CliCommandDepsRuntimeOptions["anilist"]["openSetup"]; + getQueueStatus: CliCommandDepsRuntimeOptions["anilist"]["getQueueStatus"]; + retryQueueNow: CliCommandDepsRuntimeOptions["anilist"]["retryQueueNow"]; + }; ui: { openYomitanSettings: CliCommandDepsRuntimeOptions["ui"]["openYomitanSettings"]; cycleSecondarySubMode: CliCommandDepsRuntimeOptions["ui"]["cycleSecondarySubMode"]; @@ -216,6 +228,11 @@ export function createMainIpcRuntimeServiceDeps( setRuntimeOption: params.setRuntimeOption, cycleRuntimeOption: params.cycleRuntimeOption, reportOverlayContentBounds: params.reportOverlayContentBounds, + getAnilistStatus: params.getAnilistStatus, + clearAnilistToken: params.clearAnilistToken, + openAnilistSetup: params.openAnilistSetup, + getAnilistQueueStatus: params.getAnilistQueueStatus, + retryAnilistQueueNow: params.retryAnilistQueueNow, }; } @@ -283,6 +300,13 @@ export function createCliCommandRuntimeServiceDeps( triggerSubsyncFromConfig: params.mining.triggerSubsyncFromConfig, markLastCardAsAudioCard: params.mining.markLastCardAsAudioCard, }, + anilist: { + getStatus: params.anilist.getStatus, + clearToken: params.anilist.clearToken, + openSetup: params.anilist.openSetup, + getQueueStatus: params.anilist.getQueueStatus, + retryQueueNow: params.anilist.retryQueueNow, + }, ui: { openYomitanSettings: params.ui.openYomitanSettings, cycleSecondarySubMode: params.ui.cycleSecondarySubMode,