Merge pull request #7 from ksyasuda/feature/add-anilist-tracking

Add AniList Tracking
This commit is contained in:
2026-02-17 00:08:33 -08:00
committed by GitHub
19 changed files with 1642 additions and 19 deletions

View File

@@ -1,9 +1,10 @@
--- ---
id: TASK-29 id: TASK-29
title: Add Anilist integration for post-watch updates title: Add Anilist integration for post-watch updates
status: To Do status: In Progress
assignee: [] assignee: []
created_date: '2026-02-13 17:57' created_date: '2026-02-13 17:57'
updated_date: '2026-02-17 04:19'
labels: labels:
- anilist - anilist
- anime - anime
@@ -40,6 +41,12 @@ Requirements:
- [ ] #7 Error states and duplicate/duplicate-inconsistent updates are handled deterministically (idempotent where practical). - [ ] #7 Error states and duplicate/duplicate-inconsistent updates are handled deterministically (idempotent where practical).
<!-- AC:END --> <!-- AC:END -->
## Implementation Notes
<!-- SECTION:NOTES:BEGIN -->
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.
<!-- SECTION:NOTES:END -->
## Definition of Done ## Definition of Done
<!-- DOD:BEGIN --> <!-- DOD:BEGIN -->
- [ ] #1 Core Anilist service module exists and is wired into application flow for post-watch updates. - [ ] #1 Core Anilist service module exists and is wired into application flow for post-watch updates.

View File

@@ -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
<!-- AC:BEGIN -->
- [ ] #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.
<!-- AC:END -->
## Implementation Notes
<!-- SECTION:NOTES:BEGIN -->
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.
<!-- SECTION:NOTES:END -->
## Definition of Done
<!-- DOD:BEGIN -->
- [ ] #1 Token lifecycle module wired into AniList update/auth flow.
- [ ] #2 Unit/integration coverage added for token storage and logout paths.
<!-- DOD:END -->

View File

@@ -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
<!-- AC:BEGIN -->
- [ ] #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.
<!-- AC:END -->
## Implementation Notes
<!-- SECTION:NOTES:BEGIN -->
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.
<!-- SECTION:NOTES:END -->
## Definition of Done
<!-- DOD:BEGIN -->
- [ ] #1 Queue service integrated into AniList post-watch update path.
- [ ] #2 Backoff/retry behavior covered by unit tests.
<!-- DOD:END -->

View File

@@ -37,6 +37,15 @@
"port": 6677 "port": 6677
}, },
// ==========================================
// Logging
// Controls logging verbosity.
// Set to debug for full runtime diagnostics.
// ==========================================
"logging": {
"level": "info"
},
// ========================================== // ==========================================
// AnkiConnect Integration // AnkiConnect Integration
// Automatic Anki updates and media generation options. // Automatic Anki updates and media generation options.
@@ -87,6 +96,7 @@
"refreshMinutes": 1440, "refreshMinutes": 1440,
"matchMode": "headword", "matchMode": "headword",
"decks": [], "decks": [],
"minSentenceWords": 3,
"nPlusOne": "#c6a0f6", "nPlusOne": "#c6a0f6",
"knownWord": "#a6da95" "knownWord": "#a6da95"
}, },
@@ -165,6 +175,20 @@
"N4": "#a6e3a1", "N4": "#a6e3a1",
"N5": "#8aadf4" "N5": "#8aadf4"
}, },
"frequencyDictionary": {
"enabled": false,
"sourcePath": "",
"topX": 1000,
"mode": "single",
"singleColor": "#f5a97f",
"bandedColors": [
"#ed8796",
"#f5a97f",
"#f9e2af",
"#a6e3a1",
"#8aadf4"
]
},
"secondary": { "secondary": {
"fontSize": 24, "fontSize": 24,
"fontColor": "#ffffff", "fontColor": "#ffffff",
@@ -227,5 +251,14 @@
"ja", "ja",
"jpn" "jpn"
] ]
},
// ==========================================
// Anilist
// Anilist API credentials and update behavior.
// ==========================================
"anilist": {
"enabled": false,
"accessToken": ""
} }
} }

