feat(anilist): add CLI and IPC management controls

This commit is contained in:
2026-02-17 01:31:26 -08:00
parent a359e91b14
commit 25faf3ef3e
17 changed files with 663 additions and 26 deletions

View File

@@ -46,4 +46,14 @@ test("hasExplicitCommand and shouldStartApp preserve command intent", () => {
assert.equal(refreshKnownWords.help, false);
assert.equal(hasExplicitCommand(refreshKnownWords), true);
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);
});

View File

@@ -22,6 +22,10 @@ export interface CliArgs {
triggerSubsync: boolean;
markAudioCard: boolean;
openRuntimeOptions: boolean;
anilistStatus: boolean;
anilistLogout: boolean;
anilistSetup: boolean;
anilistRetryQueue: boolean;
texthooker: boolean;
help: boolean;
autoStartOverlay: boolean;
@@ -62,6 +66,10 @@ export function parseArgs(argv: string[]): CliArgs {
triggerSubsync: false,
markAudioCard: false,
openRuntimeOptions: false,
anilistStatus: false,
anilistLogout: false,
anilistSetup: false,
anilistRetryQueue: false,
texthooker: false,
help: false,
autoStartOverlay: false,
@@ -109,6 +117,10 @@ export function parseArgs(argv: string[]): CliArgs {
else if (arg === "--trigger-subsync") args.triggerSubsync = true;
else if (arg === "--mark-audio-card") args.markAudioCard = 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 === "--auto-start-overlay") args.autoStartOverlay = true;
else if (arg === "--generate-config") args.generateConfig = true;
@@ -190,6 +202,10 @@ export function hasExplicitCommand(args: CliArgs): boolean {
args.triggerSubsync ||
args.markAudioCard ||
args.openRuntimeOptions ||
args.anilistStatus ||
args.anilistLogout ||
args.anilistSetup ||
args.anilistRetryQueue ||
args.texthooker ||
args.generateConfig ||
args.help

View File

@@ -18,4 +18,6 @@ test("printHelp includes configured texthooker port", () => {
assert.match(output, /--help\s+Show this help/);
assert.match(output, /default: 7777/);
assert.match(output, /--refresh-known-words/);
assert.match(output, /--anilist-status/);
assert.match(output, /--anilist-retry-queue/);
});

View File

@@ -25,6 +25,10 @@ SubMiner CLI commands:
--trigger-subsync Run subtitle sync
--mark-audio-card Mark last card as audio card
--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)
--socket PATH Override MPV IPC socket/pipe path
--backend BACKEND Override window tracker backend (auto, hyprland, sway, x11, macos)

View 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();
}
});

View 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");
});

View File

@@ -28,6 +28,10 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
markAudioCard: false,
refreshKnownWords: false,
openRuntimeOptions: false,
anilistStatus: false,
anilistLogout: false,
anilistSetup: false,
anilistRetryQueue: false,
texthooker: false,
help: false,
autoStartOverlay: false,
@@ -125,6 +129,35 @@ function createDeps(overrides: Partial<CliCommandServiceDeps> = {}) {
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: () => {
calls.push("printHelp");
},
@@ -281,6 +314,8 @@ test("handleCliCommand handles visibility and utility command dispatches", () =>
},
{ args: { toggleSecondarySub: true }, expected: "cycleSecondarySubMode" },
{ args: { openRuntimeOptions: true }, expected: "openRuntimeOptionsPalette" },
{ args: { anilistLogout: true }, expected: "clearAnilistToken" },
{ args: { anilistSetup: true }, expected: "openAnilistSetup" },
];
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", () => {
const { deps, calls } = createDeps();

View File

