refactor: extract texthooker and subtitle websocket services

This commit is contained in:
2026-02-09 19:35:19 -08:00
parent 6922a6741f
commit f61524bef4
3 changed files with 155 additions and 121 deletions

View File

@@ -0,0 +1,57 @@
import * as fs from "fs";
import * as os from "os";
import * as path from "path";
import WebSocket from "ws";
export function hasMpvWebsocketPlugin(): boolean {
const mpvWebsocketPath = path.join(
os.homedir(),
".config",
"mpv",
"mpv_websocket",
);
return fs.existsSync(mpvWebsocketPath);
}
export class SubtitleWebSocketService {
private server: WebSocket.Server | null = null;
public isRunning(): boolean {
return this.server !== null;
}
public start(port: number, getCurrentSubtitleText: () => string): void {
this.server = new WebSocket.Server({ port, host: "127.0.0.1" });
this.server.on("connection", (ws: WebSocket) => {
console.log("WebSocket client connected");
const currentText = getCurrentSubtitleText();
if (currentText) {
ws.send(JSON.stringify({ sentence: currentText }));
}
});
this.server.on("error", (err: Error) => {
console.error("WebSocket server error:", err.message);
});
console.log(`Subtitle WebSocket server running on ws://127.0.0.1:${port}`);
}
public broadcast(text: string): void {
if (!this.server) return;
const message = JSON.stringify({ sentence: text });
for (const client of this.server.clients) {
if (client.readyState === WebSocket.OPEN) {
client.send(message);
}
}
}
public stop(): void {
if (this.server) {
this.server.close();
this.server = null;
}
}
}

View File

@@ -0,0 +1,82 @@
import * as fs from "fs";
import * as http from "http";
import * as path from "path";
export class TexthookerService {
private server: http.Server | null = null;
public isRunning(): boolean {
return this.server !== null;
}
public start(port: number): http.Server | null {
const texthookerPath = this.getTexthookerPath();
if (!texthookerPath) {
console.error("texthooker-ui not found");
return null;
}
this.server = http.createServer((req, res) => {
const urlPath = (req.url || "/").split("?")[0];
const filePath = path.join(
texthookerPath,
urlPath === "/" ? "index.html" : urlPath,
);
const ext = path.extname(filePath);
const mimeTypes: Record<string, string> = {
".html": "text/html",
".js": "application/javascript",
".css": "text/css",
".json": "application/json",
".png": "image/png",
".svg": "image/svg+xml",
".ttf": "font/ttf",
".woff": "font/woff",
".woff2": "font/woff2",
};
fs.readFile(filePath, (err, data) => {
if (err) {
res.writeHead(404);
res.end("Not found");
return;
}
res.writeHead(200, { "Content-Type": mimeTypes[ext] || "text/plain" });
res.end(data);
});
});
this.server.listen(port, "127.0.0.1", () => {
console.log(`Texthooker server running at http://127.0.0.1:${port}`);
});
return this.server;
}
public stop(): void {
if (this.server) {
this.server.close();
this.server = null;
}
}
private getTexthookerPath(): string | null {
const searchPaths = [
path.join(__dirname, "..", "..", "..", "vendor", "texthooker-ui", "docs"),
path.join(
process.resourcesPath,
"app",
"vendor",
"texthooker-ui",
"docs",
),
];
for (const candidate of searchPaths) {
if (fs.existsSync(path.join(candidate, "index.html"))) {
return candidate;
}
}
return null;
}
}

View File

