From 107971f1516c105d664dc51050032e3c97606769 Mon Sep 17 00:00:00 2001 From: sudacode Date: Mon, 16 Feb 2026 01:56:21 -0800 Subject: [PATCH] Fix AniList URL guard --- config.example.jsonc | 33 ++ docs/configuration.md | 21 + docs/public/config.example.jsonc | 46 ++- package.json | 2 +- src/config/config.test.ts | 24 ++ src/config/definitions.ts | 21 + src/config/service.ts | 26 ++ src/core/services/anilist/anilist-auth.ts | 76 ++++ src/core/services/anilist/anilist-updater.ts | 301 +++++++++++++++ src/main.ts | 386 ++++++++++++++++++- src/main/anilist-url-guard.test.ts | 37 ++ src/main/anilist-url-guard.ts | 25 ++ src/main/state.ts | 20 + src/types.ts | 10 + 14 files changed, 1010 insertions(+), 18 deletions(-) create mode 100644 src/core/services/anilist/anilist-auth.ts create mode 100644 src/core/services/anilist/anilist-updater.ts create mode 100644 src/main/anilist-url-guard.test.ts create mode 100644 src/main/anilist-url-guard.ts 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 7e66a4c..33220e5 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 @@ -361,6 +362,26 @@ 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. + ### 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 e4f2c19..f19bab7 100644 --- a/package.json +++ b/package.json @@ -17,7 +17,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-auth.ts b/src/core/services/anilist/anilist-auth.ts new file mode 100644 index 0000000..94e02cf --- /dev/null +++ b/src/core/services/anilist/anilist-auth.ts @@ -0,0 +1,76 @@ +import * as childProcess from "child_process"; + +const SECRET_COMMAND_PATTERN = /^\((.*)\)$/s; +const COMMAND_CACHE = new Map(); +const COMMAND_PENDING = new Map>(); + +function executeCommand(command: string): Promise { + return new Promise((resolve, reject) => { + childProcess.exec(command, { timeout: 10_000 }, (err, stdout) => { + if (err) { + reject(err); + return; + } + resolve(stdout); + }); + }); +} + +export function clearAnilistClientSecretCache(): void { + COMMAND_CACHE.clear(); + COMMAND_PENDING.clear(); +} + +function resolveCommand(rawSecret: string): string | null { + const commandMatch = rawSecret.match(SECRET_COMMAND_PATTERN); + if (!commandMatch || commandMatch[1] === undefined) { + return null; + } + + const command = commandMatch[1].trim(); + return command.length > 0 ? command : null; +} + +export async function resolveAnilistClientSecret(rawSecret: string): Promise { + const trimmedSecret = rawSecret.trim(); + if (trimmedSecret.length === 0) { + throw new Error("cannot authenticate without client secret"); + } + + const command = resolveCommand(trimmedSecret); + if (!command) { + return trimmedSecret; + } + + const cachedValue = COMMAND_CACHE.get(trimmedSecret); + if (cachedValue !== undefined) { + return cachedValue; + } + + const pending = COMMAND_PENDING.get(trimmedSecret); + if (pending !== undefined) { + return pending; + } + + const promise = executeCommand(command) + .then((stdout) => { + const trimmed = stdout.trim(); + if (!trimmed) { + throw new Error("secret command returned empty value"); + } + COMMAND_CACHE.set(trimmedSecret, trimmed); + COMMAND_PENDING.delete(trimmedSecret); + return trimmed; + }) + .catch((error) => { + COMMAND_PENDING.delete(trimmedSecret); + const errorMessage = error instanceof Error ? error.message : String(error); + if (errorMessage === "secret command returned empty value") { + throw error; + } + throw new Error(`secret command failed: ${errorMessage}`); + }); + + COMMAND_PENDING.set(trimmedSecret, promise); + return promise; +} 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 96a1cb2..89122d2 100644 --- a/src/main.ts +++ b/src/main.ts @@ -132,6 +132,11 @@ import { triggerFieldGroupingService, updateLastCardFromClipboardService, } from "./core/services"; +import { + guessAnilistMediaInfo, + type AnilistMediaGuess, + updateAnilistPostWatchProgress, +} from "./core/services/anilist/anilist-updater"; import { applyRuntimeOptionResultRuntimeService } from "./core/services/runtime-options-ipc-service"; import { createAppReadyRuntimeRunner, @@ -169,9 +174,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 +202,22 @@ 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_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; + +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(); + function resolveConfigDir(): string { const xdgConfigHome = process.env.XDG_CONFIG_HOME?.trim(); const baseDirs = Array.from( @@ -572,6 +598,346 @@ async function jimakuFetchJson( }); } +function setAnilistClientSecretState(partial: Partial): void { + appState.anilistClientSecretState = { + ...appState.anilistClientSecretState, + ...partial, + }; +} + +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); + 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(); + void options; + if (!isAnilistTrackingEnabled(resolved)) { + 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) { + setAnilistClientSecretState({ + status: "resolved", + source: "literal", + message: "using configured anilist.accessToken", + resolvedAt: now, + errorAt: null, + }); + appState.anilistSetupPageOpened = false; + return rawAccessToken; + } + + 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}`; +} + +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 { + const accessToken = await refreshAnilistClientSecretState(); + if (!accessToken) { + showMpvOsd("AniList: access token not configured"); + return; + } + const result = await updateAnilistPostWatchProgress( + accessToken, + guess.title, + guess.episode, + ); + if (result.status === "updated") { + anilistAttemptedUpdateKeys.add(attemptKey); + showMpvOsd(result.message); + logger.info(result.message); + return; + } + if (result.status === "skipped") { + anilistAttemptedUpdateKeys.add(attemptKey); + logger.info(result.message); + return; + } + showMpvOsd(`AniList: ${result.message}`); + logger.warn(result.message); + } finally { + anilistUpdateInFlight = false; + } +} + function loadSubtitlePosition(): SubtitlePosition | null { appState.subtitlePosition = loadSubtitlePositionService({ currentMediaPath: appState.currentMediaPath, @@ -655,6 +1021,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 +1098,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 +1116,7 @@ const startupState = runStartupBootstrapRuntimeService( ); applyStartupState(appState, startupState); +void refreshAnilistClientSecretState({ force: true }); function handleCliCommand( args: CliArgs, @@ -828,16 +1200,24 @@ 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(); }); 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..c502417 100644 --- a/src/main/state.ts +++ b/src/main/state.ts @@ -18,10 +18,19 @@ 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" | "command"; + message: string | null; + resolvedAt: number | null; + errorAt: number | 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 +42,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 +67,7 @@ export interface AppState { texthookerOnlyMode: boolean; jlptLevelLookup: (term: string) => JlptLevel | null; frequencyRankLookup: FrequencyDictionaryLookup; + anilistSetupPageOpened: boolean; } export interface AppStateInitialValues { @@ -81,6 +92,7 @@ export function createAppState(values: AppStateInitialValues): AppState { yomitanExt: null, yomitanSettingsWindow: null, yomitanParserWindow: null, + anilistSetupWindow: null, yomitanParserReadyPromise: null, yomitanParserInitPromise: null, mpvClient: null, @@ -92,6 +104,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 +137,7 @@ export function createAppState(values: AppStateInitialValues): AppState { texthookerOnlyMode: values.texthookerOnlyMode ?? false, jlptLevelLookup: () => null, frequencyRankLookup: () => null, + anilistSetupPageOpened: false, }; } diff --git a/src/types.ts b/src/types.ts index 87f2b52..e1dd07a 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;