mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-02-27 18:22:41 -08:00
- Add CLI command flag with non-GUI dispatch flow and OSD error handling. - Add runtime integration call and IPC hook so manual refresh works from command runner without app startup. - Add public AnkiIntegration manual refresh API with force refresh semantics and guard reuse. - Preserve default n+1 behavior by fixing config validation for malformed values and adding tests.
188 lines
6.9 KiB
TypeScript
188 lines
6.9 KiB
TypeScript
import { AnkiIntegration } from "../../anki-integration";
|
|
import {
|
|
AnkiConnectConfig,
|
|
JimakuApiResponse,
|
|
JimakuEntry,
|
|
JimakuFileEntry,
|
|
JimakuLanguagePreference,
|
|
JimakuMediaInfo,
|
|
KikuFieldGroupingChoice,
|
|
KikuFieldGroupingRequestData,
|
|
} from "../../types";
|
|
import { sortJimakuFiles } from "../../jimaku/utils";
|
|
import type { AnkiJimakuIpcDeps } from "./anki-jimaku-ipc-service";
|
|
|
|
export type RegisterAnkiJimakuIpcRuntimeHandler = (
|
|
deps: AnkiJimakuIpcDeps,
|
|
) => void;
|
|
|
|
interface MpvClientLike {
|
|
connected: boolean;
|
|
send: (payload: { command: string[] }) => void;
|
|
}
|
|
|
|
interface RuntimeOptionsManagerLike {
|
|
getEffectiveAnkiConnectConfig: (config?: AnkiConnectConfig) => AnkiConnectConfig;
|
|
}
|
|
|
|
interface SubtitleTimingTrackerLike {
|
|
cleanup: () => void;
|
|
}
|
|
|
|
export interface AnkiJimakuIpcRuntimeOptions {
|
|
patchAnkiConnectEnabled: (enabled: boolean) => void;
|
|
getResolvedConfig: () => { ankiConnect?: AnkiConnectConfig };
|
|
getRuntimeOptionsManager: () => RuntimeOptionsManagerLike | null;
|
|
getSubtitleTimingTracker: () => SubtitleTimingTrackerLike | null;
|
|
getMpvClient: () => MpvClientLike | null;
|
|
getAnkiIntegration: () => AnkiIntegration | null;
|
|
setAnkiIntegration: (integration: AnkiIntegration | null) => void;
|
|
getKnownWordCacheStatePath: () => string;
|
|
showDesktopNotification: (title: string, options: { body?: string; icon?: string }) => void;
|
|
createFieldGroupingCallback: () => (
|
|
data: KikuFieldGroupingRequestData,
|
|
) => Promise<KikuFieldGroupingChoice>;
|
|
broadcastRuntimeOptionsChanged: () => void;
|
|
getFieldGroupingResolver: () => ((choice: KikuFieldGroupingChoice) => void) | null;
|
|
setFieldGroupingResolver: (resolver: ((choice: KikuFieldGroupingChoice) => void) | null) => void;
|
|
parseMediaInfo: (mediaPath: string | null) => JimakuMediaInfo;
|
|
getCurrentMediaPath: () => string | null;
|
|
jimakuFetchJson: <T>(
|
|
endpoint: string,
|
|
query?: Record<string, string | number | boolean | null | undefined>,
|
|
) => Promise<JimakuApiResponse<T>>;
|
|
getJimakuMaxEntryResults: () => number;
|
|
getJimakuLanguagePreference: () => JimakuLanguagePreference;
|
|
resolveJimakuApiKey: () => Promise<string | null>;
|
|
isRemoteMediaPath: (mediaPath: string) => boolean;
|
|
downloadToFile: (
|
|
url: string,
|
|
destPath: string,
|
|
headers: Record<string, string>,
|
|
) => Promise<{ ok: true; path: string } | { ok: false; error: { error: string; code?: number; retryAfter?: number } }>;
|
|
}
|
|
|
|
export function registerAnkiJimakuIpcRuntimeService(
|
|
options: AnkiJimakuIpcRuntimeOptions,
|
|
registerHandlers: RegisterAnkiJimakuIpcRuntimeHandler,
|
|
): void {
|
|
registerHandlers({
|
|
setAnkiConnectEnabled: (enabled) => {
|
|
options.patchAnkiConnectEnabled(enabled);
|
|
const config = options.getResolvedConfig();
|
|
const subtitleTimingTracker = options.getSubtitleTimingTracker();
|
|
const mpvClient = options.getMpvClient();
|
|
const ankiIntegration = options.getAnkiIntegration();
|
|
|
|
if (enabled && !ankiIntegration && subtitleTimingTracker && mpvClient) {
|
|
const runtimeOptionsManager = options.getRuntimeOptionsManager();
|
|
const effectiveAnkiConfig = runtimeOptionsManager
|
|
? runtimeOptionsManager.getEffectiveAnkiConnectConfig(config.ankiConnect)
|
|
: config.ankiConnect;
|
|
const integration = new AnkiIntegration(
|
|
effectiveAnkiConfig as never,
|
|
subtitleTimingTracker as never,
|
|
mpvClient as never,
|
|
(text: string) => {
|
|
if (mpvClient) {
|
|
mpvClient.send({
|
|
command: ["show-text", text, "3000"],
|
|
});
|
|
}
|
|
},
|
|
options.showDesktopNotification,
|
|
options.createFieldGroupingCallback(),
|
|
options.getKnownWordCacheStatePath(),
|
|
);
|
|
integration.start();
|
|
options.setAnkiIntegration(integration);
|
|
console.log("AnkiConnect integration enabled");
|
|
} else if (!enabled && ankiIntegration) {
|
|
ankiIntegration.destroy();
|
|
options.setAnkiIntegration(null);
|
|
console.log("AnkiConnect integration disabled");
|
|
}
|
|
|
|
options.broadcastRuntimeOptionsChanged();
|
|
},
|
|
clearAnkiHistory: () => {
|
|
const subtitleTimingTracker = options.getSubtitleTimingTracker();
|
|
if (subtitleTimingTracker) {
|
|
subtitleTimingTracker.cleanup();
|
|
console.log("AnkiConnect subtitle timing history cleared");
|
|
}
|
|
},
|
|
refreshKnownWords: async () => {
|
|
const integration = options.getAnkiIntegration();
|
|
if (!integration) {
|
|
throw new Error("AnkiConnect integration not enabled");
|
|
}
|
|
await integration.refreshKnownWordCache();
|
|
},
|
|
respondFieldGrouping: (choice) => {
|
|
const resolver = options.getFieldGroupingResolver();
|
|
if (resolver) {
|
|
resolver(choice);
|
|
options.setFieldGroupingResolver(null);
|
|
}
|
|
},
|
|
buildKikuMergePreview: async (request) => {
|
|
const integration = options.getAnkiIntegration();
|
|
if (!integration) {
|
|
return { ok: false, error: "AnkiConnect integration not enabled" };
|
|
}
|
|
return integration.buildFieldGroupingPreview(
|
|
request.keepNoteId,
|
|
request.deleteNoteId,
|
|
request.deleteDuplicate,
|
|
);
|
|
},
|
|
getJimakuMediaInfo: () => options.parseMediaInfo(options.getCurrentMediaPath()),
|
|
searchJimakuEntries: async (query) => {
|
|
console.log(`[jimaku] search-entries query: "${query.query}"`);
|
|
const response = await options.jimakuFetchJson<JimakuEntry[]>(
|
|
"/api/entries/search",
|
|
{
|
|
anime: true,
|
|
query: query.query,
|
|
},
|
|
);
|
|
if (!response.ok) return response;
|
|
const maxResults = options.getJimakuMaxEntryResults();
|
|
console.log(
|
|
`[jimaku] search-entries returned ${response.data.length} results (capped to ${maxResults})`,
|
|
);
|
|
return { ok: true, data: response.data.slice(0, maxResults) };
|
|
},
|
|
listJimakuFiles: async (query) => {
|
|
console.log(
|
|
`[jimaku] list-files entryId=${query.entryId} episode=${query.episode ?? "all"}`,
|
|
);
|
|
const response = await options.jimakuFetchJson<JimakuFileEntry[]>(
|
|
`/api/entries/${query.entryId}/files`,
|
|
{
|
|
episode: query.episode ?? undefined,
|
|
},
|
|
);
|
|
if (!response.ok) return response;
|
|
const sorted = sortJimakuFiles(
|
|
response.data,
|
|
options.getJimakuLanguagePreference(),
|
|
);
|
|
console.log(`[jimaku] list-files returned ${sorted.length} files`);
|
|
return { ok: true, data: sorted };
|
|
},
|
|
resolveJimakuApiKey: () => options.resolveJimakuApiKey(),
|
|
getCurrentMediaPath: () => options.getCurrentMediaPath(),
|
|
isRemoteMediaPath: (mediaPath) => options.isRemoteMediaPath(mediaPath),
|
|
downloadToFile: (url, destPath, headers) =>
|
|
options.downloadToFile(url, destPath, headers),
|
|
onDownloadedSubtitle: (pathToSubtitle) => {
|
|
const mpvClient = options.getMpvClient();
|
|
if (mpvClient && mpvClient.connected) {
|
|
mpvClient.send({ command: ["sub-add", pathToSubtitle, "select"] });
|
|
}
|
|
},
|
|
});
|
|
}
|