mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-02-27 18:22:41 -08:00
feat(core): add module scaffolding and provider registries
This commit is contained in:
21
src/core/action-bus.ts
Normal file
21
src/core/action-bus.ts
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
export type ActionWithType = { type: string };
|
||||||
|
|
||||||
|
export type ActionHandler<TAction extends ActionWithType> = (
|
||||||
|
action: TAction,
|
||||||
|
) => void | Promise<void>;
|
||||||
|
|
||||||
|
export class ActionBus<TAction extends ActionWithType> {
|
||||||
|
private handlers = new Map<string, ActionHandler<TAction>>();
|
||||||
|
|
||||||
|
register(type: TAction["type"], handler: ActionHandler<TAction>): void {
|
||||||
|
this.handlers.set(type, handler);
|
||||||
|
}
|
||||||
|
|
||||||
|
async dispatch(action: TAction): Promise<void> {
|
||||||
|
const handler = this.handlers.get(action.type);
|
||||||
|
if (!handler) {
|
||||||
|
throw new Error(`No handler registered for action: ${action.type}`);
|
||||||
|
}
|
||||||
|
await handler(action);
|
||||||
|
}
|
||||||
|
}
|
||||||
16
src/core/actions.ts
Normal file
16
src/core/actions.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
export type AppAction =
|
||||||
|
| { type: "overlay.toggleVisible" }
|
||||||
|
| { type: "overlay.toggleInvisible" }
|
||||||
|
| { type: "overlay.setVisible"; visible: boolean }
|
||||||
|
| { type: "overlay.setInvisibleVisible"; visible: boolean }
|
||||||
|
| { type: "overlay.openSettings" }
|
||||||
|
| { type: "subtitle.copyCurrent" }
|
||||||
|
| { type: "subtitle.copyMultiplePrompt"; timeoutMs: number }
|
||||||
|
| { type: "anki.mineSentence" }
|
||||||
|
| { type: "anki.mineSentenceMultiplePrompt"; timeoutMs: number }
|
||||||
|
| { type: "anki.updateLastCardFromClipboard" }
|
||||||
|
| { type: "anki.markAudioCard" }
|
||||||
|
| { type: "kiku.triggerFieldGrouping" }
|
||||||
|
| { type: "subsync.triggerFromConfig" }
|
||||||
|
| { type: "secondarySub.toggleMode" }
|
||||||
|
| { type: "runtimeOptions.openPalette" };
|
||||||
45
src/core/app-context.ts
Normal file
45
src/core/app-context.ts
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
import {
|
||||||
|
AnkiConnectConfig,
|
||||||
|
JimakuApiResponse,
|
||||||
|
JimakuDownloadQuery,
|
||||||
|
JimakuDownloadResult,
|
||||||
|
JimakuEntry,
|
||||||
|
JimakuFileEntry,
|
||||||
|
JimakuFilesQuery,
|
||||||
|
JimakuMediaInfo,
|
||||||
|
JimakuSearchQuery,
|
||||||
|
RuntimeOptionState,
|
||||||
|
SubsyncManualRunRequest,
|
||||||
|
SubsyncMode,
|
||||||
|
SubsyncResult,
|
||||||
|
} from "../types";
|
||||||
|
|
||||||
|
export interface RuntimeOptionsModuleContext {
|
||||||
|
getAnkiConfig: () => AnkiConnectConfig;
|
||||||
|
applyAnkiPatch: (patch: Partial<AnkiConnectConfig>) => void;
|
||||||
|
onOptionsChanged: (options: RuntimeOptionState[]) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AppContext {
|
||||||
|
runtimeOptions?: RuntimeOptionsModuleContext;
|
||||||
|
jimaku?: {
|
||||||
|
getMediaInfo: () => JimakuMediaInfo;
|
||||||
|
searchEntries: (
|
||||||
|
query: JimakuSearchQuery,
|
||||||
|
) => Promise<JimakuApiResponse<JimakuEntry[]>>;
|
||||||
|
listFiles: (
|
||||||
|
query: JimakuFilesQuery,
|
||||||
|
) => Promise<JimakuApiResponse<JimakuFileEntry[]>>;
|
||||||
|
downloadFile: (
|
||||||
|
query: JimakuDownloadQuery,
|
||||||
|
) => Promise<JimakuDownloadResult>;
|
||||||
|
};
|
||||||
|
subsync?: {
|
||||||
|
getDefaultMode: () => SubsyncMode;
|
||||||
|
openManualPicker: () => Promise<void>;
|
||||||
|
runAuto: () => Promise<SubsyncResult>;
|
||||||
|
runManual: (request: SubsyncManualRunRequest) => Promise<SubsyncResult>;
|
||||||
|
showOsd: (message: string) => void;
|
||||||
|
runWithSpinner: <T>(task: () => Promise<T>, label?: string) => Promise<T>;
|
||||||
|
};
|
||||||
|
}
|
||||||
36
src/core/module-registry.ts
Normal file
36
src/core/module-registry.ts
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import { SubminerModule } from "./module";
|
||||||
|
|
||||||
|
export class ModuleRegistry<TContext = unknown> {
|
||||||
|
private readonly modules: SubminerModule<TContext>[] = [];
|
||||||
|
|
||||||
|
register(module: SubminerModule<TContext>): void {
|
||||||
|
if (this.modules.some((existing) => existing.id === module.id)) {
|
||||||
|
throw new Error(`Module already registered: ${module.id}`);
|
||||||
|
}
|
||||||
|
this.modules.push(module);
|
||||||
|
}
|
||||||
|
|
||||||
|
async initAll(context: TContext): Promise<void> {
|
||||||
|
for (const module of this.modules) {
|
||||||
|
if (module.init) {
|
||||||
|
await module.init(context);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async startAll(): Promise<void> {
|
||||||
|
for (const module of this.modules) {
|
||||||
|
if (module.start) {
|
||||||
|
await module.start();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async stopAll(): Promise<void> {
|
||||||
|
for (const module of [...this.modules].reverse()) {
|
||||||
|
if (module.stop) {
|
||||||
|
await module.stop();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
6
src/core/module.ts
Normal file
6
src/core/module.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
export interface SubminerModule<TContext = unknown> {
|
||||||
|
id: string;
|
||||||
|
init?: (context: TContext) => void | Promise<void>;
|
||||||
|
start?: () => void | Promise<void>;
|
||||||
|
stop?: () => void | Promise<void>;
|
||||||
|
}
|
||||||
61
src/ipc/contract.ts
Normal file
61
src/ipc/contract.ts
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
export const IPC_CHANNELS = {
|
||||||
|
rendererToMainInvoke: {
|
||||||
|
getOverlayVisibility: "get-overlay-visibility",
|
||||||
|
getVisibleOverlayVisibility: "get-visible-overlay-visibility",
|
||||||
|
getInvisibleOverlayVisibility: "get-invisible-overlay-visibility",
|
||||||
|
getCurrentSubtitle: "get-current-subtitle",
|
||||||
|
getCurrentSubtitleAss: "get-current-subtitle-ass",
|
||||||
|
getMpvSubtitleRenderMetrics: "get-mpv-subtitle-render-metrics",
|
||||||
|
getSubtitlePosition: "get-subtitle-position",
|
||||||
|
getSubtitleStyle: "get-subtitle-style",
|
||||||
|
getMecabStatus: "get-mecab-status",
|
||||||
|
getKeybindings: "get-keybindings",
|
||||||
|
getSecondarySubMode: "get-secondary-sub-mode",
|
||||||
|
getCurrentSecondarySub: "get-current-secondary-sub",
|
||||||
|
runSubsyncManual: "subsync:run-manual",
|
||||||
|
getAnkiConnectStatus: "get-anki-connect-status",
|
||||||
|
runtimeOptionsGet: "runtime-options:get",
|
||||||
|
runtimeOptionsSet: "runtime-options:set",
|
||||||
|
runtimeOptionsCycle: "runtime-options:cycle",
|
||||||
|
kikuBuildMergePreview: "kiku:build-merge-preview",
|
||||||
|
jimakuGetMediaInfo: "jimaku:get-media-info",
|
||||||
|
jimakuSearchEntries: "jimaku:search-entries",
|
||||||
|
jimakuListFiles: "jimaku:list-files",
|
||||||
|
jimakuDownloadFile: "jimaku:download-file",
|
||||||
|
},
|
||||||
|
rendererToMainSend: {
|
||||||
|
setIgnoreMouseEvents: "set-ignore-mouse-events",
|
||||||
|
overlayModalClosed: "overlay:modal-closed",
|
||||||
|
openYomitanSettings: "open-yomitan-settings",
|
||||||
|
quitApp: "quit-app",
|
||||||
|
toggleDevTools: "toggle-dev-tools",
|
||||||
|
toggleOverlay: "toggle-overlay",
|
||||||
|
saveSubtitlePosition: "save-subtitle-position",
|
||||||
|
setMecabEnabled: "set-mecab-enabled",
|
||||||
|
mpvCommand: "mpv-command",
|
||||||
|
setAnkiConnectEnabled: "set-anki-connect-enabled",
|
||||||
|
clearAnkiConnectHistory: "clear-anki-connect-history",
|
||||||
|
kikuFieldGroupingRespond: "kiku:field-grouping-respond",
|
||||||
|
},
|
||||||
|
mainToRendererEvent: {
|
||||||
|
subtitleSet: "subtitle:set",
|
||||||
|
mpvSubVisibility: "mpv:subVisibility",
|
||||||
|
subtitlePositionSet: "subtitle-position:set",
|
||||||
|
mpvSubtitleRenderMetricsSet: "mpv-subtitle-render-metrics:set",
|
||||||
|
subtitleAssSet: "subtitle-ass:set",
|
||||||
|
overlayDebugVisualizationSet: "overlay-debug-visualization:set",
|
||||||
|
secondarySubtitleSet: "secondary-subtitle:set",
|
||||||
|
secondarySubtitleMode: "secondary-subtitle:mode",
|
||||||
|
subsyncOpenManual: "subsync:open-manual",
|
||||||
|
kikuFieldGroupingRequest: "kiku:field-grouping-request",
|
||||||
|
runtimeOptionsChanged: "runtime-options:changed",
|
||||||
|
runtimeOptionsOpen: "runtime-options:open",
|
||||||
|
},
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export type RendererToMainInvokeChannel =
|
||||||
|
(typeof IPC_CHANNELS.rendererToMainInvoke)[keyof typeof IPC_CHANNELS.rendererToMainInvoke];
|
||||||
|
export type RendererToMainSendChannel =
|
||||||
|
(typeof IPC_CHANNELS.rendererToMainSend)[keyof typeof IPC_CHANNELS.rendererToMainSend];
|
||||||
|
export type MainToRendererEventChannel =
|
||||||
|
(typeof IPC_CHANNELS.mainToRendererEvent)[keyof typeof IPC_CHANNELS.mainToRendererEvent];
|
||||||
19
src/ipc/main-api.ts
Normal file
19
src/ipc/main-api.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import { ipcMain, IpcMainEvent } from "electron";
|
||||||
|
import {
|
||||||
|
RendererToMainInvokeChannel,
|
||||||
|
RendererToMainSendChannel,
|
||||||
|
} from "./contract";
|
||||||
|
|
||||||
|
export function onRendererSend(
|
||||||
|
channel: RendererToMainSendChannel,
|
||||||
|
listener: (event: IpcMainEvent, ...args: any[]) => void,
|
||||||
|
): void {
|
||||||
|
ipcMain.on(channel, listener);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function handleRendererInvoke(
|
||||||
|
channel: RendererToMainInvokeChannel,
|
||||||
|
handler: (event: Electron.IpcMainInvokeEvent, ...args: any[]) => unknown,
|
||||||
|
): void {
|
||||||
|
ipcMain.handle(channel, handler);
|
||||||
|
}
|
||||||
27
src/ipc/renderer-api.ts
Normal file
27
src/ipc/renderer-api.ts
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import { ipcRenderer, IpcRendererEvent } from "electron";
|
||||||
|
import {
|
||||||
|
MainToRendererEventChannel,
|
||||||
|
RendererToMainInvokeChannel,
|
||||||
|
RendererToMainSendChannel,
|
||||||
|
} from "./contract";
|
||||||
|
|
||||||
|
export function invokeFromRenderer<T>(
|
||||||
|
channel: RendererToMainInvokeChannel,
|
||||||
|
...args: unknown[]
|
||||||
|
): Promise<T> {
|
||||||
|
return ipcRenderer.invoke(channel, ...args) as Promise<T>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sendFromRenderer(
|
||||||
|
channel: RendererToMainSendChannel,
|
||||||
|
...args: unknown[]
|
||||||
|
): void {
|
||||||
|
ipcRenderer.send(channel, ...args);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function onMainEvent(
|
||||||
|
channel: MainToRendererEventChannel,
|
||||||
|
listener: (event: IpcRendererEvent, ...args: unknown[]) => void,
|
||||||
|
): void {
|
||||||
|
ipcRenderer.on(channel, listener);
|
||||||
|
}
|
||||||
72
src/modules/jimaku/module.ts
Normal file
72
src/modules/jimaku/module.ts
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
import { AppContext } from "../../core/app-context";
|
||||||
|
import { SubminerModule } from "../../core/module";
|
||||||
|
import {
|
||||||
|
JimakuApiResponse,
|
||||||
|
JimakuDownloadQuery,
|
||||||
|
JimakuDownloadResult,
|
||||||
|
JimakuEntry,
|
||||||
|
JimakuFileEntry,
|
||||||
|
JimakuFilesQuery,
|
||||||
|
JimakuMediaInfo,
|
||||||
|
JimakuSearchQuery,
|
||||||
|
} from "../../types";
|
||||||
|
|
||||||
|
export class JimakuModule implements SubminerModule<AppContext> {
|
||||||
|
readonly id = "jimaku";
|
||||||
|
private context: AppContext["jimaku"] | undefined;
|
||||||
|
|
||||||
|
init(context: AppContext): void {
|
||||||
|
if (!context.jimaku) {
|
||||||
|
throw new Error("Jimaku context is missing");
|
||||||
|
}
|
||||||
|
this.context = context.jimaku;
|
||||||
|
}
|
||||||
|
|
||||||
|
getMediaInfo(): JimakuMediaInfo {
|
||||||
|
if (!this.context) {
|
||||||
|
return {
|
||||||
|
title: "",
|
||||||
|
season: null,
|
||||||
|
episode: null,
|
||||||
|
confidence: "low",
|
||||||
|
filename: "",
|
||||||
|
rawTitle: "",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return this.context.getMediaInfo();
|
||||||
|
}
|
||||||
|
|
||||||
|
searchEntries(
|
||||||
|
query: JimakuSearchQuery,
|
||||||
|
): Promise<JimakuApiResponse<JimakuEntry[]>> {
|
||||||
|
if (!this.context) {
|
||||||
|
return Promise.resolve({
|
||||||
|
ok: false,
|
||||||
|
error: { error: "Jimaku module not initialized" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return this.context.searchEntries(query);
|
||||||
|
}
|
||||||
|
|
||||||
|
listFiles(
|
||||||
|
query: JimakuFilesQuery,
|
||||||
|
): Promise<JimakuApiResponse<JimakuFileEntry[]>> {
|
||||||
|
if (!this.context) {
|
||||||
|
return Promise.resolve({
|
||||||
|
ok: false,
|
||||||
|
error: { error: "Jimaku module not initialized" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return this.context.listFiles(query);
|
||||||
|
}
|
||||||
|
|
||||||
|
downloadFile(query: JimakuDownloadQuery): Promise<JimakuDownloadResult> {
|
||||||
|
if (!this.context) {
|
||||||
|
return Promise.resolve({
|
||||||
|
ok: false,
|
||||||
|
error: { error: "Jimaku module not initialized" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return this.context.downloadFile(query);
|
||||||
|
}
|
||||||
|
}
|
||||||
61
src/modules/runtime-options/module.ts
Normal file
61
src/modules/runtime-options/module.ts
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
import { AppContext } from "../../core/app-context";
|
||||||
|
import { SubminerModule } from "../../core/module";
|
||||||
|
import { RuntimeOptionsManager } from "../../runtime-options";
|
||||||
|
import {
|
||||||
|
AnkiConnectConfig,
|
||||||
|
RuntimeOptionApplyResult,
|
||||||
|
RuntimeOptionId,
|
||||||
|
RuntimeOptionState,
|
||||||
|
RuntimeOptionValue,
|
||||||
|
} from "../../types";
|
||||||
|
|
||||||
|
export class RuntimeOptionsModule implements SubminerModule<AppContext> {
|
||||||
|
readonly id = "runtime-options";
|
||||||
|
private manager: RuntimeOptionsManager | null = null;
|
||||||
|
|
||||||
|
init(context: AppContext): void {
|
||||||
|
if (!context.runtimeOptions) {
|
||||||
|
throw new Error("Runtime options context is missing");
|
||||||
|
}
|
||||||
|
|
||||||
|
this.manager = new RuntimeOptionsManager(
|
||||||
|
context.runtimeOptions.getAnkiConfig,
|
||||||
|
{
|
||||||
|
applyAnkiPatch: context.runtimeOptions.applyAnkiPatch,
|
||||||
|
onOptionsChanged: context.runtimeOptions.onOptionsChanged,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
listOptions(): RuntimeOptionState[] {
|
||||||
|
return this.manager ? this.manager.listOptions() : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
getOptionValue(id: RuntimeOptionId): RuntimeOptionValue | undefined {
|
||||||
|
return this.manager?.getOptionValue(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
setOptionValue(
|
||||||
|
id: RuntimeOptionId,
|
||||||
|
value: RuntimeOptionValue,
|
||||||
|
): RuntimeOptionApplyResult {
|
||||||
|
if (!this.manager) {
|
||||||
|
return { ok: false, error: "Runtime options manager unavailable" };
|
||||||
|
}
|
||||||
|
return this.manager.setOptionValue(id, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
cycleOption(id: RuntimeOptionId, direction: 1 | -1): RuntimeOptionApplyResult {
|
||||||
|
if (!this.manager) {
|
||||||
|
return { ok: false, error: "Runtime options manager unavailable" };
|
||||||
|
}
|
||||||
|
return this.manager.cycleOption(id, direction);
|
||||||
|
}
|
||||||
|
|
||||||
|
getEffectiveAnkiConnectConfig(baseConfig?: AnkiConnectConfig): AnkiConnectConfig {
|
||||||
|
if (!this.manager) {
|
||||||
|
return baseConfig ? JSON.parse(JSON.stringify(baseConfig)) : {};
|
||||||
|
}
|
||||||
|
return this.manager.getEffectiveAnkiConnectConfig(baseConfig);
|
||||||
|
}
|
||||||
|
}
|
||||||
78
src/modules/subsync/module.ts
Normal file
78
src/modules/subsync/module.ts
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
import { AppContext } from "../../core/app-context";
|
||||||
|
import { SubminerModule } from "../../core/module";
|
||||||
|
import { SubsyncManualRunRequest, SubsyncResult } from "../../types";
|
||||||
|
|
||||||
|
export class SubsyncModule implements SubminerModule<AppContext> {
|
||||||
|
readonly id = "subsync";
|
||||||
|
private inProgress = false;
|
||||||
|
private context: AppContext["subsync"] | undefined;
|
||||||
|
|
||||||
|
init(context: AppContext): void {
|
||||||
|
if (!context.subsync) {
|
||||||
|
throw new Error("Subsync context is missing");
|
||||||
|
}
|
||||||
|
this.context = context.subsync;
|
||||||
|
}
|
||||||
|
|
||||||
|
isInProgress(): boolean {
|
||||||
|
return this.inProgress;
|
||||||
|
}
|
||||||
|
|
||||||
|
async triggerFromConfig(): Promise<void> {
|
||||||
|
if (!this.context) {
|
||||||
|
throw new Error("Subsync module not initialized");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.inProgress) {
|
||||||
|
this.context.showOsd("Subsync already running");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (this.context.getDefaultMode() === "manual") {
|
||||||
|
await this.context.openManualPicker();
|
||||||
|
this.context.showOsd("Subsync: choose engine and source");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.inProgress = true;
|
||||||
|
const result = await this.context.runWithSpinner(
|
||||||
|
() => this.context!.runAuto(),
|
||||||
|
"Subsync: syncing",
|
||||||
|
);
|
||||||
|
this.context.showOsd(result.message);
|
||||||
|
} catch (error) {
|
||||||
|
this.context.showOsd(`Subsync failed: ${(error as Error).message}`);
|
||||||
|
} finally {
|
||||||
|
this.inProgress = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async runManual(request: SubsyncManualRunRequest): Promise<SubsyncResult> {
|
||||||
|
if (!this.context) {
|
||||||
|
return { ok: false, message: "Subsync module not initialized" };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.inProgress) {
|
||||||
|
const busy = "Subsync already running";
|
||||||
|
this.context.showOsd(busy);
|
||||||
|
return { ok: false, message: busy };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
this.inProgress = true;
|
||||||
|
const result = await this.context.runWithSpinner(
|
||||||
|
() => this.context!.runManual(request),
|
||||||
|
"Subsync: syncing",
|
||||||
|
);
|
||||||
|
this.context.showOsd(result.message);
|
||||||
|
return result;
|
||||||
|
} catch (error) {
|
||||||
|
const message = `Subsync failed: ${(error as Error).message}`;
|
||||||
|
this.context.showOsd(message);
|
||||||
|
return { ok: false, message };
|
||||||
|
} finally {
|
||||||
|
this.inProgress = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
95
src/subsync/engines.ts
Normal file
95
src/subsync/engines.ts
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
export type SubsyncEngine = "alass" | "ffsubsync";
|
||||||
|
|
||||||
|
export interface SubsyncCommandResult {
|
||||||
|
ok: boolean;
|
||||||
|
code: number | null;
|
||||||
|
stderr: string;
|
||||||
|
stdout: string;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SubsyncEngineExecutionContext {
|
||||||
|
referenceFilePath: string;
|
||||||
|
videoPath: string;
|
||||||
|
inputSubtitlePath: string;
|
||||||
|
outputPath: string;
|
||||||
|
audioStreamIndex: number | null;
|
||||||
|
resolveExecutablePath: (
|
||||||
|
configuredPath: string,
|
||||||
|
commandName: string,
|
||||||
|
) => string;
|
||||||
|
resolvedPaths: {
|
||||||
|
alassPath: string;
|
||||||
|
ffsubsyncPath: string;
|
||||||
|
};
|
||||||
|
runCommand: (command: string, args: string[]) => Promise<SubsyncCommandResult>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SubsyncEngineProvider {
|
||||||
|
engine: SubsyncEngine;
|
||||||
|
execute: (
|
||||||
|
context: SubsyncEngineExecutionContext,
|
||||||
|
) => Promise<SubsyncCommandResult>;
|
||||||
|
}
|
||||||
|
|
||||||
|
type SubsyncEngineProviderFactory = () => SubsyncEngineProvider;
|
||||||
|
|
||||||
|
const subsyncEngineProviderFactories = new Map<SubsyncEngine, SubsyncEngineProviderFactory>();
|
||||||
|
|
||||||
|
export function registerSubsyncEngineProvider(
|
||||||
|
engine: SubsyncEngine,
|
||||||
|
factory: SubsyncEngineProviderFactory,
|
||||||
|
): void {
|
||||||
|
if (subsyncEngineProviderFactories.has(engine)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
subsyncEngineProviderFactories.set(engine, factory);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createSubsyncEngineProvider(
|
||||||
|
engine: SubsyncEngine,
|
||||||
|
): SubsyncEngineProvider | null {
|
||||||
|
const factory = subsyncEngineProviderFactories.get(engine);
|
||||||
|
if (!factory) return null;
|
||||||
|
return factory();
|
||||||
|
}
|
||||||
|
|
||||||
|
function registerDefaultSubsyncEngineProviders(): void {
|
||||||
|
registerSubsyncEngineProvider("alass", () => ({
|
||||||
|
engine: "alass",
|
||||||
|
execute: async (context: SubsyncEngineExecutionContext) => {
|
||||||
|
const alassPath = context.resolveExecutablePath(
|
||||||
|
context.resolvedPaths.alassPath,
|
||||||
|
"alass",
|
||||||
|
);
|
||||||
|
return context.runCommand(alassPath, [
|
||||||
|
context.referenceFilePath,
|
||||||
|
context.inputSubtitlePath,
|
||||||
|
context.outputPath,
|
||||||
|
]);
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
registerSubsyncEngineProvider("ffsubsync", () => ({
|
||||||
|
engine: "ffsubsync",
|
||||||
|
execute: async (context: SubsyncEngineExecutionContext) => {
|
||||||
|
const ffsubsyncPath = context.resolveExecutablePath(
|
||||||
|
context.resolvedPaths.ffsubsyncPath,
|
||||||
|
"ffsubsync",
|
||||||
|
);
|
||||||
|
const args = [
|
||||||
|
context.videoPath,
|
||||||
|
"-i",
|
||||||
|
context.inputSubtitlePath,
|
||||||
|
"-o",
|
||||||
|
context.outputPath,
|
||||||
|
];
|
||||||
|
if (context.audioStreamIndex !== null) {
|
||||||
|
args.push("--reference-stream", `0:${context.audioStreamIndex}`);
|
||||||
|
}
|
||||||
|
return context.runCommand(ffsubsyncPath, args);
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
registerDefaultSubsyncEngineProviders();
|
||||||
46
src/subtitle/pipeline.ts
Normal file
46
src/subtitle/pipeline.ts
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
import { TokenMergerProvider } from "../token-mergers";
|
||||||
|
import { TokenizerProvider } from "../tokenizers";
|
||||||
|
import { SubtitleData } from "../types";
|
||||||
|
import {
|
||||||
|
normalizeDisplayText,
|
||||||
|
normalizeTokenizerInput,
|
||||||
|
} from "./stages/normalize";
|
||||||
|
import { tokenizeStage } from "./stages/tokenize";
|
||||||
|
import { mergeStage } from "./stages/merge";
|
||||||
|
|
||||||
|
export interface SubtitlePipelineDeps {
|
||||||
|
getTokenizer: () => TokenizerProvider | null;
|
||||||
|
getTokenMerger: () => TokenMergerProvider | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class SubtitlePipeline {
|
||||||
|
private readonly deps: SubtitlePipelineDeps;
|
||||||
|
|
||||||
|
constructor(deps: SubtitlePipelineDeps) {
|
||||||
|
this.deps = deps;
|
||||||
|
}
|
||||||
|
|
||||||
|
async process(text: string): Promise<SubtitleData> {
|
||||||
|
if (!text) {
|
||||||
|
return { text, tokens: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
const displayText = normalizeDisplayText(text);
|
||||||
|
if (!displayText) {
|
||||||
|
return { text, tokens: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
const tokenizeText = normalizeTokenizerInput(displayText);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const tokens = await tokenizeStage(this.deps.getTokenizer(), tokenizeText);
|
||||||
|
const mergedTokens = mergeStage(this.deps.getTokenMerger(), tokens);
|
||||||
|
if (!mergedTokens || mergedTokens.length === 0) {
|
||||||
|
return { text: displayText, tokens: null };
|
||||||
|
}
|
||||||
|
return { text: displayText, tokens: mergedTokens };
|
||||||
|
} catch {
|
||||||
|
return { text: displayText, tokens: null };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
12
src/subtitle/stages/merge.ts
Normal file
12
src/subtitle/stages/merge.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import { TokenMergerProvider } from "../../token-mergers";
|
||||||
|
import { MergedToken, Token } from "../../types";
|
||||||
|
|
||||||
|
export function mergeStage(
|
||||||
|
mergerProvider: TokenMergerProvider | null,
|
||||||
|
tokens: Token[] | null,
|
||||||
|
): MergedToken[] | null {
|
||||||
|
if (!mergerProvider || !tokens || tokens.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return mergerProvider.merge(tokens);
|
||||||
|
}
|
||||||
14
src/subtitle/stages/normalize.ts
Normal file
14
src/subtitle/stages/normalize.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
export function normalizeDisplayText(text: string): string {
|
||||||
|
return text
|
||||||
|
.replace(/\r\n/g, "\n")
|
||||||
|
.replace(/\\N/g, "\n")
|
||||||
|
.replace(/\\n/g, "\n")
|
||||||
|
.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeTokenizerInput(displayText: string): string {
|
||||||
|
return displayText
|
||||||
|
.replace(/\n/g, " ")
|
||||||
|
.replace(/\s+/g, " ")
|
||||||
|
.trim();
|
||||||
|
}
|
||||||
12
src/subtitle/stages/tokenize.ts
Normal file
12
src/subtitle/stages/tokenize.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import { TokenizerProvider } from "../../tokenizers";
|
||||||
|
import { Token } from "../../types";
|
||||||
|
|
||||||
|
export async function tokenizeStage(
|
||||||
|
tokenizerProvider: TokenizerProvider | null,
|
||||||
|
input: string,
|
||||||
|
): Promise<Token[] | null> {
|
||||||
|
if (!tokenizerProvider || !input) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return tokenizerProvider.tokenize(input);
|
||||||
|
}
|
||||||
42
src/token-mergers/index.ts
Normal file
42
src/token-mergers/index.ts
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
import { mergeTokens as defaultMergeTokens } from "../token-merger";
|
||||||
|
import { MergedToken, Token } from "../types";
|
||||||
|
|
||||||
|
export interface TokenMergerProvider {
|
||||||
|
id: string;
|
||||||
|
merge: (tokens: Token[]) => MergedToken[];
|
||||||
|
}
|
||||||
|
|
||||||
|
type TokenMergerProviderFactory = () => TokenMergerProvider;
|
||||||
|
|
||||||
|
const tokenMergerProviderFactories = new Map<string, TokenMergerProviderFactory>();
|
||||||
|
|
||||||
|
export function registerTokenMergerProvider(
|
||||||
|
id: string,
|
||||||
|
factory: TokenMergerProviderFactory,
|
||||||
|
): void {
|
||||||
|
if (tokenMergerProviderFactories.has(id)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
tokenMergerProviderFactories.set(id, factory);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getRegisteredTokenMergerProviderIds(): string[] {
|
||||||
|
return Array.from(tokenMergerProviderFactories.keys());
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createTokenMergerProvider(
|
||||||
|
id = "default",
|
||||||
|
): TokenMergerProvider | null {
|
||||||
|
const factory = tokenMergerProviderFactories.get(id);
|
||||||
|
if (!factory) return null;
|
||||||
|
return factory();
|
||||||
|
}
|
||||||
|
|
||||||
|
function registerDefaultTokenMergerProviders(): void {
|
||||||
|
registerTokenMergerProvider("default", () => ({
|
||||||
|
id: "default",
|
||||||
|
merge: (tokens: Token[]) => defaultMergeTokens(tokens),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
registerDefaultTokenMergerProviders();
|
||||||
53
src/tokenizers/index.ts
Normal file
53
src/tokenizers/index.ts
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
import { MecabTokenizer } from "../mecab-tokenizer";
|
||||||
|
import { MecabStatus, Token } from "../types";
|
||||||
|
|
||||||
|
export interface TokenizerProvider {
|
||||||
|
id: string;
|
||||||
|
checkAvailability: () => Promise<boolean>;
|
||||||
|
tokenize: (text: string) => Promise<Token[] | null>;
|
||||||
|
getStatus: () => MecabStatus;
|
||||||
|
setEnabled: (enabled: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
type TokenizerProviderFactory = () => TokenizerProvider;
|
||||||
|
|
||||||
|
const tokenizerProviderFactories = new Map<string, TokenizerProviderFactory>();
|
||||||
|
|
||||||
|
export function registerTokenizerProvider(
|
||||||
|
id: string,
|
||||||
|
factory: TokenizerProviderFactory,
|
||||||
|
): void {
|
||||||
|
if (tokenizerProviderFactories.has(id)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
tokenizerProviderFactories.set(id, factory);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getRegisteredTokenizerProviderIds(): string[] {
|
||||||
|
return Array.from(tokenizerProviderFactories.keys());
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createTokenizerProvider(
|
||||||
|
id = "mecab",
|
||||||
|
): TokenizerProvider | null {
|
||||||
|
const factory = tokenizerProviderFactories.get(id);
|
||||||
|
if (!factory) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return factory();
|
||||||
|
}
|
||||||
|
|
||||||
|
function registerDefaultTokenizerProviders(): void {
|
||||||
|
registerTokenizerProvider("mecab", () => {
|
||||||
|
const mecab = new MecabTokenizer();
|
||||||
|
return {
|
||||||
|
id: "mecab",
|
||||||
|
checkAvailability: () => mecab.checkAvailability(),
|
||||||
|
tokenize: (text: string) => mecab.tokenize(text),
|
||||||
|
getStatus: () => mecab.getStatus(),
|
||||||
|
setEnabled: (enabled: boolean) => mecab.setEnabled(enabled),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
registerDefaultTokenizerProviders();
|
||||||
106
src/translators/index.ts
Normal file
106
src/translators/index.ts
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
import axios from "axios";
|
||||||
|
|
||||||
|
export interface TranslationRequest {
|
||||||
|
sentence: string;
|
||||||
|
apiKey: string;
|
||||||
|
baseUrl: string;
|
||||||
|
model: string;
|
||||||
|
targetLanguage: string;
|
||||||
|
systemPrompt: string;
|
||||||
|
timeoutMs?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TranslationProvider {
|
||||||
|
id: string;
|
||||||
|
translate: (request: TranslationRequest) => Promise<string | null>;
|
||||||
|
}
|
||||||
|
|
||||||
|
type TranslationProviderFactory = () => TranslationProvider;
|
||||||
|
|
||||||
|
const translationProviderFactories = new Map<string, TranslationProviderFactory>();
|
||||||
|
|
||||||
|
export function registerTranslationProvider(
|
||||||
|
id: string,
|
||||||
|
factory: TranslationProviderFactory,
|
||||||
|
): void {
|
||||||
|
if (translationProviderFactories.has(id)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
translationProviderFactories.set(id, factory);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createTranslationProvider(
|
||||||
|
id = "openai-compatible",
|
||||||
|
): TranslationProvider | null {
|
||||||
|
const factory = translationProviderFactories.get(id);
|
||||||
|
if (!factory) return null;
|
||||||
|
return factory();
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractAiText(content: unknown): string {
|
||||||
|
if (typeof content === "string") {
|
||||||
|
return content.trim();
|
||||||
|
}
|
||||||
|
if (!Array.isArray(content)) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
const parts: string[] = [];
|
||||||
|
for (const item of content) {
|
||||||
|
if (
|
||||||
|
item &&
|
||||||
|
typeof item === "object" &&
|
||||||
|
"type" in item &&
|
||||||
|
(item as { type?: unknown }).type === "text" &&
|
||||||
|
"text" in item &&
|
||||||
|
typeof (item as { text?: unknown }).text === "string"
|
||||||
|
) {
|
||||||
|
parts.push((item as { text: string }).text);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return parts.join("").trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeOpenAiBaseUrl(baseUrl: string): string {
|
||||||
|
const trimmed = baseUrl.trim().replace(/\/+$/, "");
|
||||||
|
if (/\/v1$/i.test(trimmed)) {
|
||||||
|
return trimmed;
|
||||||
|
}
|
||||||
|
return `${trimmed}/v1`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function registerDefaultTranslationProviders(): void {
|
||||||
|
registerTranslationProvider("openai-compatible", () => ({
|
||||||
|
id: "openai-compatible",
|
||||||
|
translate: async (request: TranslationRequest): Promise<string | null> => {
|
||||||
|
const response = await axios.post(
|
||||||
|
`${normalizeOpenAiBaseUrl(request.baseUrl)}/chat/completions`,
|
||||||
|
{
|
||||||
|
model: request.model,
|
||||||
|
temperature: 0,
|
||||||
|
messages: [
|
||||||
|
{ role: "system", content: request.systemPrompt },
|
||||||
|
{
|
||||||
|
role: "user",
|
||||||
|
content: `Translate this text to ${request.targetLanguage}:\n\n${request.sentence}`,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${request.apiKey}`,
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
timeout: request.timeoutMs ?? 15000,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const content = (response.data as { choices?: unknown[] })?.choices?.[0] as
|
||||||
|
| { message?: { content?: unknown } }
|
||||||
|
| undefined;
|
||||||
|
const translated = extractAiText(content?.message?.content);
|
||||||
|
return translated || null;
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
registerDefaultTranslationProviders();
|
||||||
Reference in New Issue
Block a user