View File

@@ -52,6 +52,7 @@ The configuration file includes several main sections:
- [**Auto Subtitle Sync**](#auto-subtitle-sync) - Sync current subtitle with `alass`/`ffsubsync` - [**Auto Subtitle Sync**](#auto-subtitle-sync) - Sync current subtitle with `alass`/`ffsubsync`
- [**Invisible Overlay**](#invisible-overlay) - Startup visibility behavior for the invisible mining layer - [**Invisible Overlay**](#invisible-overlay) - Startup visibility behavior for the invisible mining layer
- [**Jimaku**](#jimaku) - Jimaku API configuration and defaults - [**Jimaku**](#jimaku) - Jimaku API configuration and defaults
- [**AniList**](#anilist) - Optional post-watch progress updates
- [**Keybindings**](#keybindings) - MPV command shortcuts - [**Keybindings**](#keybindings) - MPV command shortcuts
- [**Runtime Option Palette**](#runtime-option-palette) - Live, session-only option toggles - [**Runtime Option Palette**](#runtime-option-palette) - Live, session-only option toggles
- [**Secondary Subtitles**](#secondary-subtitles) - Dual subtitle track support - [**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. 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 ### Keybindings
Add a `keybindings` array to configure keyboard shortcuts that send commands to mpv: Add a `keybindings` array to configure keyboard shortcuts that send commands to mpv:

View File

@@ -37,6 +37,15 @@
"port": 6677 "port": 6677
}, },
// ==========================================
// Logging
// Controls logging verbosity.
// Set to debug for full runtime diagnostics.
// ==========================================
"logging": {
"level": "info"
},
// ========================================== // ==========================================
// AnkiConnect Integration // AnkiConnect Integration
// Automatic Anki updates and media generation options. // Automatic Anki updates and media generation options.
@@ -151,20 +160,6 @@
// ========================================== // ==========================================
"subtitleStyle": { "subtitleStyle": {
"enableJlpt": false, "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", "fontFamily": "Noto Sans CJK JP Regular, Noto Sans CJK JP, Arial Unicode MS, Arial, sans-serif",
"fontSize": 35, "fontSize": 35,
"fontColor": "#cad3f5", "fontColor": "#cad3f5",
@@ -180,6 +175,20 @@
"N4": "#a6e3a1", "N4": "#a6e3a1",
"N5": "#8aadf4" "N5": "#8aadf4"
}, },
"frequencyDictionary": {
"enabled": false,
"sourcePath": "",
"topX": 1000,
"mode": "single",
"singleColor": "#f5a97f",
"bandedColors": [
"#ed8796",
"#f5a97f",
"#f9e2af",
"#a6e3a1",
"#8aadf4"
]
},
"secondary": { "secondary": {
"fontSize": 24, "fontSize": 24,
"fontColor": "#ffffff", "fontColor": "#ffffff",
@@ -242,5 +251,14 @@
"ja", "ja",
"jpn" "jpn"
] ]
},
// ==========================================
// Anilist
// Anilist API credentials and update behavior.
// ==========================================
"anilist": {
"enabled": false,
"accessToken": ""
} }
} }

View File

@@ -21,7 +21,7 @@
"docs:build": "VITE_EXTRA_EXTENSIONS=jsonc vitepress build docs", "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", "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: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:subtitle:dist": "echo \"Subtitle tests are currently not configured\"",
"test:config": "pnpm run build && pnpm run test:config:dist", "test:config": "pnpm run build && pnpm run test:config:dist",
"test:core": "pnpm run build && pnpm run test:core:dist", "test:core": "pnpm run build && pnpm run test:core:dist",

View File

@@ -17,6 +17,30 @@ test("loads defaults when config is missing", () => {
const config = service.getConfig(); const config = service.getConfig();
assert.equal(config.websocket.port, DEFAULT_CONFIG.websocket.port); assert.equal(config.websocket.port, DEFAULT_CONFIG.websocket.port);
assert.equal(config.ankiConnect.behavior.autoUpdateNewCards, true); 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", () => { test("parses jsonc and warns/falls back on invalid value", () => {

View File

@@ -226,6 +226,10 @@ export const DEFAULT_CONFIG: ResolvedConfig = {
languagePreference: "ja", languagePreference: "ja",
maxEntryResults: 10, maxEntryResults: 10,
}, },
anilist: {
enabled: false,
accessToken: "",
},
youtubeSubgen: { youtubeSubgen: {
mode: "automatic", mode: "automatic",
whisperBin: "", whisperBin: "",
@@ -467,6 +471,18 @@ export const CONFIG_OPTION_REGISTRY: ConfigOptionRegistryEntry[] = [
defaultValue: DEFAULT_CONFIG.jimaku.maxEntryResults, defaultValue: DEFAULT_CONFIG.jimaku.maxEntryResults,
description: "Maximum Jimaku search results returned.", 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", path: "youtubeSubgen.mode",
kind: "enum", kind: "enum",
@@ -600,6 +616,11 @@ export const CONFIG_TEMPLATE_SECTIONS: ConfigTemplateSection[] = [
], ],
key: "youtubeSubgen", key: "youtubeSubgen",
}, },
{
title: "Anilist",
description: ["Anilist API credentials and update behavior."],
key: "anilist",
},
]; ];
export function deepCloneConfig(config: ResolvedConfig): ResolvedConfig { export function deepCloneConfig(config: ResolvedConfig): ResolvedConfig {

View File

@@ -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) { if (asBoolean(src.auto_start_overlay) !== undefined) {
resolved.auto_start_overlay = src.auto_start_overlay as boolean; resolved.auto_start_overlay = src.auto_start_overlay as boolean;
} }

View File

@@ -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);
}
},
};
}

