mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-02-28 06:22:45 -08:00
refactor: extract mpv, tokenizer, and yomitan loader services
This commit is contained in:
761
src/core/services/mpv-service.ts
Normal file
761
src/core/services/mpv-service.ts
Normal file
@@ -0,0 +1,761 @@
|
|||||||
|
import * as net from "net";
|
||||||
|
import {
|
||||||
|
Config,
|
||||||
|
MpvClient,
|
||||||
|
MpvSubtitleRenderMetrics,
|
||||||
|
SubtitleData,
|
||||||
|
} from "../../types";
|
||||||
|
import { asBoolean, asFiniteNumber } from "../utils/coerce";
|
||||||
|
|
||||||
|
interface MpvMessage {
|
||||||
|
event?: string;
|
||||||
|
name?: string;
|
||||||
|
data?: unknown;
|
||||||
|
request_id?: number;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const MPV_REQUEST_ID_SUBTEXT = 101;
|
||||||
|
const MPV_REQUEST_ID_PATH = 102;
|
||||||
|
const MPV_REQUEST_ID_SECONDARY_SUBTEXT = 103;
|
||||||
|
export const MPV_REQUEST_ID_SECONDARY_SUB_VISIBILITY = 104;
|
||||||
|
const MPV_REQUEST_ID_AID = 105;
|
||||||
|
const MPV_REQUEST_ID_SUB_POS = 106;
|
||||||
|
const MPV_REQUEST_ID_SUB_FONT_SIZE = 107;
|
||||||
|
const MPV_REQUEST_ID_SUB_SCALE = 108;
|
||||||
|
const MPV_REQUEST_ID_SUB_MARGIN_Y = 109;
|
||||||
|
const MPV_REQUEST_ID_SUB_MARGIN_X = 110;
|
||||||
|
const MPV_REQUEST_ID_SUB_FONT = 111;
|
||||||
|
const MPV_REQUEST_ID_SUB_SCALE_BY_WINDOW = 112;
|
||||||
|
const MPV_REQUEST_ID_OSD_HEIGHT = 113;
|
||||||
|
const MPV_REQUEST_ID_OSD_DIMENSIONS = 114;
|
||||||
|
const MPV_REQUEST_ID_SUBTEXT_ASS = 115;
|
||||||
|
const MPV_REQUEST_ID_SUB_SPACING = 116;
|
||||||
|
const MPV_REQUEST_ID_SUB_BOLD = 117;
|
||||||
|
const MPV_REQUEST_ID_SUB_ITALIC = 118;
|
||||||
|
const MPV_REQUEST_ID_SUB_BORDER_SIZE = 119;
|
||||||
|
const MPV_REQUEST_ID_SUB_SHADOW_OFFSET = 120;
|
||||||
|
const MPV_REQUEST_ID_SUB_ASS_OVERRIDE = 121;
|
||||||
|
const MPV_REQUEST_ID_SUB_USE_MARGINS = 122;
|
||||||
|
const MPV_REQUEST_ID_TRACK_LIST_SECONDARY = 200;
|
||||||
|
const MPV_REQUEST_ID_TRACK_LIST_AUDIO = 201;
|
||||||
|
|
||||||
|
interface SubtitleTimingTrackerLike {
|
||||||
|
recordSubtitle: (text: string, start: number, end: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MpvIpcClientDeps {
|
||||||
|
getResolvedConfig: () => Config;
|
||||||
|
autoStartOverlay: boolean;
|
||||||
|
setOverlayVisible: (visible: boolean) => void;
|
||||||
|
shouldBindVisibleOverlayToMpvSubVisibility: () => boolean;
|
||||||
|
isVisibleOverlayVisible: () => boolean;
|
||||||
|
getReconnectTimer: () => ReturnType<typeof setTimeout> | null;
|
||||||
|
setReconnectTimer: (timer: ReturnType<typeof setTimeout> | null) => void;
|
||||||
|
getCurrentSubText: () => string;
|
||||||
|
setCurrentSubText: (text: string) => void;
|
||||||
|
setCurrentSubAssText: (text: string) => void;
|
||||||
|
getSubtitleTimingTracker: () => SubtitleTimingTrackerLike | null;
|
||||||
|
subtitleWsBroadcast: (text: string) => void;
|
||||||
|
getOverlayWindowsCount: () => number;
|
||||||
|
tokenizeSubtitle: (text: string) => Promise<SubtitleData>;
|
||||||
|
broadcastToOverlayWindows: (channel: string, ...args: unknown[]) => void;
|
||||||
|
updateCurrentMediaPath: (mediaPath: unknown) => void;
|
||||||
|
updateMpvSubtitleRenderMetrics: (
|
||||||
|
patch: Partial<MpvSubtitleRenderMetrics>,
|
||||||
|
) => void;
|
||||||
|
getMpvSubtitleRenderMetrics: () => MpvSubtitleRenderMetrics;
|
||||||
|
setPreviousSecondarySubVisibility: (value: boolean | null) => void;
|
||||||
|
showMpvOsd: (text: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class MpvIpcClient implements MpvClient {
|
||||||
|
private socketPath: string;
|
||||||
|
private deps: MpvIpcClientDeps;
|
||||||
|
public socket: net.Socket | null = null;
|
||||||
|
private buffer = "";
|
||||||
|
public connected = false;
|
||||||
|
private connecting = false;
|
||||||
|
private reconnectAttempt = 0;
|
||||||
|
private firstConnection = true;
|
||||||
|
private hasConnectedOnce = false;
|
||||||
|
public currentVideoPath = "";
|
||||||
|
public currentTimePos = 0;
|
||||||
|
public currentSubStart = 0;
|
||||||
|
public currentSubEnd = 0;
|
||||||
|
public currentSubText = "";
|
||||||
|
public currentSecondarySubText = "";
|
||||||
|
public currentAudioStreamIndex: number | null = null;
|
||||||
|
private currentAudioTrackId: number | null = null;
|
||||||
|
private pauseAtTime: number | null = null;
|
||||||
|
private pendingPauseAtSubEnd = false;
|
||||||
|
private nextDynamicRequestId = 1000;
|
||||||
|
private pendingRequests = new Map<number, (message: MpvMessage) => void>();
|
||||||
|
|
||||||
|
constructor(socketPath: string, deps: MpvIpcClientDeps) {
|
||||||
|
this.socketPath = socketPath;
|
||||||
|
this.deps = deps;
|
||||||
|
}
|
||||||
|
|
||||||
|
setSocketPath(socketPath: string): void {
|
||||||
|
this.socketPath = socketPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
connect(): void {
|
||||||
|
if (this.connected || this.connecting) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.socket) {
|
||||||
|
this.socket.destroy();
|
||||||
|
}
|
||||||
|
|
||||||
|
this.connecting = true;
|
||||||
|
this.socket = new net.Socket();
|
||||||
|
|
||||||
|
this.socket.on("connect", () => {
|
||||||
|
console.log("Connected to MPV socket");
|
||||||
|
this.connected = true;
|
||||||
|
this.connecting = false;
|
||||||
|
this.reconnectAttempt = 0;
|
||||||
|
this.hasConnectedOnce = true;
|
||||||
|
this.subscribeToProperties();
|
||||||
|
this.getInitialState();
|
||||||
|
|
||||||
|
const shouldAutoStart =
|
||||||
|
this.deps.autoStartOverlay ||
|
||||||
|
this.deps.getResolvedConfig().auto_start_overlay === true;
|
||||||
|
if (this.firstConnection && shouldAutoStart) {
|
||||||
|
console.log("Auto-starting overlay, hiding mpv subtitles");
|
||||||
|
setTimeout(() => {
|
||||||
|
this.deps.setOverlayVisible(true);
|
||||||
|
}, 100);
|
||||||
|
} else if (this.deps.shouldBindVisibleOverlayToMpvSubVisibility()) {
|
||||||
|
this.setSubVisibility(!this.deps.isVisibleOverlayVisible());
|
||||||
|
}
|
||||||
|
|
||||||
|
this.firstConnection = false;
|
||||||
|
});
|
||||||
|
|
||||||
|
this.socket.on("data", (data: Buffer) => {
|
||||||
|
this.buffer += data.toString();
|
||||||
|
this.processBuffer();
|
||||||
|
});
|
||||||
|
|
||||||
|
this.socket.on("error", (err: Error) => {
|
||||||
|
console.error("MPV socket error:", err.message);
|
||||||
|
this.connected = false;
|
||||||
|
this.connecting = false;
|
||||||
|
this.failPendingRequests();
|
||||||
|
});
|
||||||
|
|
||||||
|
this.socket.on("close", () => {
|
||||||
|
console.log("MPV socket closed");
|
||||||
|
this.connected = false;
|
||||||
|
this.connecting = false;
|
||||||
|
this.failPendingRequests();
|
||||||
|
this.scheduleReconnect();
|
||||||
|
});
|
||||||
|
|
||||||
|
this.socket.connect(this.socketPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
private scheduleReconnect(): void {
|
||||||
|
const reconnectTimer = this.deps.getReconnectTimer();
|
||||||
|
if (reconnectTimer) {
|
||||||
|
clearTimeout(reconnectTimer);
|
||||||
|
}
|
||||||
|
const attempt = this.reconnectAttempt++;
|
||||||
|
let delay: number;
|
||||||
|
if (this.hasConnectedOnce) {
|
||||||
|
if (attempt < 2) {
|
||||||
|
delay = 1000;
|
||||||
|
} else if (attempt < 4) {
|
||||||
|
delay = 2000;
|
||||||
|
} else if (attempt < 7) {
|
||||||
|
delay = 5000;
|
||||||
|
} else {
|
||||||
|
delay = 10000;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (attempt < 2) {
|
||||||
|
delay = 200;
|
||||||
|
} else if (attempt < 4) {
|
||||||
|
delay = 500;
|
||||||
|
} else if (attempt < 6) {
|
||||||
|
delay = 1000;
|
||||||
|
} else {
|
||||||
|
delay = 2000;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.deps.setReconnectTimer(
|
||||||
|
setTimeout(() => {
|
||||||
|
console.log(
|
||||||
|
`Attempting to reconnect to MPV (attempt ${attempt + 1}, delay ${delay}ms)...`,
|
||||||
|
);
|
||||||
|
this.connect();
|
||||||
|
}, delay),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private processBuffer(): void {
|
||||||
|
const lines = this.buffer.split("\n");
|
||||||
|
this.buffer = lines.pop() || "";
|
||||||
|
|
||||||
|
for (const line of lines) {
|
||||||
|
if (!line.trim()) continue;
|
||||||
|
try {
|
||||||
|
const msg = JSON.parse(line) as MpvMessage;
|
||||||
|
this.handleMessage(msg);
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Failed to parse MPV message:", line, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async handleMessage(msg: MpvMessage): Promise<void> {
|
||||||
|
if (msg.event === "property-change") {
|
||||||
|
if (msg.name === "sub-text") {
|
||||||
|
const nextSubText = (msg.data as string) || "";
|
||||||
|
this.deps.setCurrentSubText(nextSubText);
|
||||||
|
this.currentSubText = nextSubText;
|
||||||
|
const subtitleTimingTracker = this.deps.getSubtitleTimingTracker();
|
||||||
|
if (
|
||||||
|
subtitleTimingTracker &&
|
||||||
|
this.currentSubStart !== undefined &&
|
||||||
|
this.currentSubEnd !== undefined
|
||||||
|
) {
|
||||||
|
subtitleTimingTracker.recordSubtitle(
|
||||||
|
nextSubText,
|
||||||
|
this.currentSubStart,
|
||||||
|
this.currentSubEnd,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
this.deps.subtitleWsBroadcast(nextSubText);
|
||||||
|
if (this.deps.getOverlayWindowsCount() > 0) {
|
||||||
|
const subtitleData = await this.deps.tokenizeSubtitle(nextSubText);
|
||||||
|
this.deps.broadcastToOverlayWindows("subtitle:set", subtitleData);
|
||||||
|
}
|
||||||
|
} else if (msg.name === "sub-text-ass") {
|
||||||
|
const nextSubAssText = (msg.data as string) || "";
|
||||||
|
this.deps.setCurrentSubAssText(nextSubAssText);
|
||||||
|
this.deps.broadcastToOverlayWindows("subtitle-ass:set", nextSubAssText);
|
||||||
|
} else if (msg.name === "sub-start") {
|
||||||
|
this.currentSubStart = (msg.data as number) || 0;
|
||||||
|
const subtitleTimingTracker = this.deps.getSubtitleTimingTracker();
|
||||||
|
if (subtitleTimingTracker && this.deps.getCurrentSubText()) {
|
||||||
|
subtitleTimingTracker.recordSubtitle(
|
||||||
|
this.deps.getCurrentSubText(),
|
||||||
|
this.currentSubStart,
|
||||||
|
this.currentSubEnd,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else if (msg.name === "sub-end") {
|
||||||
|
this.currentSubEnd = (msg.data as number) || 0;
|
||||||
|
if (this.pendingPauseAtSubEnd && this.currentSubEnd > 0) {
|
||||||
|
this.pauseAtTime = this.currentSubEnd;
|
||||||
|
this.pendingPauseAtSubEnd = false;
|
||||||
|
this.send({ command: ["set_property", "pause", false] });
|
||||||
|
}
|
||||||
|
const subtitleTimingTracker = this.deps.getSubtitleTimingTracker();
|
||||||
|
if (subtitleTimingTracker && this.deps.getCurrentSubText()) {
|
||||||
|
subtitleTimingTracker.recordSubtitle(
|
||||||
|
this.deps.getCurrentSubText(),
|
||||||
|
this.currentSubStart,
|
||||||
|
this.currentSubEnd,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else if (msg.name === "secondary-sub-text") {
|
||||||
|
this.currentSecondarySubText = (msg.data as string) || "";
|
||||||
|
this.deps.broadcastToOverlayWindows(
|
||||||
|
"secondary-subtitle:set",
|
||||||
|
this.currentSecondarySubText,
|
||||||
|
);
|
||||||
|
} else if (msg.name === "aid") {
|
||||||
|
this.currentAudioTrackId =
|
||||||
|
typeof msg.data === "number" ? (msg.data as number) : null;
|
||||||
|
this.syncCurrentAudioStreamIndex();
|
||||||
|
} else if (msg.name === "time-pos") {
|
||||||
|
this.currentTimePos = (msg.data as number) || 0;
|
||||||
|
if (
|
||||||
|
this.pauseAtTime !== null &&
|
||||||
|
this.currentTimePos >= this.pauseAtTime
|
||||||
|
) {
|
||||||
|
this.pauseAtTime = null;
|
||||||
|
this.send({ command: ["set_property", "pause", true] });
|
||||||
|
}
|
||||||
|
} else if (msg.name === "path") {
|
||||||
|
this.currentVideoPath = (msg.data as string) || "";
|
||||||
|
this.deps.updateCurrentMediaPath(msg.data);
|
||||||
|
this.autoLoadSecondarySubTrack();
|
||||||
|
this.syncCurrentAudioStreamIndex();
|
||||||
|
} else if (msg.name === "sub-pos") {
|
||||||
|
this.deps.updateMpvSubtitleRenderMetrics({ subPos: msg.data as number });
|
||||||
|
} else if (msg.name === "sub-font-size") {
|
||||||
|
this.deps.updateMpvSubtitleRenderMetrics({
|
||||||
|
subFontSize: msg.data as number,
|
||||||
|
});
|
||||||
|
} else if (msg.name === "sub-scale") {
|
||||||
|
this.deps.updateMpvSubtitleRenderMetrics({ subScale: msg.data as number });
|
||||||
|
} else if (msg.name === "sub-margin-y") {
|
||||||
|
this.deps.updateMpvSubtitleRenderMetrics({
|
||||||
|
subMarginY: msg.data as number,
|
||||||
|
});
|
||||||
|
} else if (msg.name === "sub-margin-x") {
|
||||||
|
this.deps.updateMpvSubtitleRenderMetrics({
|
||||||
|
subMarginX: msg.data as number,
|
||||||
|
});
|
||||||
|
} else if (msg.name === "sub-font") {
|
||||||
|
this.deps.updateMpvSubtitleRenderMetrics({ subFont: msg.data as string });
|
||||||
|
} else if (msg.name === "sub-spacing") {
|
||||||
|
this.deps.updateMpvSubtitleRenderMetrics({
|
||||||
|
subSpacing: msg.data as number,
|
||||||
|
});
|
||||||
|
} else if (msg.name === "sub-bold") {
|
||||||
|
this.deps.updateMpvSubtitleRenderMetrics({
|
||||||
|
subBold: asBoolean(msg.data, this.deps.getMpvSubtitleRenderMetrics().subBold),
|
||||||
|
});
|
||||||
|
} else if (msg.name === "sub-italic") {
|
||||||
|
this.deps.updateMpvSubtitleRenderMetrics({
|
||||||
|
subItalic: asBoolean(
|
||||||
|
msg.data,
|
||||||
|
this.deps.getMpvSubtitleRenderMetrics().subItalic,
|
||||||
|
),
|
||||||
|
});
|
||||||
|
} else if (msg.name === "sub-border-size") {
|
||||||
|
this.deps.updateMpvSubtitleRenderMetrics({
|
||||||
|
subBorderSize: msg.data as number,
|
||||||
|
});
|
||||||
|
} else if (msg.name === "sub-shadow-offset") {
|
||||||
|
this.deps.updateMpvSubtitleRenderMetrics({
|
||||||
|
subShadowOffset: msg.data as number,
|
||||||
|
});
|
||||||
|
} else if (msg.name === "sub-ass-override") {
|
||||||
|
this.deps.updateMpvSubtitleRenderMetrics({
|
||||||
|
subAssOverride: msg.data as string,
|
||||||
|
});
|
||||||
|
} else if (msg.name === "sub-scale-by-window") {
|
||||||
|
this.deps.updateMpvSubtitleRenderMetrics({
|
||||||
|
subScaleByWindow: asBoolean(
|
||||||
|
msg.data,
|
||||||
|
this.deps.getMpvSubtitleRenderMetrics().subScaleByWindow,
|
||||||
|
),
|
||||||
|
});
|
||||||
|
} else if (msg.name === "sub-use-margins") {
|
||||||
|
this.deps.updateMpvSubtitleRenderMetrics({
|
||||||
|
subUseMargins: asBoolean(
|
||||||
|
msg.data,
|
||||||
|
this.deps.getMpvSubtitleRenderMetrics().subUseMargins,
|
||||||
|
),
|
||||||
|
});
|
||||||
|
} else if (msg.name === "osd-height") {
|
||||||
|
this.deps.updateMpvSubtitleRenderMetrics({
|
||||||
|
osdHeight: msg.data as number,
|
||||||
|
});
|
||||||
|
} else if (msg.name === "osd-dimensions") {
|
||||||
|
const dims = msg.data as Record<string, unknown> | null;
|
||||||
|
if (!dims) {
|
||||||
|
this.deps.updateMpvSubtitleRenderMetrics({ osdDimensions: null });
|
||||||
|
} else {
|
||||||
|
this.deps.updateMpvSubtitleRenderMetrics({
|
||||||
|
osdDimensions: {
|
||||||
|
w: asFiniteNumber(dims.w, 0),
|
||||||
|
h: asFiniteNumber(dims.h, 0),
|
||||||
|
ml: asFiniteNumber(dims.ml, 0),
|
||||||
|
mr: asFiniteNumber(dims.mr, 0),
|
||||||
|
mt: asFiniteNumber(dims.mt, 0),
|
||||||
|
mb: asFiniteNumber(dims.mb, 0),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (msg.request_id) {
|
||||||
|
const pending = this.pendingRequests.get(msg.request_id);
|
||||||
|
if (pending) {
|
||||||
|
this.pendingRequests.delete(msg.request_id);
|
||||||
|
pending(msg);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (msg.data === undefined) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (msg.request_id === MPV_REQUEST_ID_TRACK_LIST_SECONDARY) {
|
||||||
|
const tracks = msg.data as Array<{
|
||||||
|
type: string;
|
||||||
|
lang?: string;
|
||||||
|
id: number;
|
||||||
|
}>;
|
||||||
|
if (Array.isArray(tracks)) {
|
||||||
|
const config = this.deps.getResolvedConfig();
|
||||||
|
const languages = config.secondarySub?.secondarySubLanguages || [];
|
||||||
|
const subTracks = tracks.filter((t) => t.type === "sub");
|
||||||
|
for (const lang of languages) {
|
||||||
|
const match = subTracks.find((t) => t.lang === lang);
|
||||||
|
if (match) {
|
||||||
|
this.send({
|
||||||
|
command: ["set_property", "secondary-sid", match.id],
|
||||||
|
});
|
||||||
|
this.deps.showMpvOsd(
|
||||||
|
`Secondary subtitle: ${lang} (track ${match.id})`,
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (msg.request_id === MPV_REQUEST_ID_TRACK_LIST_AUDIO) {
|
||||||
|
this.updateCurrentAudioStreamIndex(
|
||||||
|
msg.data as Array<{
|
||||||
|
type?: string;
|
||||||
|
id?: number;
|
||||||
|
selected?: boolean;
|
||||||
|
"ff-index"?: number;
|
||||||
|
}>,
|
||||||
|
);
|
||||||
|
} else if (msg.request_id === MPV_REQUEST_ID_SUBTEXT) {
|
||||||
|
const nextSubText = (msg.data as string) || "";
|
||||||
|
this.deps.setCurrentSubText(nextSubText);
|
||||||
|
this.currentSubText = nextSubText;
|
||||||
|
this.deps.subtitleWsBroadcast(nextSubText);
|
||||||
|
if (this.deps.getOverlayWindowsCount() > 0) {
|
||||||
|
this.deps.tokenizeSubtitle(nextSubText).then((subtitleData) => {
|
||||||
|
this.deps.broadcastToOverlayWindows("subtitle:set", subtitleData);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else if (msg.request_id === MPV_REQUEST_ID_SUBTEXT_ASS) {
|
||||||
|
const nextSubAssText = (msg.data as string) || "";
|
||||||
|
this.deps.setCurrentSubAssText(nextSubAssText);
|
||||||
|
this.deps.broadcastToOverlayWindows("subtitle-ass:set", nextSubAssText);
|
||||||
|
} else if (msg.request_id === MPV_REQUEST_ID_PATH) {
|
||||||
|
this.deps.updateCurrentMediaPath(msg.data);
|
||||||
|
} else if (msg.request_id === MPV_REQUEST_ID_AID) {
|
||||||
|
this.currentAudioTrackId =
|
||||||
|
typeof msg.data === "number" ? (msg.data as number) : null;
|
||||||
|
this.syncCurrentAudioStreamIndex();
|
||||||
|
} else if (msg.request_id === MPV_REQUEST_ID_SECONDARY_SUBTEXT) {
|
||||||
|
this.currentSecondarySubText = (msg.data as string) || "";
|
||||||
|
this.deps.broadcastToOverlayWindows(
|
||||||
|
"secondary-subtitle:set",
|
||||||
|
this.currentSecondarySubText,
|
||||||
|
);
|
||||||
|
} else if (msg.request_id === MPV_REQUEST_ID_SECONDARY_SUB_VISIBILITY) {
|
||||||
|
if (!this.deps.shouldBindVisibleOverlayToMpvSubVisibility()) {
|
||||||
|
this.deps.setPreviousSecondarySubVisibility(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.deps.setPreviousSecondarySubVisibility(
|
||||||
|
msg.data === true || msg.data === "yes",
|
||||||
|
);
|
||||||
|
this.send({
|
||||||
|
command: ["set_property", "secondary-sub-visibility", "no"],
|
||||||
|
});
|
||||||
|
} else if (msg.request_id === MPV_REQUEST_ID_SUB_POS) {
|
||||||
|
this.deps.updateMpvSubtitleRenderMetrics({ subPos: msg.data as number });
|
||||||
|
} else if (msg.request_id === MPV_REQUEST_ID_SUB_FONT_SIZE) {
|
||||||
|
this.deps.updateMpvSubtitleRenderMetrics({
|
||||||
|
subFontSize: msg.data as number,
|
||||||
|
});
|
||||||
|
} else if (msg.request_id === MPV_REQUEST_ID_SUB_SCALE) {
|
||||||
|
this.deps.updateMpvSubtitleRenderMetrics({ subScale: msg.data as number });
|
||||||
|
} else if (msg.request_id === MPV_REQUEST_ID_SUB_MARGIN_Y) {
|
||||||
|
this.deps.updateMpvSubtitleRenderMetrics({
|
||||||
|
subMarginY: msg.data as number,
|
||||||
|
});
|
||||||
|
} else if (msg.request_id === MPV_REQUEST_ID_SUB_MARGIN_X) {
|
||||||
|
this.deps.updateMpvSubtitleRenderMetrics({
|
||||||
|
subMarginX: msg.data as number,
|
||||||
|
});
|
||||||
|
} else if (msg.request_id === MPV_REQUEST_ID_SUB_FONT) {
|
||||||
|
this.deps.updateMpvSubtitleRenderMetrics({ subFont: msg.data as string });
|
||||||
|
} else if (msg.request_id === MPV_REQUEST_ID_SUB_SPACING) {
|
||||||
|
this.deps.updateMpvSubtitleRenderMetrics({
|
||||||
|
subSpacing: msg.data as number,
|
||||||
|
});
|
||||||
|
} else if (msg.request_id === MPV_REQUEST_ID_SUB_BOLD) {
|
||||||
|
this.deps.updateMpvSubtitleRenderMetrics({
|
||||||
|
subBold: asBoolean(msg.data, this.deps.getMpvSubtitleRenderMetrics().subBold),
|
||||||
|
});
|
||||||
|
} else if (msg.request_id === MPV_REQUEST_ID_SUB_ITALIC) {
|
||||||
|
this.deps.updateMpvSubtitleRenderMetrics({
|
||||||
|
subItalic: asBoolean(
|
||||||
|
msg.data,
|
||||||
|
this.deps.getMpvSubtitleRenderMetrics().subItalic,
|
||||||
|
),
|
||||||
|
});
|
||||||
|
} else if (msg.request_id === MPV_REQUEST_ID_SUB_BORDER_SIZE) {
|
||||||
|
this.deps.updateMpvSubtitleRenderMetrics({
|
||||||
|
subBorderSize: msg.data as number,
|
||||||
|
});
|
||||||
|
} else if (msg.request_id === MPV_REQUEST_ID_SUB_SHADOW_OFFSET) {
|
||||||
|
this.deps.updateMpvSubtitleRenderMetrics({
|
||||||
|
subShadowOffset: msg.data as number,
|
||||||
|
});
|
||||||
|
} else if (msg.request_id === MPV_REQUEST_ID_SUB_ASS_OVERRIDE) {
|
||||||
|
this.deps.updateMpvSubtitleRenderMetrics({
|
||||||
|
subAssOverride: msg.data as string,
|
||||||
|
});
|
||||||
|
} else if (msg.request_id === MPV_REQUEST_ID_SUB_SCALE_BY_WINDOW) {
|
||||||
|
this.deps.updateMpvSubtitleRenderMetrics({
|
||||||
|
subScaleByWindow: asBoolean(
|
||||||
|
msg.data,
|
||||||
|
this.deps.getMpvSubtitleRenderMetrics().subScaleByWindow,
|
||||||
|
),
|
||||||
|
});
|
||||||
|
} else if (msg.request_id === MPV_REQUEST_ID_SUB_USE_MARGINS) {
|
||||||
|
this.deps.updateMpvSubtitleRenderMetrics({
|
||||||
|
subUseMargins: asBoolean(
|
||||||
|
msg.data,
|
||||||
|
this.deps.getMpvSubtitleRenderMetrics().subUseMargins,
|
||||||
|
),
|
||||||
|
});
|
||||||
|
} else if (msg.request_id === MPV_REQUEST_ID_OSD_HEIGHT) {
|
||||||
|
this.deps.updateMpvSubtitleRenderMetrics({
|
||||||
|
osdHeight: msg.data as number,
|
||||||
|
});
|
||||||
|
} else if (msg.request_id === MPV_REQUEST_ID_OSD_DIMENSIONS) {
|
||||||
|
const dims = msg.data as Record<string, unknown> | null;
|
||||||
|
if (!dims) {
|
||||||
|
this.deps.updateMpvSubtitleRenderMetrics({ osdDimensions: null });
|
||||||
|
} else {
|
||||||
|
this.deps.updateMpvSubtitleRenderMetrics({
|
||||||
|
osdDimensions: {
|
||||||
|
w: asFiniteNumber(dims.w, 0),
|
||||||
|
h: asFiniteNumber(dims.h, 0),
|
||||||
|
ml: asFiniteNumber(dims.ml, 0),
|
||||||
|
mr: asFiniteNumber(dims.mr, 0),
|
||||||
|
mt: asFiniteNumber(dims.mt, 0),
|
||||||
|
mb: asFiniteNumber(dims.mb, 0),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private autoLoadSecondarySubTrack(): void {
|
||||||
|
const config = this.deps.getResolvedConfig();
|
||||||
|
if (!config.secondarySub?.autoLoadSecondarySub) return;
|
||||||
|
const languages = config.secondarySub.secondarySubLanguages;
|
||||||
|
if (!languages || languages.length === 0) return;
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
this.send({
|
||||||
|
command: ["get_property", "track-list"],
|
||||||
|
request_id: MPV_REQUEST_ID_TRACK_LIST_SECONDARY,
|
||||||
|
});
|
||||||
|
}, 500);
|
||||||
|
}
|
||||||
|
|
||||||
|
private syncCurrentAudioStreamIndex(): void {
|
||||||
|
this.send({
|
||||||
|
command: ["get_property", "track-list"],
|
||||||
|
request_id: MPV_REQUEST_ID_TRACK_LIST_AUDIO,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private updateCurrentAudioStreamIndex(
|
||||||
|
tracks: Array<{
|
||||||
|
type?: string;
|
||||||
|
id?: number;
|
||||||
|
selected?: boolean;
|
||||||
|
"ff-index"?: number;
|
||||||
|
}>,
|
||||||
|
): void {
|
||||||
|
if (!Array.isArray(tracks)) {
|
||||||
|
this.currentAudioStreamIndex = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const audioTracks = tracks.filter((track) => track.type === "audio");
|
||||||
|
const activeTrack =
|
||||||
|
audioTracks.find((track) => track.id === this.currentAudioTrackId) ||
|
||||||
|
audioTracks.find((track) => track.selected === true);
|
||||||
|
|
||||||
|
const ffIndex = activeTrack?.["ff-index"];
|
||||||
|
this.currentAudioStreamIndex =
|
||||||
|
typeof ffIndex === "number" && Number.isInteger(ffIndex) && ffIndex >= 0
|
||||||
|
? ffIndex
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
send(command: { command: unknown[]; request_id?: number }): boolean {
|
||||||
|
if (!this.connected || !this.socket) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const msg = JSON.stringify(command) + "\n";
|
||||||
|
this.socket.write(msg);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
request(command: unknown[]): Promise<MpvMessage> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
if (!this.connected || !this.socket) {
|
||||||
|
reject(new Error("MPV not connected"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const requestId = this.nextDynamicRequestId++;
|
||||||
|
this.pendingRequests.set(requestId, resolve);
|
||||||
|
const sent = this.send({ command, request_id: requestId });
|
||||||
|
if (!sent) {
|
||||||
|
this.pendingRequests.delete(requestId);
|
||||||
|
reject(new Error("Failed to send MPV request"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
if (this.pendingRequests.delete(requestId)) {
|
||||||
|
reject(new Error("MPV request timed out"));
|
||||||
|
}
|
||||||
|
}, 4000);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async requestProperty(name: string): Promise<unknown> {
|
||||||
|
const response = await this.request(["get_property", name]);
|
||||||
|
if (response.error && response.error !== "success") {
|
||||||
|
throw new Error(
|
||||||
|
`Failed to read MPV property '${name}': ${response.error}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
private failPendingRequests(): void {
|
||||||
|
for (const [requestId, resolve] of this.pendingRequests.entries()) {
|
||||||
|
resolve({ request_id: requestId, error: "disconnected" });
|
||||||
|
}
|
||||||
|
this.pendingRequests.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
private subscribeToProperties(): void {
|
||||||
|
this.send({ command: ["observe_property", 1, "sub-text"] });
|
||||||
|
this.send({ command: ["observe_property", 2, "path"] });
|
||||||
|
this.send({ command: ["observe_property", 3, "sub-start"] });
|
||||||
|
this.send({ command: ["observe_property", 4, "sub-end"] });
|
||||||
|
this.send({ command: ["observe_property", 5, "time-pos"] });
|
||||||
|
this.send({ command: ["observe_property", 6, "secondary-sub-text"] });
|
||||||
|
this.send({ command: ["observe_property", 7, "aid"] });
|
||||||
|
this.send({ command: ["observe_property", 8, "sub-pos"] });
|
||||||
|
this.send({ command: ["observe_property", 9, "sub-font-size"] });
|
||||||
|
this.send({ command: ["observe_property", 10, "sub-scale"] });
|
||||||
|
this.send({ command: ["observe_property", 11, "sub-margin-y"] });
|
||||||
|
this.send({ command: ["observe_property", 12, "sub-margin-x"] });
|
||||||
|
this.send({ command: ["observe_property", 13, "sub-font"] });
|
||||||
|
this.send({ command: ["observe_property", 14, "sub-spacing"] });
|
||||||
|
this.send({ command: ["observe_property", 15, "sub-bold"] });
|
||||||
|
this.send({ command: ["observe_property", 16, "sub-italic"] });
|
||||||
|
this.send({ command: ["observe_property", 17, "sub-scale-by-window"] });
|
||||||
|
this.send({ command: ["observe_property", 18, "osd-height"] });
|
||||||
|
this.send({ command: ["observe_property", 19, "osd-dimensions"] });
|
||||||
|
this.send({ command: ["observe_property", 20, "sub-text-ass"] });
|
||||||
|
this.send({ command: ["observe_property", 21, "sub-border-size"] });
|
||||||
|
this.send({ command: ["observe_property", 22, "sub-shadow-offset"] });
|
||||||
|
this.send({ command: ["observe_property", 23, "sub-ass-override"] });
|
||||||
|
this.send({ command: ["observe_property", 24, "sub-use-margins"] });
|
||||||
|
}
|
||||||
|
|
||||||
|
private getInitialState(): void {
|
||||||
|
this.send({
|
||||||
|
command: ["get_property", "sub-text"],
|
||||||
|
request_id: MPV_REQUEST_ID_SUBTEXT,
|
||||||
|
});
|
||||||
|
this.send({
|
||||||
|
command: ["get_property", "sub-text-ass"],
|
||||||
|
request_id: MPV_REQUEST_ID_SUBTEXT_ASS,
|
||||||
|
});
|
||||||
|
this.send({
|
||||||
|
command: ["get_property", "path"],
|
||||||
|
request_id: MPV_REQUEST_ID_PATH,
|
||||||
|
});
|
||||||
|
this.send({
|
||||||
|
command: ["get_property", "secondary-sub-text"],
|
||||||
|
request_id: MPV_REQUEST_ID_SECONDARY_SUBTEXT,
|
||||||
|
});
|
||||||
|
this.send({
|
||||||
|
command: ["get_property", "aid"],
|
||||||
|
request_id: MPV_REQUEST_ID_AID,
|
||||||
|
});
|
||||||
|
this.send({
|
||||||
|
command: ["get_property", "sub-pos"],
|
||||||
|
request_id: MPV_REQUEST_ID_SUB_POS,
|
||||||
|
});
|
||||||
|
this.send({
|
||||||
|
command: ["get_property", "sub-font-size"],
|
||||||
|
request_id: MPV_REQUEST_ID_SUB_FONT_SIZE,
|
||||||
|
});
|
||||||
|
this.send({
|
||||||
|
command: ["get_property", "sub-scale"],
|
||||||
|
request_id: MPV_REQUEST_ID_SUB_SCALE,
|
||||||
|
});
|
||||||
|
this.send({
|
||||||
|
command: ["get_property", "sub-margin-y"],
|
||||||
|
request_id: MPV_REQUEST_ID_SUB_MARGIN_Y,
|
||||||
|
});
|
||||||
|
this.send({
|
||||||
|
command: ["get_property", "sub-margin-x"],
|
||||||
|
request_id: MPV_REQUEST_ID_SUB_MARGIN_X,
|
||||||
|
});
|
||||||
|
this.send({
|
||||||
|
command: ["get_property", "sub-font"],
|
||||||
|
request_id: MPV_REQUEST_ID_SUB_FONT,
|
||||||
|
});
|
||||||
|
this.send({
|
||||||
|
command: ["get_property", "sub-spacing"],
|
||||||
|
request_id: MPV_REQUEST_ID_SUB_SPACING,
|
||||||
|
});
|
||||||
|
this.send({
|
||||||
|
command: ["get_property", "sub-bold"],
|
||||||
|
request_id: MPV_REQUEST_ID_SUB_BOLD,
|
||||||
|
});
|
||||||
|
this.send({
|
||||||
|
command: ["get_property", "sub-italic"],
|
||||||
|
request_id: MPV_REQUEST_ID_SUB_ITALIC,
|
||||||
|
});
|
||||||
|
this.send({
|
||||||
|
command: ["get_property", "sub-scale-by-window"],
|
||||||
|
request_id: MPV_REQUEST_ID_SUB_SCALE_BY_WINDOW,
|
||||||
|
});
|
||||||
|
this.send({
|
||||||
|
command: ["get_property", "osd-height"],
|
||||||
|
request_id: MPV_REQUEST_ID_OSD_HEIGHT,
|
||||||
|
});
|
||||||
|
this.send({
|
||||||
|
command: ["get_property", "osd-dimensions"],
|
||||||
|
request_id: MPV_REQUEST_ID_OSD_DIMENSIONS,
|
||||||
|
});
|
||||||
|
this.send({
|
||||||
|
command: ["get_property", "sub-border-size"],
|
||||||
|
request_id: MPV_REQUEST_ID_SUB_BORDER_SIZE,
|
||||||
|
});
|
||||||
|
this.send({
|
||||||
|
command: ["get_property", "sub-shadow-offset"],
|
||||||
|
request_id: MPV_REQUEST_ID_SUB_SHADOW_OFFSET,
|
||||||
|
});
|
||||||
|
this.send({
|
||||||
|
command: ["get_property", "sub-ass-override"],
|
||||||
|
request_id: MPV_REQUEST_ID_SUB_ASS_OVERRIDE,
|
||||||
|
});
|
||||||
|
this.send({
|
||||||
|
command: ["get_property", "sub-use-margins"],
|
||||||
|
request_id: MPV_REQUEST_ID_SUB_USE_MARGINS,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
setSubVisibility(visible: boolean): void {
|
||||||
|
this.send({
|
||||||
|
command: ["set_property", "sub-visibility", visible ? "yes" : "no"],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
replayCurrentSubtitle(): void {
|
||||||
|
this.pendingPauseAtSubEnd = true;
|
||||||
|
this.send({ command: ["sub-seek", 0] });
|
||||||
|
}
|
||||||
|
|
||||||
|
playNextSubtitle(): void {
|
||||||
|
this.pendingPauseAtSubEnd = true;
|
||||||
|
this.send({ command: ["sub-seek", 1] });
|
||||||
|
}
|
||||||
|
}
|
||||||
305
src/core/services/tokenizer-service.ts
Normal file
305
src/core/services/tokenizer-service.ts
Normal file
@@ -0,0 +1,305 @@
|
|||||||
|
import { BrowserWindow, Extension, session } from "electron";
|
||||||
|
import { MergedToken, PartOfSpeech, SubtitleData } from "../../types";
|
||||||
|
|
||||||
|
interface YomitanParseHeadword {
|
||||||
|
term?: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface YomitanParseSegment {
|
||||||
|
text?: unknown;
|
||||||
|
reading?: unknown;
|
||||||
|
headwords?: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface YomitanParseResultItem {
|
||||||
|
source?: unknown;
|
||||||
|
index?: unknown;
|
||||||
|
content?: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TokenizerServiceDeps {
|
||||||
|
getYomitanExt: () => Extension | null;
|
||||||
|
getYomitanParserWindow: () => BrowserWindow | null;
|
||||||
|
setYomitanParserWindow: (window: BrowserWindow | null) => void;
|
||||||
|
getYomitanParserReadyPromise: () => Promise<void> | null;
|
||||||
|
setYomitanParserReadyPromise: (promise: Promise<void> | null) => void;
|
||||||
|
getYomitanParserInitPromise: () => Promise<boolean> | null;
|
||||||
|
setYomitanParserInitPromise: (promise: Promise<boolean> | null) => void;
|
||||||
|
tokenizeWithMecab: (text: string) => Promise<MergedToken[] | null>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractYomitanHeadword(segment: YomitanParseSegment): string {
|
||||||
|
const headwords = segment.headwords;
|
||||||
|
if (!Array.isArray(headwords) || headwords.length === 0) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
const firstGroup = headwords[0];
|
||||||
|
if (!Array.isArray(firstGroup) || firstGroup.length === 0) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
const firstHeadword = firstGroup[0] as YomitanParseHeadword;
|
||||||
|
return typeof firstHeadword?.term === "string" ? firstHeadword.term : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapYomitanParseResultsToMergedTokens(
|
||||||
|
parseResults: unknown,
|
||||||
|
): MergedToken[] | null {
|
||||||
|
if (!Array.isArray(parseResults) || parseResults.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const scanningItems = parseResults.filter((item) => {
|
||||||
|
const resultItem = item as YomitanParseResultItem;
|
||||||
|
return (
|
||||||
|
resultItem &&
|
||||||
|
resultItem.source === "scanning-parser" &&
|
||||||
|
Array.isArray(resultItem.content)
|
||||||
|
);
|
||||||
|
}) as YomitanParseResultItem[];
|
||||||
|
|
||||||
|
if (scanningItems.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const primaryItem =
|
||||||
|
scanningItems.find((item) => item.index === 0) || scanningItems[0];
|
||||||
|
const content = primaryItem.content;
|
||||||
|
if (!Array.isArray(content)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const tokens: MergedToken[] = [];
|
||||||
|
let charOffset = 0;
|
||||||
|
|
||||||
|
for (const line of content) {
|
||||||
|
if (!Array.isArray(line)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let surface = "";
|
||||||
|
let reading = "";
|
||||||
|
let headword = "";
|
||||||
|
|
||||||
|
for (const rawSegment of line) {
|
||||||
|
const segment = rawSegment as YomitanParseSegment;
|
||||||
|
if (!segment || typeof segment !== "object") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const segmentText = segment.text;
|
||||||
|
if (typeof segmentText !== "string" || segmentText.length === 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
surface += segmentText;
|
||||||
|
|
||||||
|
if (typeof segment.reading === "string") {
|
||||||
|
reading += segment.reading;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!headword) {
|
||||||
|
headword = extractYomitanHeadword(segment);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!surface) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const start = charOffset;
|
||||||
|
const end = start + surface.length;
|
||||||
|
charOffset = end;
|
||||||
|
|
||||||
|
tokens.push({
|
||||||
|
surface,
|
||||||
|
reading,
|
||||||
|
headword: headword || surface,
|
||||||
|
startPos: start,
|
||||||
|
endPos: end,
|
||||||
|
partOfSpeech: PartOfSpeech.other,
|
||||||
|
isMerged: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return tokens.length > 0 ? tokens : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ensureYomitanParserWindow(
|
||||||
|
deps: TokenizerServiceDeps,
|
||||||
|
): Promise<boolean> {
|
||||||
|
const yomitanExt = deps.getYomitanExt();
|
||||||
|
if (!yomitanExt) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentWindow = deps.getYomitanParserWindow();
|
||||||
|
if (currentWindow && !currentWindow.isDestroyed()) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const existingInitPromise = deps.getYomitanParserInitPromise();
|
||||||
|
if (existingInitPromise) {
|
||||||
|
return existingInitPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
const initPromise = (async () => {
|
||||||
|
const parserWindow = new BrowserWindow({
|
||||||
|
show: false,
|
||||||
|
width: 800,
|
||||||
|
height: 600,
|
||||||
|
webPreferences: {
|
||||||
|
contextIsolation: true,
|
||||||
|
nodeIntegration: false,
|
||||||
|
session: session.defaultSession,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
deps.setYomitanParserWindow(parserWindow);
|
||||||
|
|
||||||
|
deps.setYomitanParserReadyPromise(
|
||||||
|
new Promise((resolve, reject) => {
|
||||||
|
parserWindow.webContents.once("did-finish-load", () => resolve());
|
||||||
|
parserWindow.webContents.once(
|
||||||
|
"did-fail-load",
|
||||||
|
(_event, _errorCode, errorDescription) => {
|
||||||
|
reject(new Error(errorDescription));
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
parserWindow.on("closed", () => {
|
||||||
|
if (deps.getYomitanParserWindow() === parserWindow) {
|
||||||
|
deps.setYomitanParserWindow(null);
|
||||||
|
deps.setYomitanParserReadyPromise(null);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
await parserWindow.loadURL(`chrome-extension://${yomitanExt.id}/search.html`);
|
||||||
|
const readyPromise = deps.getYomitanParserReadyPromise();
|
||||||
|
if (readyPromise) {
|
||||||
|
await readyPromise;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
} catch (err) {
|
||||||
|
console.error(
|
||||||
|
"Failed to initialize Yomitan parser window:",
|
||||||
|
(err as Error).message,
|
||||||
|
);
|
||||||
|
if (!parserWindow.isDestroyed()) {
|
||||||
|
parserWindow.destroy();
|
||||||
|
}
|
||||||
|
if (deps.getYomitanParserWindow() === parserWindow) {
|
||||||
|
deps.setYomitanParserWindow(null);
|
||||||
|
deps.setYomitanParserReadyPromise(null);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
} finally {
|
||||||
|
deps.setYomitanParserInitPromise(null);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
deps.setYomitanParserInitPromise(initPromise);
|
||||||
|
return initPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function parseWithYomitanInternalParser(
|
||||||
|
text: string,
|
||||||
|
deps: TokenizerServiceDeps,
|
||||||
|
): Promise<MergedToken[] | null> {
|
||||||
|
const yomitanExt = deps.getYomitanExt();
|
||||||
|
if (!text || !yomitanExt) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isReady = await ensureYomitanParserWindow(deps);
|
||||||
|
const parserWindow = deps.getYomitanParserWindow();
|
||||||
|
if (!isReady || !parserWindow || parserWindow.isDestroyed()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const script = `
|
||||||
|
(async () => {
|
||||||
|
const invoke = (action, params) =>
|
||||||
|
new Promise((resolve, reject) => {
|
||||||
|
chrome.runtime.sendMessage({ action, params }, (response) => {
|
||||||
|
if (chrome.runtime.lastError) {
|
||||||
|
reject(new Error(chrome.runtime.lastError.message));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!response || typeof response !== "object") {
|
||||||
|
reject(new Error("Invalid response from Yomitan backend"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (response.error) {
|
||||||
|
reject(new Error(response.error.message || "Yomitan backend error"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
resolve(response.result);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const optionsFull = await invoke("optionsGetFull", undefined);
|
||||||
|
const profileIndex = optionsFull.profileCurrent;
|
||||||
|
const scanLength =
|
||||||
|
optionsFull.profiles?.[profileIndex]?.options?.scanning?.length ?? 40;
|
||||||
|
|
||||||
|
return await invoke("parseText", {
|
||||||
|
text: ${JSON.stringify(text)},
|
||||||
|
optionsContext: { index: profileIndex },
|
||||||
|
scanLength,
|
||||||
|
useInternalParser: true,
|
||||||
|
useMecabParser: false
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parseResults = await parserWindow.webContents.executeJavaScript(
|
||||||
|
script,
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
return mapYomitanParseResultsToMergedTokens(parseResults);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Yomitan parser request failed:", (err as Error).message);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function tokenizeSubtitleService(
|
||||||
|
text: string,
|
||||||
|
deps: TokenizerServiceDeps,
|
||||||
|
): Promise<SubtitleData> {
|
||||||
|
const displayText = text
|
||||||
|
.replace(/\r\n/g, "\n")
|
||||||
|
.replace(/\\N/g, "\n")
|
||||||
|
.replace(/\\n/g, "\n")
|
||||||
|
.trim();
|
||||||
|
|
||||||
|
if (!displayText) {
|
||||||
|
return { text, tokens: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
const tokenizeText = displayText
|
||||||
|
.replace(/\n/g, " ")
|
||||||
|
.replace(/\s+/g, " ")
|
||||||
|
.trim();
|
||||||
|
|
||||||
|
const yomitanTokens = await parseWithYomitanInternalParser(tokenizeText, deps);
|
||||||
|
if (yomitanTokens && yomitanTokens.length > 0) {
|
||||||
|
return { text: displayText, tokens: yomitanTokens };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const mecabTokens = await deps.tokenizeWithMecab(tokenizeText);
|
||||||
|
if (mecabTokens && mecabTokens.length > 0) {
|
||||||
|
return { text: displayText, tokens: mecabTokens };
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Tokenization error:", (err as Error).message);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { text: displayText, tokens: null };
|
||||||
|
}
|
||||||
109
src/core/services/yomitan-extension-loader-service.ts
Normal file
109
src/core/services/yomitan-extension-loader-service.ts
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
import { BrowserWindow, Extension, session } from "electron";
|
||||||
|
import * as fs from "fs";
|
||||||
|
import * as path from "path";
|
||||||
|
|
||||||
|
export interface YomitanExtensionLoaderDeps {
|
||||||
|
userDataPath: string;
|
||||||
|
getYomitanParserWindow: () => BrowserWindow | null;
|
||||||
|
setYomitanParserWindow: (window: BrowserWindow | null) => void;
|
||||||
|
setYomitanParserReadyPromise: (promise: Promise<void> | null) => void;
|
||||||
|
setYomitanParserInitPromise: (promise: Promise<boolean> | null) => void;
|
||||||
|
setYomitanExtension: (extension: Extension | null) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureExtensionCopy(sourceDir: string, userDataPath: string): string {
|
||||||
|
if (process.platform === "win32") {
|
||||||
|
return sourceDir;
|
||||||
|
}
|
||||||
|
|
||||||
|
const extensionsRoot = path.join(userDataPath, "extensions");
|
||||||
|
const targetDir = path.join(extensionsRoot, "yomitan");
|
||||||
|
|
||||||
|
const sourceManifest = path.join(sourceDir, "manifest.json");
|
||||||
|
const targetManifest = path.join(targetDir, "manifest.json");
|
||||||
|
|
||||||
|
let shouldCopy = !fs.existsSync(targetDir);
|
||||||
|
if (
|
||||||
|
!shouldCopy &&
|
||||||
|
fs.existsSync(sourceManifest) &&
|
||||||
|
fs.existsSync(targetManifest)
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const sourceVersion = (
|
||||||
|
JSON.parse(fs.readFileSync(sourceManifest, "utf-8")) as {
|
||||||
|
version: string;
|
||||||
|
}
|
||||||
|
).version;
|
||||||
|
const targetVersion = (
|
||||||
|
JSON.parse(fs.readFileSync(targetManifest, "utf-8")) as {
|
||||||
|
version: string;
|
||||||
|
}
|
||||||
|
).version;
|
||||||
|
shouldCopy = sourceVersion !== targetVersion;
|
||||||
|
} catch {
|
||||||
|
shouldCopy = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (shouldCopy) {
|
||||||
|
fs.mkdirSync(extensionsRoot, { recursive: true });
|
||||||
|
fs.rmSync(targetDir, { recursive: true, force: true });
|
||||||
|
fs.cpSync(sourceDir, targetDir, { recursive: true });
|
||||||
|
console.log(`Copied yomitan extension to ${targetDir}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return targetDir;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadYomitanExtensionService(
|
||||||
|
deps: YomitanExtensionLoaderDeps,
|
||||||
|
): Promise<Extension | null> {
|
||||||
|
const searchPaths = [
|
||||||
|
path.join(__dirname, "..", "..", "..", "vendor", "yomitan"),
|
||||||
|
path.join(process.resourcesPath, "yomitan"),
|
||||||
|
"/usr/share/SubMiner/yomitan",
|
||||||
|
path.join(deps.userDataPath, "yomitan"),
|
||||||
|
];
|
||||||
|
|
||||||
|
let extPath: string | null = null;
|
||||||
|
for (const p of searchPaths) {
|
||||||
|
if (fs.existsSync(p)) {
|
||||||
|
extPath = p;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!extPath) {
|
||||||
|
console.error("Yomitan extension not found in any search path");
|
||||||
|
console.error("Install Yomitan to one of:", searchPaths);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
extPath = ensureExtensionCopy(extPath, deps.userDataPath);
|
||||||
|
|
||||||
|
const parserWindow = deps.getYomitanParserWindow();
|
||||||
|
if (parserWindow && !parserWindow.isDestroyed()) {
|
||||||
|
parserWindow.destroy();
|
||||||
|
}
|
||||||
|
deps.setYomitanParserWindow(null);
|
||||||
|
deps.setYomitanParserReadyPromise(null);
|
||||||
|
deps.setYomitanParserInitPromise(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const extensions = session.defaultSession.extensions;
|
||||||
|
const extension = extensions
|
||||||
|
? await extensions.loadExtension(extPath, {
|
||||||
|
allowFileAccess: true,
|
||||||
|
})
|
||||||
|
: await session.defaultSession.loadExtension(extPath, {
|
||||||
|
allowFileAccess: true,
|
||||||
|
});
|
||||||
|
deps.setYomitanExtension(extension);
|
||||||
|
return extension;
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to load Yomitan extension:", (err as Error).message);
|
||||||
|
console.error("Full error:", err);
|
||||||
|
deps.setYomitanExtension(null);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
1146
src/main.ts
1146
src/main.ts
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user