mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-03-02 06:22:42 -08:00
Fix AniList URL guard
This commit is contained in:
@@ -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": ""
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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.
|
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
|
### 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:
|
||||||
|
|||||||
@@ -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": ""
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,7 +17,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",
|
||||||
|
|||||||
@@ -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", () => {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
76
src/core/services/anilist/anilist-auth.ts
Normal file
76
src/core/services/anilist/anilist-auth.ts
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
import * as childProcess from "child_process";
|
||||||
|
|
||||||
|
const SECRET_COMMAND_PATTERN = /^\((.*)\)$/s;
|
||||||
|
const COMMAND_CACHE = new Map<string, string>();
|
||||||
|
const COMMAND_PENDING = new Map<string, Promise<string>>();
|
||||||
|
|
||||||
|
function executeCommand(command: string): Promise<string> {
|
||||||
|
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<string> {
|
||||||
|
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;
|
||||||
|
}
|
||||||
301
src/core/services/anilist/anilist-updater.ts
Normal file
301
src/core/services/anilist/anilist-updater.ts
Normal 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}.`,
|
||||||
|
};
|
||||||
|
}
|
||||||
386
src/main.ts
386
src/main.ts
@@ -132,6 +132,11 @@ import {
|
|||||||
triggerFieldGroupingService,
|
triggerFieldGroupingService,
|
||||||
updateLastCardFromClipboardService,
|
updateLastCardFromClipboardService,
|
||||||
} from "./core/services";
|
} from "./core/services";
|
||||||
|
import {
|
||||||
|
guessAnilistMediaInfo,
|
||||||
|
type AnilistMediaGuess,
|
||||||
|
updateAnilistPostWatchProgress,
|
||||||
|
} from "./core/services/anilist/anilist-updater";
|
||||||
import { applyRuntimeOptionResultRuntimeService } from "./core/services/runtime-options-ipc-service";
|
import { applyRuntimeOptionResultRuntimeService } from "./core/services/runtime-options-ipc-service";
|
||||||
import {
|
import {
|
||||||
createAppReadyRuntimeRunner,
|
createAppReadyRuntimeRunner,
|
||||||
@@ -169,9 +174,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 +202,22 @@ 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_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<AnilistMediaGuess | null> | null = null;
|
||||||
|
let anilistLastDurationProbeAtMs = 0;
|
||||||
|
let anilistUpdateInFlight = false;
|
||||||
|
const anilistAttemptedUpdateKeys = new Set<string>();
|
||||||
|
|
||||||
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(
|
||||||
@@ -572,6 +598,346 @@ async function jimakuFetchJson<T>(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function setAnilistClientSecretState(partial: Partial<AppState["anilistClientSecretState"]>): 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 = `<!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();
|
||||||
|
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<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}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 {
|
||||||
|
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 {
|
function loadSubtitlePosition(): SubtitlePosition | null {
|
||||||
appState.subtitlePosition = loadSubtitlePositionService({
|
appState.subtitlePosition = loadSubtitlePositionService({
|
||||||
currentMediaPath: appState.currentMediaPath,
|
currentMediaPath: appState.currentMediaPath,
|
||||||
@@ -655,6 +1021,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 +1098,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 +1116,7 @@ const startupState = runStartupBootstrapRuntimeService(
|
|||||||
);
|
);
|
||||||
|
|
||||||
applyStartupState(appState, startupState);
|
applyStartupState(appState, startupState);
|
||||||
|
void refreshAnilistClientSecretState({ force: true });
|
||||||
|
|
||||||
function handleCliCommand(
|
function handleCliCommand(
|
||||||
args: CliArgs,
|
args: CliArgs,
|
||||||
@@ -828,16 +1200,24 @@ 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();
|
||||||
});
|
});
|
||||||
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);
|
||||||
|
|||||||
37
src/main/anilist-url-guard.test.ts
Normal file
37
src/main/anilist-url-guard.test.ts
Normal 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);
|
||||||
|
});
|
||||||
25
src/main/anilist-url-guard.ts
Normal file
25
src/main/anilist-url-guard.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -18,10 +18,19 @@ 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" | "command";
|
||||||
|
message: string | null;
|
||||||
|
resolvedAt: number | null;
|
||||||
|
errorAt: number | 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 +42,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 +67,7 @@ export interface AppState {
|
|||||||
texthookerOnlyMode: boolean;
|
texthookerOnlyMode: boolean;
|
||||||
jlptLevelLookup: (term: string) => JlptLevel | null;
|
jlptLevelLookup: (term: string) => JlptLevel | null;
|
||||||
frequencyRankLookup: FrequencyDictionaryLookup;
|
frequencyRankLookup: FrequencyDictionaryLookup;
|
||||||
|
anilistSetupPageOpened: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AppStateInitialValues {
|
export interface AppStateInitialValues {
|
||||||
@@ -81,6 +92,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 +104,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 +137,7 @@ export function createAppState(values: AppStateInitialValues): AppState {
|
|||||||
texthookerOnlyMode: values.texthookerOnlyMode ?? false,
|
texthookerOnlyMode: values.texthookerOnlyMode ?? false,
|
||||||
jlptLevelLookup: () => null,
|
jlptLevelLookup: () => null,
|
||||||
frequencyRankLookup: () => null,
|
frequencyRankLookup: () => null,
|
||||||
|
anilistSetupPageOpened: false,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
10
src/types.ts
10
src/types.ts
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user