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/config.example.jsonc b/config.example.jsonc index fdf142c..e367ffa 100644 --- a/config.example.jsonc +++ b/config.example.jsonc @@ -37,6 +37,15 @@ "port": 6677 }, + // ========================================== + // Logging + // Controls logging verbosity. + // Set to debug for full runtime diagnostics. + // ========================================== + "logging": { + "level": "info" + }, + // ========================================== // AnkiConnect Integration // Automatic Anki updates and media generation options. @@ -87,6 +96,7 @@ "refreshMinutes": 1440, "matchMode": "headword", "decks": [], + "minSentenceWords": 3, "nPlusOne": "#c6a0f6", "knownWord": "#a6da95" }, @@ -165,6 +175,20 @@ "N4": "#a6e3a1", "N5": "#8aadf4" }, + "frequencyDictionary": { + "enabled": false, + "sourcePath": "", + "topX": 1000, + "mode": "single", + "singleColor": "#f5a97f", + "bandedColors": [ + "#ed8796", + "#f5a97f", + "#f9e2af", + "#a6e3a1", + "#8aadf4" + ] + }, "secondary": { "fontSize": 24, "fontColor": "#ffffff", @@ -227,5 +251,14 @@ "ja", "jpn" ] + }, + + // ========================================== + // Anilist + // Anilist API credentials and update behavior. + // ========================================== + "anilist": { + "enabled": false, + "accessToken": "" } } diff --git a/docs/configuration.md b/docs/configuration.md index 88c1d8b..1ff9ae0 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -52,6 +52,7 @@ The configuration file includes several main sections: - [**Auto Subtitle Sync**](#auto-subtitle-sync) - Sync current subtitle with `alass`/`ffsubsync` - [**Invisible Overlay**](#invisible-overlay) - Startup visibility behavior for the invisible mining layer - [**Jimaku**](#jimaku) - Jimaku API configuration and defaults +- [**AniList**](#anilist) - Optional post-watch progress updates - [**Keybindings**](#keybindings) - MPV command shortcuts - [**Runtime Option Palette**](#runtime-option-palette) - Live, session-only option toggles - [**Secondary Subtitles**](#secondary-subtitles) - Dual subtitle track support @@ -383,6 +384,48 @@ Jimaku is rate limited; if you hit a limit, SubMiner will surface the retry dela Set `openBrowser` to `false` to only print the URL without opening a browser. +### AniList + +AniList integration is opt-in and disabled by default. Enable it and provide an access token to allow SubMiner to update your watched episode progress after playback. + +```json +{ + "anilist": { + "enabled": true, + "accessToken": "YOUR_ANILIST_ACCESS_TOKEN" + } +} +``` + +| Option | Values | Description | +| ------ | ------ | ----------- | +| `enabled` | `true`, `false` | Enable AniList post-watch progress updates (default: `false`) | +| `accessToken` | string | AniList access token used for authenticated GraphQL updates (default: empty string) | + +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. +- Failed updates are retried with a persistent backoff queue in the background. + +Setup flow details: + +1. Set `anilist.enabled` to `true`. +2. Leave `anilist.accessToken` empty and restart SubMiner to trigger setup. +3. Approve access in AniList (browser window or system browser fallback). +4. Copy the returned token and paste it into `anilist.accessToken`. +5. Save config and restart SubMiner. + +Token + detection notes: + +- `anilist.accessToken` can be set directly in config; SubMiner also stores the token locally for reuse if config token is later blank. +- 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. + ### Keybindings Add a `keybindings` array to configure keyboard shortcuts that send commands to mpv: diff --git a/docs/public/config.example.jsonc b/docs/public/config.example.jsonc index b492f73..e367ffa 100644 --- a/docs/public/config.example.jsonc +++ b/docs/public/config.example.jsonc @@ -37,6 +37,15 @@ "port": 6677 }, + // ========================================== + // Logging + // Controls logging verbosity. + // Set to debug for full runtime diagnostics. + // ========================================== + "logging": { + "level": "info" + }, + // ========================================== // AnkiConnect Integration // Automatic Anki updates and media generation options. @@ -151,20 +160,6 @@ // ========================================== "subtitleStyle": { "enableJlpt": false, - "frequencyDictionary": { - "enabled": false, - "sourcePath": "", - "topX": 1000, - "mode": "single", - "singleColor": "#f5a97f", - "bandedColors": [ - "#ed8796", - "#f5a97f", - "#f9e2af", - "#a6e3a1", - "#8aadf4" - ] - }, "fontFamily": "Noto Sans CJK JP Regular, Noto Sans CJK JP, Arial Unicode MS, Arial, sans-serif", "fontSize": 35, "fontColor": "#cad3f5", @@ -180,6 +175,20 @@ "N4": "#a6e3a1", "N5": "#8aadf4" }, + "frequencyDictionary": { + "enabled": false, + "sourcePath": "", + "topX": 1000, + "mode": "single", + "singleColor": "#f5a97f", + "bandedColors": [ + "#ed8796", + "#f5a97f", + "#f9e2af", + "#a6e3a1", + "#8aadf4" + ] + }, "secondary": { "fontSize": 24, "fontColor": "#ffffff", @@ -242,5 +251,14 @@ "ja", "jpn" ] + }, + + // ========================================== + // Anilist + // Anilist API credentials and update behavior. + // ========================================== + "anilist": { + "enabled": false, + "accessToken": "" } } diff --git a/package.json b/package.json index 6add4b1..eaea79c 100644 --- a/package.json +++ b/package.json @@ -21,7 +21,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-service.test.js dist/core/services/field-grouping-overlay-service.test.js dist/core/services/numeric-shortcut-session-service.test.js dist/core/services/secondary-subtitle-service.test.js dist/core/services/mpv-render-metrics-service.test.js dist/core/services/overlay-content-measurement-service.test.js dist/core/services/mpv-control-service.test.js dist/core/services/mpv-service.test.js dist/core/services/runtime-options-ipc-service.test.js dist/core/services/runtime-config-service.test.js dist/core/services/tokenizer-service.test.js dist/core/services/subsync-service.test.js dist/core/services/overlay-bridge-service.test.js dist/core/services/overlay-manager-service.test.js dist/core/services/overlay-shortcut-handler.test.js dist/core/services/mining-service.test.js dist/core/services/anki-jimaku-service.test.js dist/core/services/app-ready-service.test.js dist/core/services/startup-bootstrap-service.test.js dist/subsync/utils.test.js", + "test:core:dist": "node --test dist/cli/args.test.js dist/cli/help.test.js dist/core/services/cli-command-service.test.js dist/core/services/field-grouping-overlay-service.test.js dist/core/services/numeric-shortcut-session-service.test.js dist/core/services/secondary-subtitle-service.test.js dist/core/services/mpv-render-metrics-service.test.js dist/core/services/overlay-content-measurement-service.test.js dist/core/services/mpv-control-service.test.js dist/core/services/mpv-service.test.js dist/core/services/runtime-options-ipc-service.test.js dist/core/services/runtime-config-service.test.js dist/core/services/tokenizer-service.test.js dist/core/services/subsync-service.test.js dist/core/services/overlay-bridge-service.test.js dist/core/services/overlay-manager-service.test.js dist/core/services/overlay-shortcut-handler.test.js dist/core/services/mining-service.test.js dist/core/services/anki-jimaku-service.test.js dist/core/services/app-ready-service.test.js dist/core/services/startup-bootstrap-service.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:config": "pnpm run build && pnpm run test:config:dist", "test:core": "pnpm run build && pnpm run test:core:dist", diff --git a/src/config/config.test.ts b/src/config/config.test.ts index 9eaa425..8d9ef14 100644 --- a/src/config/config.test.ts +++ b/src/config/config.test.ts @@ -17,6 +17,30 @@ test("loads defaults when config is missing", () => { const config = service.getConfig(); assert.equal(config.websocket.port, DEFAULT_CONFIG.websocket.port); assert.equal(config.ankiConnect.behavior.autoUpdateNewCards, true); + assert.equal(config.anilist.enabled, false); +}); + +test("parses anilist.enabled and warns for invalid value", () => { + const dir = makeTempDir(); + fs.writeFileSync( + path.join(dir, "config.jsonc"), + `{ + "anilist": { + "enabled": "yes" + } + }`, + "utf-8", + ); + + const service = new ConfigService(dir); + const config = service.getConfig(); + const warnings = service.getWarnings(); + + assert.equal(config.anilist.enabled, DEFAULT_CONFIG.anilist.enabled); + assert.ok(warnings.some((warning) => warning.path === "anilist.enabled")); + + service.patchRawConfig({ anilist: { enabled: true } }); + assert.equal(service.getConfig().anilist.enabled, true); }); test("parses jsonc and warns/falls back on invalid value", () => { diff --git a/src/config/definitions.ts b/src/config/definitions.ts index 253396a..d01f82d 100644 --- a/src/config/definitions.ts +++ b/src/config/definitions.ts @@ -226,6 +226,10 @@ export const DEFAULT_CONFIG: ResolvedConfig = { languagePreference: "ja", maxEntryResults: 10, }, + anilist: { + enabled: false, + accessToken: "", + }, youtubeSubgen: { mode: "automatic", whisperBin: "", @@ -467,6 +471,18 @@ export const CONFIG_OPTION_REGISTRY: ConfigOptionRegistryEntry[] = [ defaultValue: DEFAULT_CONFIG.jimaku.maxEntryResults, description: "Maximum Jimaku search results returned.", }, + { + path: "anilist.enabled", + kind: "boolean", + defaultValue: DEFAULT_CONFIG.anilist.enabled, + description: "Enable AniList post-watch progress updates.", + }, + { + path: "anilist.accessToken", + kind: "string", + defaultValue: DEFAULT_CONFIG.anilist.accessToken, + description: "AniList access token used for post-watch updates.", + }, { path: "youtubeSubgen.mode", kind: "enum", @@ -600,6 +616,11 @@ export const CONFIG_TEMPLATE_SECTIONS: ConfigTemplateSection[] = [ ], key: "youtubeSubgen", }, + { + title: "Anilist", + description: ["Anilist API credentials and update behavior."], + key: "anilist", + }, ]; export function deepCloneConfig(config: ResolvedConfig): ResolvedConfig { diff --git a/src/config/service.ts b/src/config/service.ts index b227ac9..e0fb75e 100644 --- a/src/config/service.ts +++ b/src/config/service.ts @@ -443,6 +443,32 @@ export class ConfigService { } } + if (isObject(src.anilist)) { + const enabled = asBoolean(src.anilist.enabled); + if (enabled !== undefined) { + resolved.anilist.enabled = enabled; + } else if (src.anilist.enabled !== undefined) { + warn( + "anilist.enabled", + src.anilist.enabled, + resolved.anilist.enabled, + "Expected boolean.", + ); + } + + const accessToken = asString(src.anilist.accessToken); + if (accessToken !== undefined) { + resolved.anilist.accessToken = accessToken; + } else if (src.anilist.accessToken !== undefined) { + warn( + "anilist.accessToken", + src.anilist.accessToken, + resolved.anilist.accessToken, + "Expected string.", + ); + } + } + if (asBoolean(src.auto_start_overlay) !== undefined) { resolved.auto_start_overlay = src.auto_start_overlay as boolean; } 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/core/services/anilist/anilist-updater.ts b/src/core/services/anilist/anilist-updater.ts new file mode 100644 index 0000000..cc72b10 --- /dev/null +++ b/src/core/services/anilist/anilist-updater.ts @@ -0,0 +1,301 @@ +import * as childProcess from "child_process"; + +import { parseMediaInfo } from "../../../jimaku/utils"; + +const ANILIST_GRAPHQL_URL = "https://graphql.anilist.co"; + +export interface AnilistMediaGuess { + title: string; + episode: number | null; + source: "guessit" | "fallback"; +} + +export interface AnilistPostWatchUpdateResult { + status: "updated" | "skipped" | "error"; + message: string; +} + +interface AnilistGraphQlError { + message?: string; +} + +interface AnilistGraphQlResponse { + data?: T; + errors?: AnilistGraphQlError[]; +} + +interface AnilistSearchData { + Page?: { + media?: Array<{ + id: number; + episodes: number | null; + title?: { + romaji?: string | null; + english?: string | null; + native?: string | null; + }; + }>; + }; +} + +interface AnilistMediaEntryData { + Media?: { + id: number; + mediaListEntry?: { + progress?: number | null; + status?: string | null; + } | null; + } | null; +} + +interface AnilistSaveEntryData { + SaveMediaListEntry?: { + progress?: number | null; + status?: string | null; + }; +} + +function runGuessit(target: string): Promise { + return new Promise((resolve, reject) => { + childProcess.execFile( + "guessit", + [target, "--json"], + { timeout: 5000, maxBuffer: 1024 * 1024 }, + (error, stdout) => { + if (error) { + reject(error); + return; + } + resolve(stdout); + }, + ); + }); +} + +function firstString(value: unknown): string | null { + if (typeof value === "string") { + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : null; + } + if (Array.isArray(value)) { + for (const item of value) { + const candidate = firstString(item); + if (candidate) return candidate; + } + } + return null; +} + +function firstPositiveInteger(value: unknown): number | null { + if (typeof value === "number" && Number.isInteger(value) && value > 0) { + return value; + } + if (typeof value === "string") { + const parsed = Number.parseInt(value, 10); + return Number.isInteger(parsed) && parsed > 0 ? parsed : null; + } + if (Array.isArray(value)) { + for (const item of value) { + const candidate = firstPositiveInteger(item); + if (candidate !== null) return candidate; + } + } + return null; +} + +function normalizeTitle(text: string): string { + return text.trim().toLowerCase().replace(/\s+/g, " "); +} + +async function anilistGraphQl( + accessToken: string, + query: string, + variables: Record, +): Promise> { + try { + const response = await fetch(ANILIST_GRAPHQL_URL, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${accessToken}`, + }, + body: JSON.stringify({ query, variables }), + }); + + const payload = (await response.json()) as AnilistGraphQlResponse; + return payload; + } catch (error) { + return { + errors: [ + { + message: + error instanceof Error ? error.message : String(error), + }, + ], + }; + } +} + +function firstErrorMessage(response: AnilistGraphQlResponse): string | null { + const firstError = response.errors?.find((item) => Boolean(item?.message)); + return firstError?.message ?? null; +} + +function pickBestSearchResult( + title: string, + episode: number, + media: Array<{ + id: number; + episodes: number | null; + title?: { + romaji?: string | null; + english?: string | null; + native?: string | null; + }; + }>, +): { id: number; title: string } | null { + const filtered = media.filter((item) => { + const totalEpisodes = item.episodes; + return totalEpisodes === null || totalEpisodes >= episode; + }); + const candidates = filtered.length > 0 ? filtered : media; + if (candidates.length === 0) return null; + + const normalizedTarget = normalizeTitle(title); + const exact = candidates.find((item) => { + const titles = [ + item.title?.romaji, + item.title?.english, + item.title?.native, + ] + .filter((value): value is string => typeof value === "string") + .map((value) => normalizeTitle(value)); + return titles.includes(normalizedTarget); + }); + + const selected = exact ?? candidates[0]; + const selectedTitle = + selected.title?.english || + selected.title?.romaji || + selected.title?.native || + title; + return { id: selected.id, title: selectedTitle }; +} + +export async function guessAnilistMediaInfo( + mediaPath: string | null, + mediaTitle: string | null, +): Promise { + const target = mediaPath ?? mediaTitle; + + if (target && target.trim().length > 0) { + try { + const stdout = await runGuessit(target); + const parsed = JSON.parse(stdout) as Record; + const title = firstString(parsed.title); + const episode = firstPositiveInteger(parsed.episode); + if (title) { + return { title, episode, source: "guessit" }; + } + } catch { + // Ignore guessit failures and fall back to internal parser. + } + } + + const fallbackTarget = mediaPath ?? mediaTitle; + const parsed = parseMediaInfo(fallbackTarget); + if (!parsed.title.trim()) { + return null; + } + return { + title: parsed.title.trim(), + episode: parsed.episode, + source: "fallback", + }; +} + +export async function updateAnilistPostWatchProgress( + accessToken: string, + title: string, + episode: number, +): Promise { + const searchResponse = await anilistGraphQl( + accessToken, + ` + query ($search: String!) { + Page(perPage: 5) { + media(search: $search, type: ANIME) { + id + episodes + title { + romaji + english + native + } + } + } + } + `, + { search: title }, + ); + const searchError = firstErrorMessage(searchResponse); + if (searchError) { + return { status: "error", message: `AniList search failed: ${searchError}` }; + } + + const media = searchResponse.data?.Page?.media ?? []; + const picked = pickBestSearchResult(title, episode, media); + if (!picked) { + return { status: "error", message: "AniList search returned no matches." }; + } + + const entryResponse = await anilistGraphQl( + accessToken, + ` + query ($mediaId: Int!) { + Media(id: $mediaId, type: ANIME) { + id + mediaListEntry { + progress + status + } + } + } + `, + { mediaId: picked.id }, + ); + const entryError = firstErrorMessage(entryResponse); + if (entryError) { + return { status: "error", message: `AniList entry lookup failed: ${entryError}` }; + } + + const currentProgress = entryResponse.data?.Media?.mediaListEntry?.progress ?? 0; + if (typeof currentProgress === "number" && currentProgress >= episode) { + return { + status: "skipped", + message: `AniList already at episode ${currentProgress} (${picked.title}).`, + }; + } + + const saveResponse = await anilistGraphQl( + accessToken, + ` + mutation ($mediaId: Int!, $progress: Int!) { + SaveMediaListEntry(mediaId: $mediaId, progress: $progress, status: CURRENT) { + progress + status + } + } + `, + { mediaId: picked.id, progress: episode }, + ); + const saveError = firstErrorMessage(saveResponse); + if (saveError) { + return { status: "error", message: `AniList update failed: ${saveError}` }; + } + + return { + status: "updated", + message: `AniList updated "${picked.title}" to episode ${episode}.`, + }; +} diff --git a/src/main.ts b/src/main.ts index 2ecf51f..7a2257c 100644 --- a/src/main.ts +++ b/src/main.ts @@ -132,6 +132,13 @@ import { triggerFieldGroupingService, updateLastCardFromClipboardService, } from "./core/services"; +import { + guessAnilistMediaInfo, + 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, @@ -169,9 +176,14 @@ import { import { createMediaRuntimeService } from "./main/media-runtime"; import { createOverlayVisibilityRuntimeService } from "./main/overlay-visibility-runtime"; import { + type AppState, applyStartupState, createAppState, } from "./main/state"; +import { + isAllowedAnilistExternalUrl, + isAllowedAnilistSetupNavigationUrl, +} from "./main/anilist-url-guard"; import { createStartupBootstrapRuntimeDeps } from "./main/startup"; import { createAppLifecycleRuntimeRunner } from "./main/startup-lifecycle"; import { @@ -192,6 +204,27 @@ const DEFAULT_MPV_LOG_FILE = path.join( "SubMiner", "mp.log", ); +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; +let anilistCurrentMediaGuess: AnilistMediaGuess | null = null; +let anilistCurrentMediaGuessPromise: Promise | null = 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(); const baseDirs = Array.from( @@ -230,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(); @@ -572,6 +621,436 @@ async function jimakuFetchJson( }); } +function setAnilistClientSecretState(partial: Partial): void { + appState.anilistClientSecretState = { + ...appState.anilistClientSecretState, + ...partial, + }; +} + +function refreshAnilistRetryQueueState(): void { + appState.anilistRetryQueueState = { + ...appState.anilistRetryQueueState, + ...anilistUpdateQueue.getSnapshot(), + }; +} + +function isAnilistTrackingEnabled(resolved: ResolvedConfig): boolean { + return resolved.anilist.enabled; +} + +function buildAnilistSetupUrl(): string { + const authorizeUrl = new URL(ANILIST_SETUP_CLIENT_ID_URL); + authorizeUrl.searchParams.set("client_id", ANILIST_DEFAULT_CLIENT_ID); + authorizeUrl.searchParams.set("response_type", ANILIST_SETUP_RESPONSE_TYPE); + authorizeUrl.searchParams.set("redirect_uri", ANILIST_REDIRECT_URI); + return authorizeUrl.toString(); +} + +function openAnilistSetupInBrowser(): void { + const authorizeUrl = buildAnilistSetupUrl(); + void shell.openExternal(authorizeUrl).catch((error) => { + logger.error("Failed to open AniList authorize URL in browser", error); + }); +} + +function loadAnilistSetupFallback(setupWindow: BrowserWindow, reason: string): void { + const authorizeUrl = buildAnilistSetupUrl(); + const fallbackHtml = ` + + + + AniList Setup + + + +