View File

@@ -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,
};
},
};
}

View File

@@ -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;
}
});

View File

@@ -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<T> {
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<string> {
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<T>(
accessToken: string,
query: string,
variables: Record<string, unknown>,
): Promise<AnilistGraphQlResponse<T>> {
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<T>;
return payload;
} catch (error) {
return {
errors: [
{
message:
error instanceof Error ? error.message : String(error),
},
],
};
}
}
function firstErrorMessage<T>(response: AnilistGraphQlResponse<T>): 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<AnilistMediaGuess | null> {
const target = mediaPath ?? mediaTitle;
if (target && target.trim().length > 0) {
try {
const stdout = await runGuessit(target);
const parsed = JSON.parse(stdout) as Record<string, unknown>;
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<AnilistPostWatchUpdateResult> {
const searchResponse = await anilistGraphQl<AnilistSearchData>(
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<AnilistMediaEntryData>(
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<AnilistSaveEntryData>(
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}.`,
};
}

View File

@@ -132,6 +132,13 @@ import {
triggerFieldGroupingService, triggerFieldGroupingService,
updateLastCardFromClipboardService, updateLastCardFromClipboardService,
} from "./core/services"; } 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 { applyRuntimeOptionResultRuntimeService } from "./core/services/runtime-options-ipc-service";
import { import {
createAppReadyRuntimeRunner, createAppReadyRuntimeRunner,
@@ -169,9 +176,14 @@ import {
import { createMediaRuntimeService } from "./main/media-runtime"; import { createMediaRuntimeService } from "./main/media-runtime";
import { createOverlayVisibilityRuntimeService } from "./main/overlay-visibility-runtime"; import { createOverlayVisibilityRuntimeService } from "./main/overlay-visibility-runtime";
import { import {
type AppState,
applyStartupState, applyStartupState,
createAppState, createAppState,
} from "./main/state"; } from "./main/state";
import {
isAllowedAnilistExternalUrl,
isAllowedAnilistSetupNavigationUrl,
} from "./main/anilist-url-guard";
import { createStartupBootstrapRuntimeDeps } from "./main/startup"; import { createStartupBootstrapRuntimeDeps } from "./main/startup";
import { createAppLifecycleRuntimeRunner } from "./main/startup-lifecycle"; import { createAppLifecycleRuntimeRunner } from "./main/startup-lifecycle";
import { import {
@@ -192,6 +204,27 @@ const DEFAULT_MPV_LOG_FILE = path.join(
"SubMiner", "SubMiner",
"mp.log", "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<AnilistMediaGuess | null> | null = null;
let anilistLastDurationProbeAtMs = 0;
let anilistUpdateInFlight = false;
const anilistAttemptedUpdateKeys = new Set<string>();
let anilistCachedAccessToken: string | null = null;
function resolveConfigDir(): string { function resolveConfigDir(): string {
const xdgConfigHome = process.env.XDG_CONFIG_HOME?.trim(); const xdgConfigHome = process.env.XDG_CONFIG_HOME?.trim();
const baseDirs = Array.from( const baseDirs = Array.from(
@@ -230,6 +263,22 @@ const CONFIG_DIR = resolveConfigDir();
const USER_DATA_PATH = CONFIG_DIR; const USER_DATA_PATH = CONFIG_DIR;
const DEFAULT_MPV_LOG_PATH = process.env.SUBMINER_MPV_LOG?.trim() || DEFAULT_MPV_LOG_FILE; const DEFAULT_MPV_LOG_PATH = process.env.SUBMINER_MPV_LOG?.trim() || DEFAULT_MPV_LOG_FILE;
const configService = new ConfigService(CONFIG_DIR); 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 = const isDev =
process.argv.includes("--dev") || process.argv.includes("--debug"); process.argv.includes("--dev") || process.argv.includes("--debug");
const texthookerService = new TexthookerService(); const texthookerService = new TexthookerService();
@@ -572,6 +621,436 @@ async function jimakuFetchJson<T>(
}); });
} }
function setAnilistClientSecretState(partial: Partial<AppState["anilistClientSecretState"]>): 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 = `<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<title>AniList Setup</title>
<style>
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; margin: 0; padding: 24px; background: #0b1020; color: #e5e7eb; }
h1 { margin: 0 0 12px; font-size: 22px; }
p { margin: 10px 0; line-height: 1.45; color: #cbd5e1; }
a { color: #93c5fd; word-break: break-all; }
.box { background: #111827; border: 1px solid #1f2937; border-radius: 10px; padding: 16px; }
.reason { color: #fca5a5; }
</style>
</head>
<body>
<h1>AniList Setup</h1>
<div class="box">
<p class="reason">Embedded AniList page did not render: ${reason}</p>
<p>We attempted to open the authorize URL in your default browser automatically.</p>
<p>Use one of these links to continue setup:</p>
<p><a href="${authorizeUrl}">${authorizeUrl}</a></p>
<p><a href="${ANILIST_DEVELOPER_SETTINGS_URL}">${ANILIST_DEVELOPER_SETTINGS_URL}</a></p>
<p>After login/authorization, copy the token into <code>anilist.accessToken</code>.</p>
</div>
</body>
</html>`;
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<string | null> {
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<number | null> {
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<AnilistMediaGuess | null> {
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<void> {
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<void> {
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 { function loadSubtitlePosition(): SubtitlePosition | null {
appState.subtitlePosition = loadSubtitlePositionService({ appState.subtitlePosition = loadSubtitlePositionService({
currentMediaPath: appState.currentMediaPath, currentMediaPath: appState.currentMediaPath,
@@ -655,6 +1134,7 @@ const startupState = runStartupBootstrapRuntimeService(
reloadConfig: () => { reloadConfig: () => {
configService.reloadConfig(); configService.reloadConfig();
appLogger.logInfo(`Using config file: ${configService.getConfigPath()}`); appLogger.logInfo(`Using config file: ${configService.getConfigPath()}`);
void refreshAnilistClientSecretState({ force: true });
}, },
getResolvedConfig: () => getResolvedConfig(), getResolvedConfig: () => getResolvedConfig(),
getConfigWarnings: () => configService.getWarnings(), getConfigWarnings: () => configService.getWarnings(),
@@ -731,6 +1211,10 @@ const startupState = runStartupBootstrapRuntimeService(
if (appState.ankiIntegration) { if (appState.ankiIntegration) {
appState.ankiIntegration.destroy(); appState.ankiIntegration.destroy();
} }
if (appState.anilistSetupWindow) {
appState.anilistSetupWindow.destroy();
}
appState.anilistSetupWindow = null;
}, },
shouldRestoreWindowsOnActivate: () => shouldRestoreWindowsOnActivate: () =>
appState.overlayRuntimeInitialized && BrowserWindow.getAllWindows().length === 0, appState.overlayRuntimeInitialized && BrowserWindow.getAllWindows().length === 0,
@@ -745,6 +1229,8 @@ const startupState = runStartupBootstrapRuntimeService(
); );
applyStartupState(appState, startupState); applyStartupState(appState, startupState);
void refreshAnilistClientSecretState({ force: true });
refreshAnilistRetryQueueState();
function handleCliCommand( function handleCliCommand(
args: CliArgs, args: CliArgs,
@@ -828,16 +1314,26 @@ function bindMpvClientEventHandlers(mpvClient: MpvIpcClient): void {
broadcastToOverlayWindows("secondary-subtitle:set", text); broadcastToOverlayWindows("secondary-subtitle:set", text);
}); });
mpvClient.on("subtitle-timing", ({ text, start, end }) => { mpvClient.on("subtitle-timing", ({ text, start, end }) => {
if (!text.trim() || !appState.subtitleTimingTracker) { if (text.trim() && appState.subtitleTimingTracker) {
return;
}
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 }) => { mpvClient.on("media-path-change", ({ path }) => {
mediaRuntime.updateCurrentMediaPath(path); mediaRuntime.updateCurrentMediaPath(path);
const mediaKey = getCurrentAnilistMediaKey();
resetAnilistMediaTracking(mediaKey);
if (mediaKey) {
void maybeProbeAnilistDuration(mediaKey);
void ensureAnilistMediaGuess(mediaKey);
}
}); });
mpvClient.on("media-title-change", ({ title }) => { mpvClient.on("media-title-change", ({ title }) => {
mediaRuntime.updateCurrentMediaTitle(title); mediaRuntime.updateCurrentMediaTitle(title);
anilistCurrentMediaGuess = null;
anilistCurrentMediaGuessPromise = null;
}); });
mpvClient.on("subtitle-metrics-change", ({ patch }) => { mpvClient.on("subtitle-metrics-change", ({ patch }) => {
updateMpvSubtitleRenderMetrics(patch); updateMpvSubtitleRenderMetrics(patch);

View File

@@ -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);
});

View File

@@ -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;
}
}

View File

@@ -18,10 +18,27 @@ import type { RuntimeOptionsManager } from "../runtime-options";
import type { MecabTokenizer } from "../mecab-tokenizer"; import type { MecabTokenizer } from "../mecab-tokenizer";
import type { BaseWindowTracker } from "../window-trackers"; 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 { export interface AppState {
yomitanExt: Extension | null; yomitanExt: Extension | null;
yomitanSettingsWindow: BrowserWindow | null; yomitanSettingsWindow: BrowserWindow | null;
yomitanParserWindow: BrowserWindow | null; yomitanParserWindow: BrowserWindow | null;
anilistSetupWindow: BrowserWindow | null;
yomitanParserReadyPromise: Promise<void> | null; yomitanParserReadyPromise: Promise<void> | null;
yomitanParserInitPromise: Promise<boolean> | null; yomitanParserInitPromise: Promise<boolean> | null;
mpvClient: MpvIpcClient | null; mpvClient: MpvIpcClient | null;
@@ -33,6 +50,7 @@ export interface AppState {
currentMediaPath: string | null; currentMediaPath: string | null;
currentMediaTitle: string | null; currentMediaTitle: string | null;
pendingSubtitlePosition: SubtitlePosition | null; pendingSubtitlePosition: SubtitlePosition | null;
anilistClientSecretState: AnilistSecretResolutionState;
mecabTokenizer: MecabTokenizer | null; mecabTokenizer: MecabTokenizer | null;
keybindings: Keybinding[]; keybindings: Keybinding[];
subtitleTimingTracker: SubtitleTimingTracker | null; subtitleTimingTracker: SubtitleTimingTracker | null;
@@ -57,6 +75,8 @@ export interface AppState {
texthookerOnlyMode: boolean; texthookerOnlyMode: boolean;
jlptLevelLookup: (term: string) => JlptLevel | null; jlptLevelLookup: (term: string) => JlptLevel | null;
frequencyRankLookup: FrequencyDictionaryLookup; frequencyRankLookup: FrequencyDictionaryLookup;
anilistSetupPageOpened: boolean;
anilistRetryQueueState: AnilistRetryQueueState;
} }
export interface AppStateInitialValues { export interface AppStateInitialValues {
@@ -81,6 +101,7 @@ export function createAppState(values: AppStateInitialValues): AppState {
yomitanExt: null, yomitanExt: null,
yomitanSettingsWindow: null, yomitanSettingsWindow: null,
yomitanParserWindow: null, yomitanParserWindow: null,
anilistSetupWindow: null,
yomitanParserReadyPromise: null, yomitanParserReadyPromise: null,
yomitanParserInitPromise: null, yomitanParserInitPromise: null,
mpvClient: null, mpvClient: null,
@@ -92,6 +113,13 @@ export function createAppState(values: AppStateInitialValues): AppState {
currentMediaPath: null, currentMediaPath: null,
currentMediaTitle: null, currentMediaTitle: null,
pendingSubtitlePosition: null, pendingSubtitlePosition: null,
anilistClientSecretState: {
status: "not_checked",
source: "none",
message: null,
resolvedAt: null,
errorAt: null,
},
mecabTokenizer: null, mecabTokenizer: null,
keybindings: [], keybindings: [],
subtitleTimingTracker: null, subtitleTimingTracker: null,
@@ -118,6 +146,14 @@ export function createAppState(values: AppStateInitialValues): AppState {
texthookerOnlyMode: values.texthookerOnlyMode ?? false, texthookerOnlyMode: values.texthookerOnlyMode ?? false,
jlptLevelLookup: () => null, jlptLevelLookup: () => null,
frequencyRankLookup: () => null, frequencyRankLookup: () => null,
anilistSetupPageOpened: false,
anilistRetryQueueState: {
pending: 0,
ready: 0,
deadLetter: 0,
lastAttemptAt: null,
lastError: null,
},
}; };
} }

View File

@@ -333,6 +333,11 @@ export interface JimakuConfig {
maxEntryResults?: number; maxEntryResults?: number;
} }
export interface AnilistConfig {
enabled?: boolean;
accessToken?: string;
}
export interface InvisibleOverlayConfig { export interface InvisibleOverlayConfig {
startupVisibility?: "platform-default" | "visible" | "hidden"; startupVisibility?: "platform-default" | "visible" | "hidden";
} }
@@ -359,6 +364,7 @@ export interface Config {
auto_start_overlay?: boolean; auto_start_overlay?: boolean;
bind_visible_overlay_to_mpv_sub_visibility?: boolean; bind_visible_overlay_to_mpv_sub_visibility?: boolean;
jimaku?: JimakuConfig; jimaku?: JimakuConfig;
anilist?: AnilistConfig;
invisibleOverlay?: InvisibleOverlayConfig; invisibleOverlay?: InvisibleOverlayConfig;
youtubeSubgen?: YoutubeSubgenConfig; youtubeSubgen?: YoutubeSubgenConfig;
logging?: { logging?: {
@@ -464,6 +470,10 @@ export interface ResolvedConfig {
languagePreference: JimakuLanguagePreference; languagePreference: JimakuLanguagePreference;
maxEntryResults: number; maxEntryResults: number;
}; };
anilist: {
enabled: boolean;
accessToken: string;
};
invisibleOverlay: Required<InvisibleOverlayConfig>; invisibleOverlay: Required<InvisibleOverlayConfig>;
youtubeSubgen: YoutubeSubgenConfig & { youtubeSubgen: YoutubeSubgenConfig & {
mode: YoutubeSubgenMode; mode: YoutubeSubgenMode;