@@ -35,6 +35,28 @@ export interface CliCommandServiceDeps {
triggerSubsyncFromConfig: () => Promise<void>;
markLastCardAsAudioCard: () => Promise<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;
hasMainWindow: () => boolean;
getMultiCopyTimeoutMs: () => number;
@@ -97,6 +119,14 @@ interface UiCliRuntime {
printHelp: () => void;
}
interface AnilistCliRuntime {
getStatus: CliCommandServiceDeps["getAnilistStatus"];
clearToken: CliCommandServiceDeps["clearAnilistToken"];
openSetup: CliCommandServiceDeps["openAnilistSetup"];
getQueueStatus: CliCommandServiceDeps["getAnilistQueueStatus"];
retryQueueNow: CliCommandServiceDeps["retryAnilistQueue"];
}
interface AppCliRuntime {
stop: () => void;
hasMainWindow: () => boolean;
@@ -107,6 +137,7 @@ export interface CliCommandDepsRuntimeOptions {
texthooker: TexthookerCliRuntime;
overlay: OverlayCliRuntime;
mining: MiningCliRuntime;
anilist: AnilistCliRuntime;
ui: UiCliRuntime;
app: AppCliRuntime;
getMultiCopyTimeoutMs: () => number;
@@ -167,6 +198,11 @@ export function createCliCommandDepsRuntime(
triggerSubsyncFromConfig: options.mining.triggerSubsyncFromConfig,
markLastCardAsAudioCard: options.mining.markLastCardAsAudioCard,
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,
hasMainWindow: options.app.hasMainWindow,
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(
task: () => Promise<void>,
deps: CliCommandServiceDeps,
@@ -217,6 +258,10 @@ export function handleCliCommand(
args.triggerSubsync ||
args.markAudioCard ||
args.openRuntimeOptions ||
args.anilistStatus ||
args.anilistLogout ||
args.anilistSetup ||
args.anilistRetryQueue ||
args.texthooker ||
args.help;
const ignoreStartOnly = source === "second-instance" && args.start && !hasNonStartAction;
@@ -331,6 +376,47 @@ export function handleCliCommand(
);
} else if (args.openRuntimeOptions) {
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) {
const texthookerPort = deps.getTexthookerPort();
deps.ensureTexthookerRunning(texthookerPort);

View 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"]);
});

View File

@@ -31,6 +31,11 @@ export interface IpcServiceDeps {
setRuntimeOption: (id: string, value: unknown) => unknown;
cycleRuntimeOption: (id: string, direction: 1 | -1) => unknown;
reportOverlayContentBounds: (payload: unknown) => void;
getAnilistStatus: () => unknown;
clearAnilistToken: () => void;
openAnilistSetup: () => void;
getAnilistQueueStatus: () => unknown;
retryAnilistQueueNow: () => Promise<{ ok: boolean; message: string }>;
}
interface WindowLike {
@@ -82,6 +87,11 @@ export interface IpcDepsRuntimeOptions {
setRuntimeOption: (id: string, value: unknown) => unknown;
cycleRuntimeOption: (id: string, direction: 1 | -1) => unknown;
reportOverlayContentBounds: (payload: unknown) => void;
getAnilistStatus: () => unknown;
clearAnilistToken: () => void;
openAnilistSetup: () => void;
getAnilistQueueStatus: () => unknown;
retryAnilistQueueNow: () => Promise<{ ok: boolean; message: string }>;
}
export function createIpcDepsRuntime(
@@ -140,6 +150,11 @@ export function createIpcDepsRuntime(
setRuntimeOption: options.setRuntimeOption,
cycleRuntimeOption: options.cycleRuntimeOption,
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) => {
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();
});
}

View File

@@ -30,6 +30,10 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
triggerSubsync: false,
markAudioCard: false,
openRuntimeOptions: false,
anilistStatus: false,
anilistLogout: false,
anilistSetup: false,
anilistRetryQueue: false,
texthooker: false,
help: false,
autoStartOverlay: false,

View File

@@ -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 {
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();
refreshAnilistRetryQueueState();
if (!queued) {
return;
return { ok: true, message: "AniList queue has no ready items." };
}
appState.anilistRetryQueueState.lastAttemptAt = Date.now();
const accessToken = await refreshAnilistClientSecretState();
if (!accessToken) {
appState.anilistRetryQueueState.lastError = "AniList token unavailable for queued retry.";
return;
return { ok: false, message: appState.anilistRetryQueueState.lastError };
}
const result = await updateAnilistPostWatchProgress(
@@ -1095,12 +1136,13 @@ async function processNextAnilistRetryUpdate(): Promise<void> {
appState.anilistRetryQueueState.lastError = null;
refreshAnilistRetryQueueState();
logger.info(`[AniList queue] ${result.message}`);
return;
return { ok: true, message: result.message };
}
anilistUpdateQueue.markFailure(queued.key, result.message);
appState.anilistRetryQueueState.lastError = result.message;
refreshAnilistRetryQueueState();
return { ok: false, message: result.message };
}
async function maybeRunAnilistPostWatchUpdate(): Promise<void> {
@@ -1441,6 +1483,11 @@ function handleCliCommand(
triggerFieldGrouping: () => triggerFieldGrouping(),
triggerSubsyncFromConfig: () => triggerSubsyncFromConfig(),
markLastCardAsAudioCard: () => markLastCardAsAudioCard(),
getAnilistStatus: () => getAnilistStatusSnapshot(),
clearAnilistToken: () => clearAnilistTokenState(),
openAnilistSetup: () => openAnilistSetupWindow(),
getAnilistQueueStatus: () => getAnilistQueueStatusSnapshot(),
retryAnilistQueueNow: () => processNextAnilistRetryUpdate(),
openYomitanSettings: () => openYomitanSettings(),
cycleSecondarySubMode: () => cycleSecondarySubMode(),
openRuntimeOptionsPalette: () => openRuntimeOptionsPalette(),
@@ -2115,6 +2162,11 @@ registerIpcRuntimeServices({
reportOverlayContentBounds: (payload: unknown) => {
overlayContentMeasurementStore.report(payload);
},
getAnilistStatus: () => getAnilistStatusSnapshot(),
clearAnilistToken: () => clearAnilistTokenState(),
openAnilistSetup: () => openAnilistSetupWindow(),
getAnilistQueueStatus: () => getAnilistQueueStatusSnapshot(),
retryAnilistQueueNow: () => processNextAnilistRetryUpdate(),
},
ankiJimakuDeps: createAnkiJimakuIpcRuntimeServiceDeps({
patchAnkiConnectEnabled: (enabled: boolean) => {

View File

@@ -26,6 +26,11 @@ export interface CliCommandRuntimeServiceContext {
triggerFieldGrouping: () => Promise<void>;
triggerSubsyncFromConfig: () => 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;
cycleSecondarySubMode: () => void;
openRuntimeOptionsPalette: () => void;
@@ -71,13 +76,20 @@ function createCliCommandDepsFromContext(
mining: {
copyCurrentSubtitle: context.copyCurrentSubtitle,
startPendingMultiCopy: context.startPendingMultiCopy,
mineSentenceCard: context.mineSentenceCard,
startPendingMineSentenceMultiple: context.startPendingMineSentenceMultiple,
updateLastCardFromClipboard: context.updateLastCardFromClipboard,
refreshKnownWords: context.refreshKnownWordCache,
triggerFieldGrouping: context.triggerFieldGrouping,
triggerSubsyncFromConfig: context.triggerSubsyncFromConfig,
markLastCardAsAudioCard: context.markLastCardAsAudioCard,
mineSentenceCard: context.mineSentenceCard,
startPendingMineSentenceMultiple: context.startPendingMineSentenceMultiple,
updateLastCardFromClipboard: context.updateLastCardFromClipboard,
refreshKnownWords: context.refreshKnownWordCache,
triggerFieldGrouping: context.triggerFieldGrouping,
triggerSubsyncFromConfig: context.triggerSubsyncFromConfig,
markLastCardAsAudioCard: context.markLastCardAsAudioCard,
},
anilist: {
getStatus: context.getAnilistStatus,
clearToken: context.clearAnilistToken,
openSetup: context.openAnilistSetup,
getQueueStatus: context.getAnilistQueueStatus,
retryQueueNow: context.retryAnilistQueueNow,
},
ui: {
openYomitanSettings: context.openYomitanSettings,

View File

@@ -90,6 +90,11 @@ export interface MainIpcRuntimeServiceDepsParams {
setRuntimeOption: IpcDepsRuntimeOptions["setRuntimeOption"];
cycleRuntimeOption: IpcDepsRuntimeOptions["cycleRuntimeOption"];
reportOverlayContentBounds: IpcDepsRuntimeOptions["reportOverlayContentBounds"];
getAnilistStatus: IpcDepsRuntimeOptions["getAnilistStatus"];
clearAnilistToken: IpcDepsRuntimeOptions["clearAnilistToken"];
openAnilistSetup: IpcDepsRuntimeOptions["openAnilistSetup"];
getAnilistQueueStatus: IpcDepsRuntimeOptions["getAnilistQueueStatus"];
retryAnilistQueueNow: IpcDepsRuntimeOptions["retryAnilistQueueNow"];
}
export interface AnkiJimakuIpcRuntimeServiceDepsParams {
@@ -154,6 +159,13 @@ export interface CliCommandRuntimeServiceDepsParams {
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: {
openYomitanSettings: CliCommandDepsRuntimeOptions["ui"]["openYomitanSettings"];
cycleSecondarySubMode: CliCommandDepsRuntimeOptions["ui"]["cycleSecondarySubMode"];
@@ -216,6 +228,11 @@ export function createMainIpcRuntimeServiceDeps(
setRuntimeOption: params.setRuntimeOption,
cycleRuntimeOption: params.cycleRuntimeOption,
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,
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: {
openYomitanSettings: params.ui.openYomitanSettings,
cycleSecondarySubMode: params.ui.cycleSecondarySubMode,