AniList Setup

+
+

Embedded AniList page did not render: ${reason}

+

We attempted to open the authorize URL in your default browser automatically.

+

Use one of these links to continue setup:

+

${authorizeUrl}

+

${ANILIST_DEVELOPER_SETTINGS_URL}

+

After login/authorization, copy the token into anilist.accessToken.

+
+ +`; + void setupWindow.loadURL( + `data:text/html;charset=utf-8,${encodeURIComponent(fallbackHtml)}`, + ); +} + +function openAnilistSetupWindow(): void { + if (appState.anilistSetupWindow) { + appState.anilistSetupWindow.focus(); + return; + } + + const setupWindow = new BrowserWindow({ + width: 1000, + height: 760, + title: "Anilist Setup", + show: true, + autoHideMenuBar: true, + webPreferences: { + nodeIntegration: false, + contextIsolation: true, + }, + }); + + setupWindow.webContents.setWindowOpenHandler(({ url }) => { + if (!isAllowedAnilistExternalUrl(url)) { + logger.warn("Blocked unsafe AniList setup external URL", { url }); + return { action: "deny" }; + } + void shell.openExternal(url); + return { action: "deny" }; + }); + setupWindow.webContents.on("will-navigate", (event, url) => { + if (isAllowedAnilistSetupNavigationUrl(url)) { + return; + } + event.preventDefault(); + logger.warn("Blocked unsafe AniList setup navigation URL", { url }); + }); + + setupWindow.webContents.on( + "did-fail-load", + (_event, errorCode, errorDescription, validatedURL) => { + logger.error("AniList setup window failed to load", { + errorCode, + errorDescription, + validatedURL, + }); + openAnilistSetupInBrowser(); + if (!setupWindow.isDestroyed()) { + loadAnilistSetupFallback( + setupWindow, + `${errorDescription} (${errorCode})`, + ); + } + }, + ); + + setupWindow.webContents.on("did-finish-load", () => { + const loadedUrl = setupWindow.webContents.getURL(); + if (!loadedUrl || loadedUrl === "about:blank") { + logger.warn("AniList setup loaded a blank page; using fallback"); + openAnilistSetupInBrowser(); + if (!setupWindow.isDestroyed()) { + loadAnilistSetupFallback(setupWindow, "blank page"); + } + } + }); + + void setupWindow.loadURL(buildAnilistSetupUrl()).catch((error) => { + logger.error("AniList setup loadURL rejected", error); + openAnilistSetupInBrowser(); + if (!setupWindow.isDestroyed()) { + loadAnilistSetupFallback( + setupWindow, + error instanceof Error ? error.message : String(error), + ); + } + }); + + setupWindow.on("closed", () => { + appState.anilistSetupWindow = null; + appState.anilistSetupPageOpened = false; + }); + + appState.anilistSetupWindow = setupWindow; + appState.anilistSetupPageOpened = true; +} + +async function refreshAnilistClientSecretState(options?: { force?: boolean }): Promise { + const resolved = getResolvedConfig(); + const now = Date.now(); + if (!isAnilistTrackingEnabled(resolved)) { + anilistCachedAccessToken = null; + setAnilistClientSecretState({ + status: "not_checked", + source: "none", + message: "anilist tracking disabled", + resolvedAt: null, + errorAt: null, + }); + appState.anilistSetupPageOpened = false; + return null; + } + 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", + message: "using configured anilist.accessToken", + resolvedAt: now, + errorAt: null, + }); + appState.anilistSetupPageOpened = false; + 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", + message: "cannot authenticate without anilist.accessToken", + resolvedAt: null, + errorAt: now, + }); + if ( + isAnilistTrackingEnabled(resolved) && + !appState.anilistSetupPageOpened + ) { + openAnilistSetupWindow(); + } + return null; +} + +function getCurrentAnilistMediaKey(): string | null { + const path = appState.currentMediaPath?.trim(); + return path && path.length > 0 ? path : null; +} + +function resetAnilistMediaTracking(mediaKey: string | null): void { + anilistCurrentMediaKey = mediaKey; + anilistCurrentMediaDurationSec = null; + anilistCurrentMediaGuess = null; + anilistCurrentMediaGuessPromise = null; + anilistLastDurationProbeAtMs = 0; +} + +async function maybeProbeAnilistDuration(mediaKey: string): Promise { + if (anilistCurrentMediaKey !== mediaKey) { + return null; + } + if ( + typeof anilistCurrentMediaDurationSec === "number" && + anilistCurrentMediaDurationSec > 0 + ) { + return anilistCurrentMediaDurationSec; + } + const now = Date.now(); + if (now - anilistLastDurationProbeAtMs < ANILIST_DURATION_RETRY_INTERVAL_MS) { + return null; + } + anilistLastDurationProbeAtMs = now; + + try { + const durationCandidate = await appState.mpvClient?.requestProperty("duration"); + const duration = + typeof durationCandidate === "number" && Number.isFinite(durationCandidate) + ? durationCandidate + : null; + if (duration && duration > 0 && anilistCurrentMediaKey === mediaKey) { + anilistCurrentMediaDurationSec = duration; + return duration; + } + } catch (error) { + logger.warn("AniList duration probe failed:", error); + } + return null; +} + +async function ensureAnilistMediaGuess(mediaKey: string): Promise { + if (anilistCurrentMediaKey !== mediaKey) { + return null; + } + if (anilistCurrentMediaGuess) { + return anilistCurrentMediaGuess; + } + if (anilistCurrentMediaGuessPromise) { + return anilistCurrentMediaGuessPromise; + } + + const mediaPathForGuess = mediaRuntime.resolveMediaPathForJimaku( + appState.currentMediaPath, + ); + anilistCurrentMediaGuessPromise = guessAnilistMediaInfo( + mediaPathForGuess, + appState.currentMediaTitle, + ) + .then((guess) => { + if (anilistCurrentMediaKey === mediaKey) { + anilistCurrentMediaGuess = guess; + } + return guess; + }) + .finally(() => { + if (anilistCurrentMediaKey === mediaKey) { + anilistCurrentMediaGuessPromise = null; + } + }); + return anilistCurrentMediaGuessPromise; +} + +function buildAnilistAttemptKey(mediaKey: string, episode: number): string { + return `${mediaKey}::${episode}`; +} + +function rememberAnilistAttemptedUpdateKey(key: string): void { + anilistAttemptedUpdateKeys.add(key); + if (anilistAttemptedUpdateKeys.size <= ANILIST_MAX_ATTEMPTED_UPDATE_KEYS) { + return; + } + const oldestKey = anilistAttemptedUpdateKeys.values().next().value; + if (typeof oldestKey === "string") { + anilistAttemptedUpdateKeys.delete(oldestKey); + } +} + +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; + } + + const resolved = getResolvedConfig(); + if (!isAnilistTrackingEnabled(resolved)) { + return; + } + + const mediaKey = getCurrentAnilistMediaKey(); + if (!mediaKey || !appState.mpvClient) { + return; + } + if (anilistCurrentMediaKey !== mediaKey) { + resetAnilistMediaTracking(mediaKey); + } + + const watchedSeconds = appState.mpvClient.currentTimePos; + if ( + !Number.isFinite(watchedSeconds) || + watchedSeconds < ANILIST_UPDATE_MIN_WATCH_SECONDS + ) { + return; + } + + const duration = await maybeProbeAnilistDuration(mediaKey); + if (!duration || duration <= 0) { + return; + } + if (watchedSeconds / duration < ANILIST_UPDATE_MIN_WATCH_RATIO) { + return; + } + + const guess = await ensureAnilistMediaGuess(mediaKey); + if (!guess?.title || !guess.episode || guess.episode <= 0) { + return; + } + + const attemptKey = buildAnilistAttemptKey(mediaKey, guess.episode); + if (anilistAttemptedUpdateKeys.has(attemptKey)) { + return; + } + + 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; + } + const result = await updateAnilistPostWatchProgress( + accessToken, + guess.title, + guess.episode, + ); + 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 { + anilistUpdateInFlight = false; + } +} + function loadSubtitlePosition(): SubtitlePosition | null { appState.subtitlePosition = loadSubtitlePositionService({ currentMediaPath: appState.currentMediaPath, @@ -655,6 +1134,7 @@ const startupState = runStartupBootstrapRuntimeService( reloadConfig: () => { configService.reloadConfig(); appLogger.logInfo(`Using config file: ${configService.getConfigPath()}`); + void refreshAnilistClientSecretState({ force: true }); }, getResolvedConfig: () => getResolvedConfig(), getConfigWarnings: () => configService.getWarnings(), @@ -731,6 +1211,10 @@ const startupState = runStartupBootstrapRuntimeService( if (appState.ankiIntegration) { appState.ankiIntegration.destroy(); } + if (appState.anilistSetupWindow) { + appState.anilistSetupWindow.destroy(); + } + appState.anilistSetupWindow = null; }, shouldRestoreWindowsOnActivate: () => appState.overlayRuntimeInitialized && BrowserWindow.getAllWindows().length === 0, @@ -745,6 +1229,8 @@ const startupState = runStartupBootstrapRuntimeService( ); applyStartupState(appState, startupState); +void refreshAnilistClientSecretState({ force: true }); +refreshAnilistRetryQueueState(); function handleCliCommand( args: CliArgs, @@ -828,16 +1314,26 @@ function bindMpvClientEventHandlers(mpvClient: MpvIpcClient): void { broadcastToOverlayWindows("secondary-subtitle:set", text); }); mpvClient.on("subtitle-timing", ({ text, start, end }) => { - if (!text.trim() || !appState.subtitleTimingTracker) { - return; + if (text.trim() && appState.subtitleTimingTracker) { + appState.subtitleTimingTracker.recordSubtitle(text, start, end); } - appState.subtitleTimingTracker.recordSubtitle(text, start, end); + void maybeRunAnilistPostWatchUpdate().catch((error) => { + logger.error("AniList post-watch update failed unexpectedly", error); + }); }); mpvClient.on("media-path-change", ({ path }) => { mediaRuntime.updateCurrentMediaPath(path); + const mediaKey = getCurrentAnilistMediaKey(); + resetAnilistMediaTracking(mediaKey); + if (mediaKey) { + void maybeProbeAnilistDuration(mediaKey); + void ensureAnilistMediaGuess(mediaKey); + } }); mpvClient.on("media-title-change", ({ title }) => { mediaRuntime.updateCurrentMediaTitle(title); + anilistCurrentMediaGuess = null; + anilistCurrentMediaGuessPromise = null; }); mpvClient.on("subtitle-metrics-change", ({ patch }) => { updateMpvSubtitleRenderMetrics(patch); diff --git a/src/main/anilist-url-guard.test.ts b/src/main/anilist-url-guard.test.ts new file mode 100644 index 0000000..1a217f5 --- /dev/null +++ b/src/main/anilist-url-guard.test.ts @@ -0,0 +1,37 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { + isAllowedAnilistExternalUrl, + isAllowedAnilistSetupNavigationUrl, +} from "./anilist-url-guard"; + +test("allows only AniList https URLs for external opens", () => { + assert.equal(isAllowedAnilistExternalUrl("https://anilist.co"), true); + assert.equal( + isAllowedAnilistExternalUrl("https://www.anilist.co/settings/developer"), + true, + ); + assert.equal(isAllowedAnilistExternalUrl("http://anilist.co"), false); + assert.equal(isAllowedAnilistExternalUrl("https://example.com"), false); + assert.equal(isAllowedAnilistExternalUrl("file:///tmp/test"), false); + assert.equal(isAllowedAnilistExternalUrl("not a url"), false); +}); + +test("allows only AniList https or data URLs for setup navigation", () => { + assert.equal( + isAllowedAnilistSetupNavigationUrl("https://anilist.co/api/v2/oauth/authorize"), + true, + ); + assert.equal( + isAllowedAnilistSetupNavigationUrl( + "data:text/html;charset=utf-8,%3Chtml%3E%3C%2Fhtml%3E", + ), + true, + ); + assert.equal( + isAllowedAnilistSetupNavigationUrl("https://example.com/redirect"), + false, + ); + assert.equal(isAllowedAnilistSetupNavigationUrl("javascript:alert(1)"), false); +}); diff --git a/src/main/anilist-url-guard.ts b/src/main/anilist-url-guard.ts new file mode 100644 index 0000000..a67461a --- /dev/null +++ b/src/main/anilist-url-guard.ts @@ -0,0 +1,25 @@ +const ANILIST_ALLOWED_HOSTS = new Set(["anilist.co", "www.anilist.co"]); + +export function isAllowedAnilistExternalUrl(rawUrl: string): boolean { + try { + const parsedUrl = new URL(rawUrl); + return ( + parsedUrl.protocol === "https:" && + ANILIST_ALLOWED_HOSTS.has(parsedUrl.hostname.toLowerCase()) + ); + } catch { + return false; + } +} + +export function isAllowedAnilistSetupNavigationUrl(rawUrl: string): boolean { + if (isAllowedAnilistExternalUrl(rawUrl)) { + return true; + } + try { + const parsedUrl = new URL(rawUrl); + return parsedUrl.protocol === "data:"; + } catch { + return false; + } +} diff --git a/src/main/state.ts b/src/main/state.ts index 1dab53e..85e4384 100644 --- a/src/main/state.ts +++ b/src/main/state.ts @@ -18,10 +18,27 @@ import type { RuntimeOptionsManager } from "../runtime-options"; import type { MecabTokenizer } from "../mecab-tokenizer"; import type { BaseWindowTracker } from "../window-trackers"; +export interface AnilistSecretResolutionState { + status: "not_checked" | "resolved" | "error"; + 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; yomitanParserWindow: BrowserWindow | null; + anilistSetupWindow: BrowserWindow | null; yomitanParserReadyPromise: Promise | null; yomitanParserInitPromise: Promise | null; mpvClient: MpvIpcClient | null; @@ -33,6 +50,7 @@ export interface AppState { currentMediaPath: string | null; currentMediaTitle: string | null; pendingSubtitlePosition: SubtitlePosition | null; + anilistClientSecretState: AnilistSecretResolutionState; mecabTokenizer: MecabTokenizer | null; keybindings: Keybinding[]; subtitleTimingTracker: SubtitleTimingTracker | null; @@ -57,6 +75,8 @@ export interface AppState { texthookerOnlyMode: boolean; jlptLevelLookup: (term: string) => JlptLevel | null; frequencyRankLookup: FrequencyDictionaryLookup; + anilistSetupPageOpened: boolean; + anilistRetryQueueState: AnilistRetryQueueState; } export interface AppStateInitialValues { @@ -81,6 +101,7 @@ export function createAppState(values: AppStateInitialValues): AppState { yomitanExt: null, yomitanSettingsWindow: null, yomitanParserWindow: null, + anilistSetupWindow: null, yomitanParserReadyPromise: null, yomitanParserInitPromise: null, mpvClient: null, @@ -92,6 +113,13 @@ export function createAppState(values: AppStateInitialValues): AppState { currentMediaPath: null, currentMediaTitle: null, pendingSubtitlePosition: null, + anilistClientSecretState: { + status: "not_checked", + source: "none", + message: null, + resolvedAt: null, + errorAt: null, + }, mecabTokenizer: null, keybindings: [], subtitleTimingTracker: null, @@ -118,6 +146,14 @@ export function createAppState(values: AppStateInitialValues): AppState { texthookerOnlyMode: values.texthookerOnlyMode ?? false, jlptLevelLookup: () => null, frequencyRankLookup: () => null, + anilistSetupPageOpened: false, + anilistRetryQueueState: { + pending: 0, + ready: 0, + deadLetter: 0, + lastAttemptAt: null, + lastError: null, + }, }; } diff --git a/src/types.ts b/src/types.ts index f602b4c..f7fe614 100644 --- a/src/types.ts +++ b/src/types.ts @@ -333,6 +333,11 @@ export interface JimakuConfig { maxEntryResults?: number; } +export interface AnilistConfig { + enabled?: boolean; + accessToken?: string; +} + export interface InvisibleOverlayConfig { startupVisibility?: "platform-default" | "visible" | "hidden"; } @@ -359,6 +364,7 @@ export interface Config { auto_start_overlay?: boolean; bind_visible_overlay_to_mpv_sub_visibility?: boolean; jimaku?: JimakuConfig; + anilist?: AnilistConfig; invisibleOverlay?: InvisibleOverlayConfig; youtubeSubgen?: YoutubeSubgenConfig; logging?: { @@ -464,6 +470,10 @@ export interface ResolvedConfig { languagePreference: JimakuLanguagePreference; maxEntryResults: number; }; + anilist: { + enabled: boolean; + accessToken: string; + }; invisibleOverlay: Required; youtubeSubgen: YoutubeSubgenConfig & { mode: YoutubeSubgenMode;