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