@@ -51,7 +51,6 @@ import * as https from "https";
import * as os from "os"; import * as os from "os";
import * as fs from "fs"; import * as fs from "fs";
import * as crypto from "crypto"; import * as crypto from "crypto";
import WebSocket from "ws";
import { MecabTokenizer } from "./mecab-tokenizer"; import { MecabTokenizer } from "./mecab-tokenizer";
import { mergeTokens } from "./token-merger"; import { mergeTokens } from "./token-merger";
import { createWindowTracker, BaseWindowTracker } from "./window-trackers"; import { createWindowTracker, BaseWindowTracker } from "./window-trackers";
@@ -125,6 +124,11 @@ import {
} from "./core/utils/electron-backend"; } from "./core/utils/electron-backend";
import { asBoolean, asFiniteNumber, asString } from "./core/utils/coerce"; import { asBoolean, asFiniteNumber, asString } from "./core/utils/coerce";
import { resolveKeybindings } from "./core/utils/keybindings"; import { resolveKeybindings } from "./core/utils/keybindings";
import { TexthookerService } from "./core/services/texthooker-service";
import {
hasMpvWebsocketPlugin,
SubtitleWebSocketService,
} from "./core/services/subtitle-ws-service";
import { import {
ConfigService, ConfigService,
DEFAULT_CONFIG, DEFAULT_CONFIG,
@@ -138,13 +142,13 @@ if (process.platform === "linux") {
} }
const DEFAULT_TEXTHOOKER_PORT = 5174; const DEFAULT_TEXTHOOKER_PORT = 5174;
let texthookerServer: http.Server | null = null;
let subtitleWebSocketServer: WebSocket.Server | null = null;
const CONFIG_DIR = path.join(os.homedir(), ".config", "SubMiner"); const CONFIG_DIR = path.join(os.homedir(), ".config", "SubMiner");
const USER_DATA_PATH = CONFIG_DIR; const USER_DATA_PATH = CONFIG_DIR;
const configService = new ConfigService(CONFIG_DIR); const configService = new ConfigService(CONFIG_DIR);
const isDev = const isDev =
process.argv.includes("--dev") || process.argv.includes("--debug"); process.argv.includes("--dev") || process.argv.includes("--debug");
const texthookerService = new TexthookerService();
const subtitleWsService = new SubtitleWebSocketService();
function getDefaultSocketPath(): string { function getDefaultSocketPath(): string {
if (process.platform === "win32") { if (process.platform === "win32") {
@@ -503,115 +507,6 @@ function updateCurrentMediaPath(mediaPath: unknown): void {
broadcastToOverlayWindows("subtitle-position:set", position); broadcastToOverlayWindows("subtitle-position:set", position);
} }
function getTexthookerPath(): string | null {
const searchPaths = [
path.join(__dirname, "..", "vendor", "texthooker-ui", "docs"),
path.join(process.resourcesPath, "app", "vendor", "texthooker-ui", "docs"),
];
for (const p of searchPaths) {
if (fs.existsSync(path.join(p, "index.html"))) {
return p;
}
}
return null;
}
function startTexthookerServer(port: number): http.Server | null {
const texthookerPath = getTexthookerPath();
if (!texthookerPath) {
console.error("texthooker-ui not found");
return null;
}
texthookerServer = http.createServer((req, res) => {
let urlPath = (req.url || "/").split("?")[0];
let filePath = path.join(
texthookerPath,
urlPath === "/" ? "index.html" : urlPath,
);
const ext = path.extname(filePath);
const mimeTypes: Record<string, string> = {
".html": "text/html",
".js": "application/javascript",
".css": "text/css",
".json": "application/json",
".png": "image/png",
".svg": "image/svg+xml",
".ttf": "font/ttf",
".woff": "font/woff",
".woff2": "font/woff2",
};
fs.readFile(filePath, (err, data) => {
if (err) {
res.writeHead(404);
res.end("Not found");
return;
}
res.writeHead(200, { "Content-Type": mimeTypes[ext] || "text/plain" });
res.end(data);
});
});
texthookerServer.listen(port, "127.0.0.1", () => {
console.log(`Texthooker server running at http://127.0.0.1:${port}`);
});
return texthookerServer;
}
function stopTexthookerServer(): void {
if (texthookerServer) {
texthookerServer.close();
texthookerServer = null;
}
}
function hasMpvWebsocket(): boolean {
const mpvWebsocketPath = path.join(
os.homedir(),
".config",
"mpv",
"mpv_websocket",
);
return fs.existsSync(mpvWebsocketPath);
}
function startSubtitleWebSocketServer(port: number): void {
subtitleWebSocketServer = new WebSocket.Server({ port, host: "127.0.0.1" });
subtitleWebSocketServer.on("connection", (ws: WebSocket) => {
console.log("WebSocket client connected");
if (currentSubText) {
ws.send(JSON.stringify({ sentence: currentSubText }));
}
});
subtitleWebSocketServer.on("error", (err: Error) => {
console.error("WebSocket server error:", err.message);
});
console.log(`Subtitle WebSocket server running on ws://127.0.0.1:${port}`);
}
function broadcastSubtitle(text: string): void {
if (!subtitleWebSocketServer) return;
const message = JSON.stringify({ sentence: text });
for (const client of subtitleWebSocketServer.clients) {
if (client.readyState === WebSocket.OPEN) {
client.send(message);
}
}
}
function stopSubtitleWebSocketServer(): void {
if (subtitleWebSocketServer) {
subtitleWebSocketServer.close();
subtitleWebSocketServer = null;
}
}
const AUTOSUBSYNC_SPINNER_FRAMES = ["|", "/", "-", "\\"]; const AUTOSUBSYNC_SPINNER_FRAMES = ["|", "/", "-", "\\"];
let subsyncInProgress = false; let subsyncInProgress = false;
@@ -723,9 +618,9 @@ if (initialArgs.generateConfig && !shouldStartApp(initialArgs)) {
if ( if (
wsEnabled === true || wsEnabled === true ||
(wsEnabled === "auto" && !hasMpvWebsocket()) (wsEnabled === "auto" && !hasMpvWebsocketPlugin())
) { ) {
startSubtitleWebSocketServer(wsPort); subtitleWsService.start(wsPort, () => currentSubText);
} else if (wsEnabled === "auto") { } else if (wsEnabled === "auto") {
console.log( console.log(
"mpv_websocket detected, skipping built-in WebSocket server", "mpv_websocket detected, skipping built-in WebSocket server",
@@ -759,8 +654,8 @@ if (initialArgs.generateConfig && !shouldStartApp(initialArgs)) {
app.on("will-quit", () => { app.on("will-quit", () => {
globalShortcut.unregisterAll(); globalShortcut.unregisterAll();
stopSubtitleWebSocketServer(); subtitleWsService.stop();
stopTexthookerServer(); texthookerService.stop();
if (yomitanParserWindow && !yomitanParserWindow.isDestroyed()) { if (yomitanParserWindow && !yomitanParserWindow.isDestroyed()) {
yomitanParserWindow.destroy(); yomitanParserWindow.destroy();
} }
@@ -848,7 +743,7 @@ function handleCliCommand(
} }
} }
if (args.texthookerPort !== undefined) { if (args.texthookerPort !== undefined) {
if (texthookerServer) { if (texthookerService.isRunning()) {
console.warn( console.warn(
"Ignoring --port override because the texthooker server is already running.", "Ignoring --port override because the texthooker server is already running.",
); );
@@ -925,8 +820,8 @@ function handleCliCommand(
} else if (args.openRuntimeOptions) { } else if (args.openRuntimeOptions) {
openRuntimeOptionsPalette(); openRuntimeOptionsPalette();
} else if (args.texthooker) { } else if (args.texthooker) {
if (!texthookerServer) { if (!texthookerService.isRunning()) {
startTexthookerServer(texthookerPort); texthookerService.start(texthookerPort);
} }
const config = getResolvedConfig(); const config = getResolvedConfig();
const openBrowser = config.texthooker?.openBrowser !== false; const openBrowser = config.texthooker?.openBrowser !== false;
@@ -1255,7 +1150,7 @@ class MpvIpcClient implements MpvClient {
this.currentSubEnd, this.currentSubEnd,
); );
} }
broadcastSubtitle(currentSubText); subtitleWsService.broadcast(currentSubText);
if (getOverlayWindows().length > 0) { if (getOverlayWindows().length > 0) {
const subtitleData = await tokenizeSubtitle(currentSubText); const subtitleData = await tokenizeSubtitle(currentSubText);
broadcastToOverlayWindows("subtitle:set", subtitleData); broadcastToOverlayWindows("subtitle:set", subtitleData);
@@ -1424,7 +1319,7 @@ class MpvIpcClient implements MpvClient {
if (mpvClient) { if (mpvClient) {
mpvClient.currentSubText = currentSubText; mpvClient.currentSubText = currentSubText;
} }
broadcastSubtitle(currentSubText); subtitleWsService.broadcast(currentSubText);
if (getOverlayWindows().length > 0) { if (getOverlayWindows().length > 0) {
tokenizeSubtitle(currentSubText).then((subtitleData) => { tokenizeSubtitle(currentSubText).then((subtitleData) => {
broadcastToOverlayWindows("subtitle:set", subtitleData); broadcastToOverlayWindows("subtitle:set", subtitleData);