initial commit

This commit is contained in:
2026-02-09 19:04:19 -08:00
commit 272d92169d
531 changed files with 196294 additions and 0 deletions

228
src/anki-connect.ts Normal file
View File

@@ -0,0 +1,228 @@
/*
* SubMiner - Subtitle mining overlay for mpv
* Copyright (C) 2024 sudacode
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import axios, { AxiosInstance } from "axios";
import http from "http";
import https from "https";
import { createLogger } from "./logger";
const log = createLogger("anki");
interface AnkiConnectRequest {
action: string;
version: number;
params: Record<string, unknown>;
}
interface AnkiConnectResponse {
result: unknown;
error: string | null;
}
export class AnkiConnectClient {
private client: AxiosInstance;
private url: string;
private backoffMs = 200;
private maxBackoffMs = 5000;
private consecutiveFailures = 0;
private maxConsecutiveFailures = 5;
constructor(url: string) {
this.url = url;
const httpAgent = new http.Agent({
keepAlive: true,
keepAliveMsecs: 1000,
maxSockets: 5,
maxFreeSockets: 2,
timeout: 10000,
});
const httpsAgent = new https.Agent({
keepAlive: true,
keepAliveMsecs: 1000,
maxSockets: 5,
maxFreeSockets: 2,
timeout: 10000,
});
this.client = axios.create({
baseURL: url,
timeout: 10000,
httpAgent,
httpsAgent,
});
}
private async sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
private isRetryableError(error: unknown): boolean {
if (!error || typeof error !== "object") return false;
const code = (error as Record<string, unknown>).code;
const message =
typeof (error as Record<string, unknown>).message === "string"
? ((error as Record<string, unknown>).message as string).toLowerCase()
: "";
return (
code === "ECONNRESET" ||
code === "ETIMEDOUT" ||
code === "ENOTFOUND" ||
code === "ECONNREFUSED" ||
code === "EPIPE" ||
message.includes("socket hang up") ||
message.includes("network error") ||
message.includes("timeout")
);
}
async invoke(
action: string,
params: Record<string, unknown> = {},
options: { timeout?: number; maxRetries?: number } = {},
): Promise<unknown> {
const maxRetries = options.maxRetries ?? 3;
let lastError: Error | null = null;
const isMediaUpload = action === "storeMediaFile";
const requestTimeout = options.timeout || (isMediaUpload ? 30000 : 10000);
for (let attempt = 0; attempt <= maxRetries; attempt++) {
try {
if (attempt > 0) {
const delay = Math.min(
this.backoffMs * Math.pow(2, attempt - 1),
this.maxBackoffMs,
);
log.info(
`AnkiConnect retry ${attempt}/${maxRetries} after ${delay}ms delay`,
);
await this.sleep(delay);
}
const response = await this.client.post<AnkiConnectResponse>(
"",
{
action,
version: 6,
params,
} as AnkiConnectRequest,
{
timeout: requestTimeout,
},
);
this.consecutiveFailures = 0;
this.backoffMs = 200;
if (response.data.error) {
throw new Error(response.data.error);
}
return response.data.result;
} catch (error) {
lastError = error as Error;
this.consecutiveFailures++;
if (!this.isRetryableError(error) || attempt === maxRetries) {
if (this.consecutiveFailures < this.maxConsecutiveFailures) {
log.error(
`AnkiConnect error (attempt ${this.consecutiveFailures}/${this.maxConsecutiveFailures}):`,
lastError.message,
);
} else if (this.consecutiveFailures === this.maxConsecutiveFailures) {
log.error(
"AnkiConnect: Too many consecutive failures, suppressing further error logs",
);
}
throw lastError;
}
}
}
throw lastError || new Error("Unknown error");
}
async findNotes(
query: string,
options?: { maxRetries?: number },
): Promise<number[]> {
const result = await this.invoke("findNotes", { query }, options);
return (result as number[]) || [];
}
async notesInfo(noteIds: number[]): Promise<Record<string, unknown>[]> {
const result = await this.invoke("notesInfo", { notes: noteIds });
return (result as Record<string, unknown>[]) || [];
}
async updateNoteFields(
noteId: number,
fields: Record<string, string>,
): Promise<void> {
await this.invoke("updateNoteFields", {
note: {
id: noteId,
fields,
},
});
}
async storeMediaFile(filename: string, data: Buffer): Promise<void> {
const base64Data = data.toString("base64");
const sizeKB = Math.round(base64Data.length / 1024);
log.info(`Uploading media file: ${filename} (${sizeKB}KB)`);
await this.invoke(
"storeMediaFile",
{
filename,
data: base64Data,
},
{ timeout: 30000 },
);
}
async addNote(
deckName: string,
modelName: string,
fields: Record<string, string>,
): Promise<number> {
const result = await this.invoke("addNote", {
note: { deckName, modelName, fields },
});
return result as number;
}
async deleteNotes(noteIds: number[]): Promise<void> {
await this.invoke("deleteNotes", { notes: noteIds });
}
async retrieveMediaFile(filename: string): Promise<string> {
const result = await this.invoke("retrieveMediaFile", { filename });
return (result as string) || "";
}
resetBackoff(): void {
this.backoffMs = 200;
this.consecutiveFailures = 0;
}
}

2679
src/anki-integration.ts Normal file

File diff suppressed because it is too large Load Diff

79
src/config/config.test.ts Normal file
View File

@@ -0,0 +1,79 @@
import test from "node:test";
import assert from "node:assert/strict";
import * as fs from "fs";
import * as os from "os";
import * as path from "path";
import { ConfigService } from "./service";
import { DEFAULT_CONFIG, RUNTIME_OPTION_REGISTRY } from "./definitions";
import { generateConfigTemplate } from "./template";
function makeTempDir(): string {
return fs.mkdtempSync(path.join(os.tmpdir(), "subminer-config-test-"));
}
test("loads defaults when config is missing", () => {
const dir = makeTempDir();
const service = new ConfigService(dir);
const config = service.getConfig();
assert.equal(config.websocket.port, DEFAULT_CONFIG.websocket.port);
assert.equal(config.ankiConnect.behavior.autoUpdateNewCards, true);
});
test("parses jsonc and warns/falls back on invalid value", () => {
const dir = makeTempDir();
fs.writeFileSync(
path.join(dir, "config.jsonc"),
`{
// invalid websocket port
"websocket": { "port": "bad" }
}`,
"utf-8",
);
const service = new ConfigService(dir);
const config = service.getConfig();
assert.equal(config.websocket.port, DEFAULT_CONFIG.websocket.port);
assert.ok(service.getWarnings().some((w) => w.path === "websocket.port"));
});
test("parses invisible overlay config and new global shortcuts", () => {
const dir = makeTempDir();
fs.writeFileSync(
path.join(dir, "config.jsonc"),
`{
"shortcuts": {
"toggleVisibleOverlayGlobal": "Alt+Shift+U",
"toggleInvisibleOverlayGlobal": "Alt+Shift+I"
},
"invisibleOverlay": {
"startupVisibility": "hidden"
},
"bind_visible_overlay_to_mpv_sub_visibility": false,
"youtubeSubgen": {
"primarySubLanguages": ["ja", "jpn", "jp"]
}
}`,
"utf-8",
);
const service = new ConfigService(dir);
const config = service.getConfig();
assert.equal(config.shortcuts.toggleVisibleOverlayGlobal, "Alt+Shift+U");
assert.equal(config.shortcuts.toggleInvisibleOverlayGlobal, "Alt+Shift+I");
assert.equal(config.invisibleOverlay.startupVisibility, "hidden");
assert.equal(config.bind_visible_overlay_to_mpv_sub_visibility, false);
assert.deepEqual(config.youtubeSubgen.primarySubLanguages, ["ja", "jpn", "jp"]);
});
test("runtime options registry is centralized", () => {
const ids = RUNTIME_OPTION_REGISTRY.map((entry) => entry.id);
assert.deepEqual(ids, ["anki.autoUpdateNewCards", "anki.kikuFieldGrouping"]);
});
test("template generator includes known keys", () => {
const output = generateConfigTemplate(DEFAULT_CONFIG);
assert.match(output, /"ankiConnect":/);
assert.match(output, /"websocket":/);
assert.match(output, /"youtubeSubgen":/);
assert.match(output, /auto-generated from src\/config\/definitions.ts/);
});

474
src/config/definitions.ts Normal file
View File

@@ -0,0 +1,474 @@
import {
AnkiConnectConfig,
Config,
RawConfig,
ResolvedConfig,
RuntimeOptionId,
RuntimeOptionScope,
RuntimeOptionValue,
RuntimeOptionValueType,
} from "../types";
export type ConfigValueKind =
| "boolean"
| "number"
| "string"
| "enum"
| "array"
| "object";
export interface RuntimeOptionRegistryEntry {
id: RuntimeOptionId;
path: string;
label: string;
scope: RuntimeOptionScope;
valueType: RuntimeOptionValueType;
allowedValues: RuntimeOptionValue[];
defaultValue: RuntimeOptionValue;
requiresRestart: boolean;
formatValueForOsd: (value: RuntimeOptionValue) => string;
toAnkiPatch: (value: RuntimeOptionValue) => Partial<AnkiConnectConfig>;
}
export interface ConfigOptionRegistryEntry {
path: string;
kind: ConfigValueKind;
defaultValue: unknown;
description: string;
enumValues?: readonly string[];
runtime?: RuntimeOptionRegistryEntry;
}
export interface ConfigTemplateSection {
title: string;
description: string[];
key: keyof ResolvedConfig;
notes?: string[];
}
export const SPECIAL_COMMANDS = {
SUBSYNC_TRIGGER: "__subsync-trigger",
RUNTIME_OPTIONS_OPEN: "__runtime-options-open",
RUNTIME_OPTION_CYCLE_PREFIX: "__runtime-option-cycle:",
REPLAY_SUBTITLE: "__replay-subtitle",
PLAY_NEXT_SUBTITLE: "__play-next-subtitle",
} as const;
export const DEFAULT_KEYBINDINGS: NonNullable<ResolvedConfig["keybindings"]> = [
{ key: "Space", command: ["cycle", "pause"] },
{ key: "ArrowRight", command: ["seek", 5] },
{ key: "ArrowLeft", command: ["seek", -5] },
{ key: "ArrowUp", command: ["seek", 60] },
{ key: "ArrowDown", command: ["seek", -60] },
{ key: "Shift+KeyH", command: ["sub-seek", -1] },
{ key: "Shift+KeyL", command: ["sub-seek", 1] },
{ key: "Ctrl+Shift+KeyH", command: [SPECIAL_COMMANDS.REPLAY_SUBTITLE] },
{ key: "Ctrl+Shift+KeyL", command: [SPECIAL_COMMANDS.PLAY_NEXT_SUBTITLE] },
{ key: "KeyQ", command: ["quit"] },
{ key: "Ctrl+KeyW", command: ["quit"] },
];
export const DEFAULT_CONFIG: ResolvedConfig = {
subtitlePosition: { yPercent: 10 },
keybindings: [],
websocket: {
enabled: "auto",
port: 6677,
},
texthooker: {
openBrowser: true,
},
ankiConnect: {
enabled: false,
url: "http://127.0.0.1:8765",
pollingRate: 3000,
fields: {
audio: "ExpressionAudio",
image: "Picture",
sentence: "Sentence",
miscInfo: "MiscInfo",
translation: "SelectionText",
},
ai: {
enabled: false,
alwaysUseAiTranslation: false,
apiKey: "",
model: "openai/gpt-4o-mini",
baseUrl: "https://openrouter.ai/api",
targetLanguage: "English",
systemPrompt:
"You are a translation engine. Return only the translated text with no explanations.",
},
media: {
generateAudio: true,
generateImage: true,
imageType: "static",
imageFormat: "jpg",
imageQuality: 92,
imageMaxWidth: undefined,
imageMaxHeight: undefined,
animatedFps: 10,
animatedMaxWidth: 640,
animatedMaxHeight: undefined,
animatedCrf: 35,
audioPadding: 0.5,
fallbackDuration: 3.0,
maxMediaDuration: 30,
},
behavior: {
overwriteAudio: true,
overwriteImage: true,
mediaInsertMode: "append",
highlightWord: true,
notificationType: "osd",
autoUpdateNewCards: true,
},
metadata: {
pattern: "[SubMiner] %f (%t)",
},
isLapis: {
enabled: false,
sentenceCardModel: "Japanese sentences",
sentenceCardSentenceField: "Sentence",
sentenceCardAudioField: "SentenceAudio",
},
isKiku: {
enabled: false,
fieldGrouping: "disabled",
deleteDuplicateInAuto: true,
},
},
shortcuts: {
toggleVisibleOverlayGlobal: "Alt+Shift+O",
toggleInvisibleOverlayGlobal: "Alt+Shift+I",
copySubtitle: "CommandOrControl+C",
copySubtitleMultiple: "CommandOrControl+Shift+C",
updateLastCardFromClipboard: "CommandOrControl+V",
triggerFieldGrouping: "CommandOrControl+G",
triggerSubsync: "Ctrl+Alt+S",
mineSentence: "CommandOrControl+S",
mineSentenceMultiple: "CommandOrControl+Shift+S",
multiCopyTimeoutMs: 3000,
toggleSecondarySub: "CommandOrControl+Shift+V",
markAudioCard: "CommandOrControl+Shift+A",
openRuntimeOptions: "CommandOrControl+Shift+O",
},
secondarySub: {
secondarySubLanguages: [],
autoLoadSecondarySub: false,
defaultMode: "hover",
},
subsync: {
defaultMode: "auto",
alass_path: "",
ffsubsync_path: "",
ffmpeg_path: "",
},
subtitleStyle: {
fontFamily:
"Noto Sans CJK JP Regular, Noto Sans CJK JP, Arial Unicode MS, Arial, sans-serif",
fontSize: 35,
fontColor: "#cad3f5",
fontWeight: "normal",
fontStyle: "normal",
backgroundColor: "rgba(54, 58, 79, 0.5)",
secondary: {
fontSize: 24,
fontColor: "#ffffff",
backgroundColor: "transparent",
fontWeight: "normal",
fontStyle: "normal",
fontFamily:
"Noto Sans CJK JP Regular, Noto Sans CJK JP, Arial Unicode MS, Arial, sans-serif",
},
},
auto_start_overlay: false,
bind_visible_overlay_to_mpv_sub_visibility: true,
jimaku: {
apiBaseUrl: "https://jimaku.cc",
languagePreference: "ja",
maxEntryResults: 10,
},
youtubeSubgen: {
mode: "automatic",
whisperBin: "",
whisperModel: "",
primarySubLanguages: ["ja", "jpn"],
},
invisibleOverlay: {
startupVisibility: "platform-default",
},
};
export const DEFAULT_ANKI_CONNECT_CONFIG = DEFAULT_CONFIG.ankiConnect;
export const RUNTIME_OPTION_REGISTRY: RuntimeOptionRegistryEntry[] = [
{
id: "anki.autoUpdateNewCards",
path: "ankiConnect.behavior.autoUpdateNewCards",
label: "Auto Update New Cards",
scope: "ankiConnect",
valueType: "boolean",
allowedValues: [true, false],
defaultValue: DEFAULT_CONFIG.ankiConnect.behavior.autoUpdateNewCards,
requiresRestart: false,
formatValueForOsd: (value) => (value === true ? "On" : "Off"),
toAnkiPatch: (value) => ({
behavior: { autoUpdateNewCards: value === true },
}),
},
{
id: "anki.kikuFieldGrouping",
path: "ankiConnect.isKiku.fieldGrouping",
label: "Kiku Field Grouping",
scope: "ankiConnect",
valueType: "enum",
allowedValues: ["auto", "manual", "disabled"],
defaultValue: "disabled",
requiresRestart: false,
formatValueForOsd: (value) => String(value),
toAnkiPatch: (value) => ({
isKiku: {
fieldGrouping:
value === "auto" || value === "manual" || value === "disabled"
? value
: "disabled",
},
}),
},
];
export const CONFIG_OPTION_REGISTRY: ConfigOptionRegistryEntry[] = [
{
path: "websocket.enabled",
kind: "enum",
enumValues: ["auto", "true", "false"],
defaultValue: DEFAULT_CONFIG.websocket.enabled,
description: "Built-in subtitle websocket server mode.",
},
{
path: "websocket.port",
kind: "number",
defaultValue: DEFAULT_CONFIG.websocket.port,
description: "Built-in subtitle websocket server port.",
},
{
path: "ankiConnect.enabled",
kind: "boolean",
defaultValue: DEFAULT_CONFIG.ankiConnect.enabled,
description: "Enable AnkiConnect integration.",
},
{
path: "ankiConnect.pollingRate",
kind: "number",
defaultValue: DEFAULT_CONFIG.ankiConnect.pollingRate,
description: "Polling interval in milliseconds.",
},
{
path: "ankiConnect.behavior.autoUpdateNewCards",
kind: "boolean",
defaultValue: DEFAULT_CONFIG.ankiConnect.behavior.autoUpdateNewCards,
description: "Automatically update newly added cards.",
runtime: RUNTIME_OPTION_REGISTRY[0],
},
{
path: "ankiConnect.isKiku.fieldGrouping",
kind: "enum",
enumValues: ["auto", "manual", "disabled"],
defaultValue: DEFAULT_CONFIG.ankiConnect.isKiku.fieldGrouping,
description: "Kiku duplicate-card field grouping mode.",
runtime: RUNTIME_OPTION_REGISTRY[1],
},
{
path: "subsync.defaultMode",
kind: "enum",
enumValues: ["auto", "manual"],
defaultValue: DEFAULT_CONFIG.subsync.defaultMode,
description: "Subsync default mode.",
},
{
path: "shortcuts.multiCopyTimeoutMs",
kind: "number",
defaultValue: DEFAULT_CONFIG.shortcuts.multiCopyTimeoutMs,
description: "Timeout for multi-copy/mine modes.",
},
{
path: "bind_visible_overlay_to_mpv_sub_visibility",
kind: "boolean",
defaultValue: DEFAULT_CONFIG.bind_visible_overlay_to_mpv_sub_visibility,
description:
"Link visible overlay toggles to MPV subtitle visibility (primary and secondary).",
},
{
path: "jimaku.languagePreference",
kind: "enum",
enumValues: ["ja", "en", "none"],
defaultValue: DEFAULT_CONFIG.jimaku.languagePreference,
description: "Preferred language used in Jimaku search.",
},
{
path: "jimaku.maxEntryResults",
kind: "number",
defaultValue: DEFAULT_CONFIG.jimaku.maxEntryResults,
description: "Maximum Jimaku search results returned.",
},
{
path: "youtubeSubgen.mode",
kind: "enum",
enumValues: ["automatic", "preprocess", "off"],
defaultValue: DEFAULT_CONFIG.youtubeSubgen.mode,
description: "YouTube subtitle generation mode for the launcher script.",
},
{
path: "youtubeSubgen.whisperBin",
kind: "string",
defaultValue: DEFAULT_CONFIG.youtubeSubgen.whisperBin,
description: "Path to whisper.cpp CLI used as fallback transcription engine.",
},
{
path: "youtubeSubgen.whisperModel",
kind: "string",
defaultValue: DEFAULT_CONFIG.youtubeSubgen.whisperModel,
description: "Path to whisper model used for fallback transcription.",
},
{
path: "youtubeSubgen.primarySubLanguages",
kind: "string",
defaultValue: DEFAULT_CONFIG.youtubeSubgen.primarySubLanguages.join(","),
description:
"Comma-separated primary subtitle language priority used by the launcher.",
},
];
export const CONFIG_TEMPLATE_SECTIONS: ConfigTemplateSection[] = [
{
title: "Overlay Auto-Start",
description: [
"When overlay connects to mpv, automatically show overlay and hide mpv subtitles.",
],
key: "auto_start_overlay",
},
{
title: "Visible Overlay Subtitle Binding",
description: [
"Control whether visible overlay toggles also toggle MPV subtitle visibility.",
"When enabled, visible overlay hides MPV subtitles; when disabled, MPV subtitles are left unchanged.",
],
key: "bind_visible_overlay_to_mpv_sub_visibility",
},
{
title: "Texthooker Server",
description: [
"Control whether browser opens automatically for texthooker.",
],
key: "texthooker",
},
{
title: "WebSocket Server",
description: [
"Built-in WebSocket server broadcasts subtitle text to connected clients.",
"Auto mode disables built-in server if mpv_websocket is detected.",
],
key: "websocket",
},
{
title: "AnkiConnect Integration",
description: ["Automatic Anki updates and media generation options."],
key: "ankiConnect",
},
{
title: "Keyboard Shortcuts",
description: [
"Overlay keyboard shortcuts. Set a shortcut to null to disable.",
],
key: "shortcuts",
},
{
title: "Invisible Overlay",
description: [
"Startup behavior for the invisible interactive subtitle mining layer.",
],
key: "invisibleOverlay",
},
{
title: "Keybindings (MPV Commands)",
description: [
"Extra keybindings that are merged with built-in defaults.",
"Set command to null to disable a default keybinding.",
],
key: "keybindings",
},
{
title: "Subtitle Appearance",
description: ["Primary and secondary subtitle styling."],
key: "subtitleStyle",
},
{
title: "Secondary Subtitles",
description: [
"Dual subtitle track options.",
"Used by subminer YouTube subtitle generation as secondary language preferences.",
],
key: "secondarySub",
},
{
title: "Auto Subtitle Sync",
description: ["Subsync engine and executable paths."],
key: "subsync",
},
{
title: "Subtitle Position",
description: ["Initial vertical subtitle position from the bottom."],
key: "subtitlePosition",
},
{
title: "Jimaku",
description: ["Jimaku API configuration and defaults."],
key: "jimaku",
},
{
title: "YouTube Subtitle Generation",
description: [
"Defaults for subminer YouTube subtitle extraction/transcription mode.",
],
key: "youtubeSubgen",
},
];
export function deepCloneConfig(config: ResolvedConfig): ResolvedConfig {
return JSON.parse(JSON.stringify(config)) as ResolvedConfig;
}
export function deepMergeRawConfig(
base: RawConfig,
patch: RawConfig,
): RawConfig {
const clone = JSON.parse(JSON.stringify(base)) as Record<string, unknown>;
const patchObject = patch as Record<string, unknown>;
const mergeInto = (
target: Record<string, unknown>,
source: Record<string, unknown>,
): void => {
for (const [key, value] of Object.entries(source)) {
if (
value !== null &&
typeof value === "object" &&
!Array.isArray(value) &&
typeof target[key] === "object" &&
target[key] !== null &&
!Array.isArray(target[key])
) {
mergeInto(
target[key] as Record<string, unknown>,
value as Record<string, unknown>,
);
} else {
target[key] = value;
}
}
};
mergeInto(clone, patchObject);
return clone as RawConfig;
}

3
src/config/index.ts Normal file
View File

@@ -0,0 +1,3 @@
export * from "./definitions";
export * from "./service";
export * from "./template";

600
src/config/service.ts Normal file
View File

@@ -0,0 +1,600 @@
import * as fs from "fs";
import * as path from "path";
import { parse as parseJsonc } from "jsonc-parser";
import {
Config,
ConfigValidationWarning,
RawConfig,
ResolvedConfig,
} from "../types";
import {
DEFAULT_CONFIG,
deepCloneConfig,
deepMergeRawConfig,
} from "./definitions";
interface LoadResult {
config: RawConfig;
path: string;
}
function isObject(value: unknown): value is Record<string, unknown> {
return value !== null && typeof value === "object" && !Array.isArray(value);
}
function asNumber(value: unknown): number | undefined {
return typeof value === "number" && Number.isFinite(value)
? value
: undefined;
}
function asString(value: unknown): string | undefined {
return typeof value === "string" ? value : undefined;
}
function asBoolean(value: unknown): boolean | undefined {
return typeof value === "boolean" ? value : undefined;
}
export class ConfigService {
private readonly configDir: string;
private readonly configFileJsonc: string;
private readonly configFileJson: string;
private rawConfig: RawConfig = {};
private resolvedConfig: ResolvedConfig = deepCloneConfig(DEFAULT_CONFIG);
private warnings: ConfigValidationWarning[] = [];
private configPathInUse: string;
constructor(configDir: string) {
this.configDir = configDir;
this.configFileJsonc = path.join(configDir, "config.jsonc");
this.configFileJson = path.join(configDir, "config.json");
this.configPathInUse = this.configFileJsonc;
this.reloadConfig();
}
getConfigPath(): string {
return this.configPathInUse;
}
getConfig(): ResolvedConfig {
return deepCloneConfig(this.resolvedConfig);
}
getRawConfig(): RawConfig {
return JSON.parse(JSON.stringify(this.rawConfig)) as RawConfig;
}
getWarnings(): ConfigValidationWarning[] {
return [...this.warnings];
}
reloadConfig(): ResolvedConfig {
const { config, path: configPath } = this.loadRawConfig();
this.rawConfig = config;
this.configPathInUse = configPath;
const { resolved, warnings } = this.resolveConfig(config);
this.resolvedConfig = resolved;
this.warnings = warnings;
return this.getConfig();
}
saveRawConfig(config: RawConfig): void {
if (!fs.existsSync(this.configDir)) {
fs.mkdirSync(this.configDir, { recursive: true });
}
const targetPath = this.configPathInUse.endsWith(".json")
? this.configPathInUse
: this.configFileJsonc;
fs.writeFileSync(targetPath, JSON.stringify(config, null, 2));
this.rawConfig = config;
this.configPathInUse = targetPath;
const { resolved, warnings } = this.resolveConfig(config);
this.resolvedConfig = resolved;
this.warnings = warnings;
}
patchRawConfig(patch: RawConfig): void {
const merged = deepMergeRawConfig(this.getRawConfig(), patch);
this.saveRawConfig(merged);
}
private loadRawConfig(): LoadResult {
const configPath = fs.existsSync(this.configFileJsonc)
? this.configFileJsonc
: fs.existsSync(this.configFileJson)
? this.configFileJson
: this.configFileJsonc;
if (!fs.existsSync(configPath)) {
return { config: {}, path: configPath };
}
try {
const data = fs.readFileSync(configPath, "utf-8");
const parsed = configPath.endsWith(".jsonc")
? parseJsonc(data)
: JSON.parse(data);
return {
config: isObject(parsed) ? (parsed as Config) : {},
path: configPath,
};
} catch {
return { config: {}, path: configPath };
}
}
private resolveConfig(raw: RawConfig): {
resolved: ResolvedConfig;
warnings: ConfigValidationWarning[];
} {
const warnings: ConfigValidationWarning[] = [];
const resolved = deepCloneConfig(DEFAULT_CONFIG);
const warn = (
path: string,
value: unknown,
fallback: unknown,
message: string,
): void => {
warnings.push({
path,
value,
fallback,
message,
});
};
const src = isObject(raw) ? raw : {};
if (isObject(src.texthooker)) {
const openBrowser = asBoolean(src.texthooker.openBrowser);
if (openBrowser !== undefined) {
resolved.texthooker.openBrowser = openBrowser;
} else if (src.texthooker.openBrowser !== undefined) {
warn(
"texthooker.openBrowser",
src.texthooker.openBrowser,
resolved.texthooker.openBrowser,
"Expected boolean.",
);
}
}
if (isObject(src.websocket)) {
const enabled = src.websocket.enabled;
if (enabled === "auto" || enabled === true || enabled === false) {
resolved.websocket.enabled = enabled;
} else if (enabled !== undefined) {
warn(
"websocket.enabled",
enabled,
resolved.websocket.enabled,
"Expected true, false, or 'auto'.",
);
}
const port = asNumber(src.websocket.port);
if (port !== undefined && port > 0 && port <= 65535) {
resolved.websocket.port = Math.floor(port);
} else if (src.websocket.port !== undefined) {
warn(
"websocket.port",
src.websocket.port,
resolved.websocket.port,
"Expected integer between 1 and 65535.",
);
}
}
if (Array.isArray(src.keybindings)) {
resolved.keybindings = src.keybindings.filter(
(
entry,
): entry is { key: string; command: (string | number)[] | null } => {
if (!isObject(entry)) return false;
if (typeof entry.key !== "string") return false;
if (entry.command === null) return true;
return Array.isArray(entry.command);
},
);
}
if (isObject(src.shortcuts)) {
const shortcutKeys = [
"toggleVisibleOverlayGlobal",
"toggleInvisibleOverlayGlobal",
"copySubtitle",
"copySubtitleMultiple",
"updateLastCardFromClipboard",
"triggerFieldGrouping",
"triggerSubsync",
"mineSentence",
"mineSentenceMultiple",
"toggleSecondarySub",
"markAudioCard",
"openRuntimeOptions",
] as const;
for (const key of shortcutKeys) {
const value = src.shortcuts[key];
if (typeof value === "string" || value === null) {
resolved.shortcuts[key] =
value as (typeof resolved.shortcuts)[typeof key];
} else if (value !== undefined) {
warn(
`shortcuts.${key}`,
value,
resolved.shortcuts[key],
"Expected string or null.",
);
}
}
const timeout = asNumber(src.shortcuts.multiCopyTimeoutMs);
if (timeout !== undefined && timeout > 0) {
resolved.shortcuts.multiCopyTimeoutMs = Math.floor(timeout);
} else if (src.shortcuts.multiCopyTimeoutMs !== undefined) {
warn(
"shortcuts.multiCopyTimeoutMs",
src.shortcuts.multiCopyTimeoutMs,
resolved.shortcuts.multiCopyTimeoutMs,
"Expected positive number.",
);
}
}
if (isObject(src.invisibleOverlay)) {
const startupVisibility = src.invisibleOverlay.startupVisibility;
if (
startupVisibility === "platform-default" ||
startupVisibility === "visible" ||
startupVisibility === "hidden"
) {
resolved.invisibleOverlay.startupVisibility = startupVisibility;
} else if (startupVisibility !== undefined) {
warn(
"invisibleOverlay.startupVisibility",
startupVisibility,
resolved.invisibleOverlay.startupVisibility,
"Expected platform-default, visible, or hidden.",
);
}
}
if (isObject(src.secondarySub)) {
if (Array.isArray(src.secondarySub.secondarySubLanguages)) {
resolved.secondarySub.secondarySubLanguages =
src.secondarySub.secondarySubLanguages.filter(
(item): item is string => typeof item === "string",
);
}
const autoLoad = asBoolean(src.secondarySub.autoLoadSecondarySub);
if (autoLoad !== undefined) {
resolved.secondarySub.autoLoadSecondarySub = autoLoad;
}
const defaultMode = src.secondarySub.defaultMode;
if (
defaultMode === "hidden" ||
defaultMode === "visible" ||
defaultMode === "hover"
) {
resolved.secondarySub.defaultMode = defaultMode;
} else if (defaultMode !== undefined) {
warn(
"secondarySub.defaultMode",
defaultMode,
resolved.secondarySub.defaultMode,
"Expected hidden, visible, or hover.",
);
}
}
if (isObject(src.subsync)) {
const mode = src.subsync.defaultMode;
if (mode === "auto" || mode === "manual") {
resolved.subsync.defaultMode = mode;
} else if (mode !== undefined) {
warn(
"subsync.defaultMode",
mode,
resolved.subsync.defaultMode,
"Expected auto or manual.",
);
}
const alass = asString(src.subsync.alass_path);
if (alass !== undefined) resolved.subsync.alass_path = alass;
const ffsubsync = asString(src.subsync.ffsubsync_path);
if (ffsubsync !== undefined) resolved.subsync.ffsubsync_path = ffsubsync;
const ffmpeg = asString(src.subsync.ffmpeg_path);
if (ffmpeg !== undefined) resolved.subsync.ffmpeg_path = ffmpeg;
}
if (isObject(src.subtitlePosition)) {
const y = asNumber(src.subtitlePosition.yPercent);
if (y !== undefined) {
resolved.subtitlePosition.yPercent = y;
}
}
if (isObject(src.jimaku)) {
const apiKey = asString(src.jimaku.apiKey);
if (apiKey !== undefined) resolved.jimaku.apiKey = apiKey;
const apiKeyCommand = asString(src.jimaku.apiKeyCommand);
if (apiKeyCommand !== undefined)
resolved.jimaku.apiKeyCommand = apiKeyCommand;
const apiBaseUrl = asString(src.jimaku.apiBaseUrl);
if (apiBaseUrl !== undefined) resolved.jimaku.apiBaseUrl = apiBaseUrl;
const lang = src.jimaku.languagePreference;
if (lang === "ja" || lang === "en" || lang === "none") {
resolved.jimaku.languagePreference = lang;
} else if (lang !== undefined) {
warn(
"jimaku.languagePreference",
lang,
resolved.jimaku.languagePreference,
"Expected ja, en, or none.",
);
}
const maxEntryResults = asNumber(src.jimaku.maxEntryResults);
if (maxEntryResults !== undefined && maxEntryResults > 0) {
resolved.jimaku.maxEntryResults = Math.floor(maxEntryResults);
} else if (src.jimaku.maxEntryResults !== undefined) {
warn(
"jimaku.maxEntryResults",
src.jimaku.maxEntryResults,
resolved.jimaku.maxEntryResults,
"Expected positive number.",
);
}
}
if (isObject(src.youtubeSubgen)) {
const mode = src.youtubeSubgen.mode;
if (mode === "automatic" || mode === "preprocess" || mode === "off") {
resolved.youtubeSubgen.mode = mode;
} else if (mode !== undefined) {
warn(
"youtubeSubgen.mode",
mode,
resolved.youtubeSubgen.mode,
"Expected automatic, preprocess, or off.",
);
}
const whisperBin = asString(src.youtubeSubgen.whisperBin);
if (whisperBin !== undefined) {
resolved.youtubeSubgen.whisperBin = whisperBin;
} else if (src.youtubeSubgen.whisperBin !== undefined) {
warn(
"youtubeSubgen.whisperBin",
src.youtubeSubgen.whisperBin,
resolved.youtubeSubgen.whisperBin,
"Expected string.",
);
}
const whisperModel = asString(src.youtubeSubgen.whisperModel);
if (whisperModel !== undefined) {
resolved.youtubeSubgen.whisperModel = whisperModel;
} else if (src.youtubeSubgen.whisperModel !== undefined) {
warn(
"youtubeSubgen.whisperModel",
src.youtubeSubgen.whisperModel,
resolved.youtubeSubgen.whisperModel,
"Expected string.",
);
}
if (Array.isArray(src.youtubeSubgen.primarySubLanguages)) {
resolved.youtubeSubgen.primarySubLanguages =
src.youtubeSubgen.primarySubLanguages.filter(
(item): item is string => typeof item === "string",
);
} else if (src.youtubeSubgen.primarySubLanguages !== undefined) {
warn(
"youtubeSubgen.primarySubLanguages",
src.youtubeSubgen.primarySubLanguages,
resolved.youtubeSubgen.primarySubLanguages,
"Expected string array.",
);
}
}
if (asBoolean(src.auto_start_overlay) !== undefined) {
resolved.auto_start_overlay = src.auto_start_overlay as boolean;
}
if (asBoolean(src.bind_visible_overlay_to_mpv_sub_visibility) !== undefined) {
resolved.bind_visible_overlay_to_mpv_sub_visibility =
src.bind_visible_overlay_to_mpv_sub_visibility as boolean;
} else if (src.bind_visible_overlay_to_mpv_sub_visibility !== undefined) {
warn(
"bind_visible_overlay_to_mpv_sub_visibility",
src.bind_visible_overlay_to_mpv_sub_visibility,
resolved.bind_visible_overlay_to_mpv_sub_visibility,
"Expected boolean.",
);
}
if (isObject(src.subtitleStyle)) {
resolved.subtitleStyle = {
...resolved.subtitleStyle,
...(src.subtitleStyle as ResolvedConfig["subtitleStyle"]),
secondary: {
...resolved.subtitleStyle.secondary,
...(isObject(src.subtitleStyle.secondary)
? (src.subtitleStyle
.secondary as ResolvedConfig["subtitleStyle"]["secondary"])
: {}),
},
};
}
if (isObject(src.ankiConnect)) {
const ac = src.ankiConnect;
const aiSource = isObject(ac.ai)
? ac.ai
: isObject(ac.openRouter)
? ac.openRouter
: {};
resolved.ankiConnect = {
...resolved.ankiConnect,
...(isObject(ac) ? (ac as Partial<ResolvedConfig["ankiConnect"]>) : {}),
fields: {
...resolved.ankiConnect.fields,
...(isObject(ac.fields)
? (ac.fields as ResolvedConfig["ankiConnect"]["fields"])
: {}),
},
ai: {
...resolved.ankiConnect.ai,
...(aiSource as ResolvedConfig["ankiConnect"]["ai"]),
},
media: {
...resolved.ankiConnect.media,
...(isObject(ac.media)
? (ac.media as ResolvedConfig["ankiConnect"]["media"])
: {}),
},
behavior: {
...resolved.ankiConnect.behavior,
...(isObject(ac.behavior)
? (ac.behavior as ResolvedConfig["ankiConnect"]["behavior"])
: {}),
},
metadata: {
...resolved.ankiConnect.metadata,
...(isObject(ac.metadata)
? (ac.metadata as ResolvedConfig["ankiConnect"]["metadata"])
: {}),
},
isLapis: {
...resolved.ankiConnect.isLapis,
...(isObject(ac.isLapis)
? (ac.isLapis as ResolvedConfig["ankiConnect"]["isLapis"])
: {}),
},
isKiku: {
...resolved.ankiConnect.isKiku,
...(isObject(ac.isKiku)
? (ac.isKiku as ResolvedConfig["ankiConnect"]["isKiku"])
: {}),
},
};
const legacy = ac as Record<string, unknown>;
const mapLegacy = (
key: string,
apply: (value: unknown) => void,
): void => {
if (legacy[key] !== undefined) apply(legacy[key]);
};
mapLegacy("audioField", (value) => {
resolved.ankiConnect.fields.audio = value as string;
});
mapLegacy("imageField", (value) => {
resolved.ankiConnect.fields.image = value as string;
});
mapLegacy("sentenceField", (value) => {
resolved.ankiConnect.fields.sentence = value as string;
});
mapLegacy("miscInfoField", (value) => {
resolved.ankiConnect.fields.miscInfo = value as string;
});
mapLegacy("miscInfoPattern", (value) => {
resolved.ankiConnect.metadata.pattern = value as string;
});
mapLegacy("generateAudio", (value) => {
resolved.ankiConnect.media.generateAudio = value as boolean;
});
mapLegacy("generateImage", (value) => {
resolved.ankiConnect.media.generateImage = value as boolean;
});
mapLegacy("imageType", (value) => {
resolved.ankiConnect.media.imageType = value as "static" | "avif";
});
mapLegacy("imageFormat", (value) => {
resolved.ankiConnect.media.imageFormat = value as
| "jpg"
| "png"
| "webp";
});
mapLegacy("imageQuality", (value) => {
resolved.ankiConnect.media.imageQuality = value as number;
});
mapLegacy("imageMaxWidth", (value) => {
resolved.ankiConnect.media.imageMaxWidth = value as number;
});
mapLegacy("imageMaxHeight", (value) => {
resolved.ankiConnect.media.imageMaxHeight = value as number;
});
mapLegacy("animatedFps", (value) => {
resolved.ankiConnect.media.animatedFps = value as number;
});
mapLegacy("animatedMaxWidth", (value) => {
resolved.ankiConnect.media.animatedMaxWidth = value as number;
});
mapLegacy("animatedMaxHeight", (value) => {
resolved.ankiConnect.media.animatedMaxHeight = value as number;
});
mapLegacy("animatedCrf", (value) => {
resolved.ankiConnect.media.animatedCrf = value as number;
});
mapLegacy("audioPadding", (value) => {
resolved.ankiConnect.media.audioPadding = value as number;
});
mapLegacy("fallbackDuration", (value) => {
resolved.ankiConnect.media.fallbackDuration = value as number;
});
mapLegacy("maxMediaDuration", (value) => {
resolved.ankiConnect.media.maxMediaDuration = value as number;
});
mapLegacy("overwriteAudio", (value) => {
resolved.ankiConnect.behavior.overwriteAudio = value as boolean;
});
mapLegacy("overwriteImage", (value) => {
resolved.ankiConnect.behavior.overwriteImage = value as boolean;
});
mapLegacy("mediaInsertMode", (value) => {
resolved.ankiConnect.behavior.mediaInsertMode = value as
| "append"
| "prepend";
});
mapLegacy("highlightWord", (value) => {
resolved.ankiConnect.behavior.highlightWord = value as boolean;
});
mapLegacy("notificationType", (value) => {
resolved.ankiConnect.behavior.notificationType = value as
| "osd"
| "system"
| "both"
| "none";
});
mapLegacy("autoUpdateNewCards", (value) => {
resolved.ankiConnect.behavior.autoUpdateNewCards = value as boolean;
});
if (
resolved.ankiConnect.isKiku.fieldGrouping !== "auto" &&
resolved.ankiConnect.isKiku.fieldGrouping !== "manual" &&
resolved.ankiConnect.isKiku.fieldGrouping !== "disabled"
) {
warn(
"ankiConnect.isKiku.fieldGrouping",
resolved.ankiConnect.isKiku.fieldGrouping,
DEFAULT_CONFIG.ankiConnect.isKiku.fieldGrouping,
"Expected auto, manual, or disabled.",
);
resolved.ankiConnect.isKiku.fieldGrouping =
DEFAULT_CONFIG.ankiConnect.isKiku.fieldGrouping;
}
}
return { resolved, warnings };
}
}

78
src/config/template.ts Normal file
View File

@@ -0,0 +1,78 @@
import { ResolvedConfig } from "../types";
import {
CONFIG_TEMPLATE_SECTIONS,
DEFAULT_CONFIG,
deepCloneConfig,
} from "./definitions";
function renderValue(value: unknown, indent = 0): string {
const pad = " ".repeat(indent);
const nextPad = " ".repeat(indent + 2);
if (value === null) return "null";
if (typeof value === "string") return JSON.stringify(value);
if (typeof value === "number" || typeof value === "boolean") return String(value);
if (Array.isArray(value)) {
if (value.length === 0) return "[]";
const items = value.map((item) => `${nextPad}${renderValue(item, indent + 2)}`);
return `\n${items.join(",\n")}\n${pad}`.replace(/^/, "[").concat("]");
}
if (typeof value === "object") {
const entries = Object.entries(value as Record<string, unknown>).filter(
([, child]) => child !== undefined,
);
if (entries.length === 0) return "{}";
const lines = entries.map(
([key, child]) => `${nextPad}${JSON.stringify(key)}: ${renderValue(child, indent + 2)}`,
);
return `\n${lines.join(",\n")}\n${pad}`.replace(/^/, "{").concat("}");
}
return "null";
}
function renderSection(
key: keyof ResolvedConfig,
value: unknown,
isLast: boolean,
comments: string[],
): string {
const lines: string[] = [];
lines.push(" // ==========================================");
for (const comment of comments) {
lines.push(` // ${comment}`);
}
lines.push(" // ==========================================");
lines.push(` ${JSON.stringify(key)}: ${renderValue(value, 2)}${isLast ? "" : ","}`);
return lines.join("\n");
}
export function generateConfigTemplate(config: ResolvedConfig = deepCloneConfig(DEFAULT_CONFIG)): string {
const lines: string[] = [];
lines.push("/**");
lines.push(" * SubMiner Example Configuration File");
lines.push(" *");
lines.push(" * This file is auto-generated from src/config/definitions.ts.");
lines.push(" * Copy to ~/.config/SubMiner/config.jsonc and edit as needed.");
lines.push(" */");
lines.push("{");
CONFIG_TEMPLATE_SECTIONS.forEach((section, index) => {
lines.push("");
const comments = [section.title, ...section.description, ...(section.notes ?? [])];
lines.push(
renderSection(
section.key,
config[section.key],
index === CONFIG_TEMPLATE_SECTIONS.length - 1,
comments,
),
);
});
lines.push("}");
lines.push("");
return lines.join("\n");
}

View File

@@ -0,0 +1,12 @@
import * as fs from "fs";
import * as path from "path";
import { DEFAULT_CONFIG, generateConfigTemplate } from "./config";
function main(): void {
const outputPath = path.join(process.cwd(), "config.example.jsonc");
const template = generateConfigTemplate(DEFAULT_CONFIG);
fs.writeFileSync(outputPath, template, "utf-8");
console.log(`Generated ${outputPath}`);
}
main();

150
src/logger.ts Normal file
View File

@@ -0,0 +1,150 @@
export type LogLevel = "debug" | "info" | "warn" | "error";
type LogMethod = (message: string, ...meta: unknown[]) => void;
type Logger = {
debug: LogMethod;
info: LogMethod;
warn: LogMethod;
error: LogMethod;
child: (childScope: string) => Logger;
};
const LOG_LEVELS: LogLevel[] = ["debug", "info", "warn", "error"];
const LEVEL_PRIORITY: Record<LogLevel, number> = {
debug: 10,
info: 20,
warn: 30,
error: 40,
};
function pad(value: number): string {
return String(value).padStart(2, "0");
}
function formatTimestamp(date: Date): string {
const year = date.getFullYear();
const month = pad(date.getMonth() + 1);
const day = pad(date.getDate());
const hour = pad(date.getHours());
const minute = pad(date.getMinutes());
const second = pad(date.getSeconds());
return `${year}-${month}-${day} ${hour}:${minute}:${second}`;
}
function resolveMinLevel(): LogLevel {
const raw =
typeof process !== "undefined" && process?.env
? process.env.SUBMINER_LOG_LEVEL
: undefined;
const normalized = (raw || "").toLowerCase() as LogLevel;
if (LOG_LEVELS.includes(normalized)) {
return normalized;
}
return "info";
}
function normalizeError(error: Error): { message: string; stack?: string } {
return {
message: error.message,
...(error.stack ? { stack: error.stack } : {}),
};
}
function sanitizeMeta(value: unknown): unknown {
if (value instanceof Error) {
return normalizeError(value);
}
if (typeof value === "bigint") {
return value.toString();
}
return value;
}
function safeStringify(value: unknown): string {
if (typeof value === "string") {
return value;
}
if (
typeof value === "number" ||
typeof value === "boolean" ||
typeof value === "undefined" ||
value === null
) {
return String(value);
}
try {
return JSON.stringify(value);
} catch {
return String(value);
}
}
function emit(
level: LogLevel,
scope: string,
message: string,
meta: unknown[],
): void {
const minLevel = resolveMinLevel();
if (LEVEL_PRIORITY[level] < LEVEL_PRIORITY[minLevel]) {
return;
}
const timestamp = formatTimestamp(new Date());
const prefix = `[subminer] - ${timestamp} - ${level.toUpperCase()} - [${scope}] ${message}`;
const normalizedMeta = meta.map(sanitizeMeta);
if (normalizedMeta.length === 0) {
if (level === "error") {
console.error(prefix);
} else if (level === "warn") {
console.warn(prefix);
} else if (level === "debug") {
console.debug(prefix);
} else {
console.info(prefix);
}
return;
}
const serialized = normalizedMeta.map(safeStringify).join(" ");
const finalMessage = `${prefix} ${serialized}`;
if (level === "error") {
console.error(finalMessage);
} else if (level === "warn") {
console.warn(finalMessage);
} else if (level === "debug") {
console.debug(finalMessage);
} else {
console.info(finalMessage);
}
}
export function createLogger(scope: string): Logger {
const baseScope = scope.trim();
if (!baseScope) {
throw new Error("Logger scope is required");
}
const logAt = (level: LogLevel): LogMethod => {
return (message: string, ...meta: unknown[]) => {
emit(level, baseScope, message, meta);
};
};
return {
debug: logAt("debug"),
info: logAt("info"),
warn: logAt("warn"),
error: logAt("error"),
child: (childScope: string): Logger => {
const normalizedChild = childScope.trim();
if (!normalizedChild) {
throw new Error("Child logger scope is required");
}
return createLogger(`${baseScope}:${normalizedChild}`);
},
};
}

5003
src/main.ts Normal file

File diff suppressed because it is too large Load Diff

178
src/mecab-tokenizer.ts Normal file
View File

@@ -0,0 +1,178 @@
/*
* SubMiner - All-in-one sentence mining overlay
* Copyright (C) 2024 sudacode
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import { spawn, execSync } from "child_process";
import { PartOfSpeech, Token, MecabStatus } from "./types";
import { createLogger } from "./logger";
export { PartOfSpeech };
const log = createLogger("mecab");
function mapPartOfSpeech(pos1: string): PartOfSpeech {
switch (pos1) {
case "名詞":
return PartOfSpeech.noun;
case "動詞":
return PartOfSpeech.verb;
case "形容詞":
return PartOfSpeech.i_adjective;
case "形状詞":
case "形容動詞":
return PartOfSpeech.na_adjective;
case "助詞":
return PartOfSpeech.particle;
case "助動詞":
return PartOfSpeech.bound_auxiliary;
case "記号":
case "補助記号":
return PartOfSpeech.symbol;
default:
return PartOfSpeech.other;
}
}
export function parseMecabLine(line: string): Token | null {
if (!line || line === "EOS" || line.trim() === "") {
return null;
}
const tabIndex = line.indexOf("\t");
if (tabIndex === -1) {
return null;
}
const surface = line.substring(0, tabIndex);
const featureString = line.substring(tabIndex + 1);
const features = featureString.split(",");
const pos1 = features[0] || "";
const pos2 = features[1] || "";
const pos3 = features[2] || "";
const pos4 = features[3] || "";
const inflectionType = features[4] || "";
const inflectionForm = features[5] || "";
const lemma = features[6] || surface;
const reading = features[7] || "";
const pronunciation = features[8] || "";
return {
word: surface,
partOfSpeech: mapPartOfSpeech(pos1),
pos1,
pos2,
pos3,
pos4,
inflectionType,
inflectionForm,
headword: lemma !== "*" ? lemma : surface,
katakanaReading: reading !== "*" ? reading : "",
pronunciation: pronunciation !== "*" ? pronunciation : "",
};
}
export class MecabTokenizer {
private mecabPath: string | null = null;
private available: boolean = false;
private enabled: boolean = true;
async checkAvailability(): Promise<boolean> {
try {
const result = execSync("which mecab", { encoding: "utf-8" }).trim();
if (result) {
this.mecabPath = result;
this.available = true;
log.info("MeCab found at:", this.mecabPath);
return true;
}
} catch (err) {
log.info("MeCab not found on system");
}
this.available = false;
return false;
}
async tokenize(text: string): Promise<Token[] | null> {
if (!this.available || !this.enabled || !text) {
return null;
}
return new Promise((resolve) => {
const mecab = spawn("mecab", [], {
stdio: ["pipe", "pipe", "pipe"],
});
let stdout = "";
let stderr = "";
mecab.stdout.on("data", (data: Buffer) => {
stdout += data.toString();
});
mecab.stderr.on("data", (data: Buffer) => {
stderr += data.toString();
});
mecab.on("close", (code: number | null) => {
if (code !== 0) {
log.error("MeCab process exited with code:", code);
if (stderr) {
log.error("MeCab stderr:", stderr);
}
resolve(null);
return;
}
const lines = stdout.split("\n");
const tokens: Token[] = [];
for (const line of lines) {
const token = parseMecabLine(line);
if (token) {
tokens.push(token);
}
}
resolve(tokens);
});
mecab.on("error", (err: Error) => {
log.error("Failed to spawn MeCab:", err.message);
resolve(null);
});
mecab.stdin.write(text);
mecab.stdin.end();
});
}
getStatus(): MecabStatus {
return {
available: this.available,
enabled: this.enabled,
path: this.mecabPath,
};
}
setEnabled(enabled: boolean): void {
this.enabled = enabled;
}
}
export { mapPartOfSpeech };

431
src/media-generator.ts Normal file
View File

@@ -0,0 +1,431 @@
/*
* SubMiner - Subtitle mining overlay for mpv
* Copyright (C) 2024 sudacode
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import { ExecFileException, execFile } from "child_process";
import * as fs from "fs";
import * as path from "path";
import * as os from "os";
import { createLogger } from "./logger";
const log = createLogger("media");
export class MediaGenerator {
private tempDir: string;
private notifyIconDir: string;
private av1EncoderPromise: Promise<string | null> | null = null;
constructor(tempDir?: string) {
this.tempDir = tempDir || path.join(os.tmpdir(), "subminer-media");
this.notifyIconDir = path.join(os.tmpdir(), "subminer-notify");
if (!fs.existsSync(this.tempDir)) {
fs.mkdirSync(this.tempDir, { recursive: true });
}
if (!fs.existsSync(this.notifyIconDir)) {
fs.mkdirSync(this.notifyIconDir, { recursive: true });
}
// Clean up old notification icons on startup (older than 1 hour)
this.cleanupOldNotificationIcons();
}
/**
* Clean up notification icons older than 1 hour.
* Called on startup to prevent accumulation of temp files.
*/
private cleanupOldNotificationIcons(): void {
try {
if (!fs.existsSync(this.notifyIconDir)) return;
const files = fs.readdirSync(this.notifyIconDir);
const oneHourAgo = Date.now() - 60 * 60 * 1000;
for (const file of files) {
if (!file.endsWith(".png")) continue;
const filePath = path.join(this.notifyIconDir, file);
try {
const stat = fs.statSync(filePath);
if (stat.mtimeMs < oneHourAgo) {
fs.unlinkSync(filePath);
}
} catch (err) {
log.debug(
`Failed to clean up ${filePath}:`,
(err as Error).message,
);
}
}
} catch (err) {
log.error("Failed to cleanup old notification icons:", err);
}
}
/**
* Write a notification icon buffer to a temp file and return the file path.
* The file path can be passed directly to Electron Notification for better
* compatibility with Linux/Wayland notification daemons.
*/
writeNotificationIconToFile(iconBuffer: Buffer, noteId: number): string {
const filename = `icon_${noteId}_${Date.now()}.png`;
const filePath = path.join(this.notifyIconDir, filename);
fs.writeFileSync(filePath, iconBuffer);
return filePath;
}
scheduleNotificationIconCleanup(filePath: string, delayMs = 10000): void {
setTimeout(() => {
try {
fs.unlinkSync(filePath);
} catch {}
}, delayMs);
}
private ffmpegError(label: string, error: ExecFileException): Error {
if (error.code === "ENOENT") {
return new Error(
"FFmpeg not found. Install FFmpeg to enable media generation.",
);
}
return new Error(`FFmpeg ${label} failed: ${error.message}`);
}
private detectAv1Encoder(): Promise<string | null> {
if (this.av1EncoderPromise) return this.av1EncoderPromise;
this.av1EncoderPromise = new Promise((resolve) => {
execFile(
"ffmpeg",
["-hide_banner", "-encoders"],
{ timeout: 10000 },
(error, stdout, stderr) => {
if (error) {
resolve(null);
return;
}
const output = `${stdout || ""}\n${stderr || ""}`;
const candidates = ["libaom-av1", "libsvtav1", "librav1e"];
for (const encoder of candidates) {
if (output.includes(encoder)) {
resolve(encoder);
return;
}
}
resolve(null);
},
);
});
return this.av1EncoderPromise;
}
async generateAudio(
videoPath: string,
startTime: number,
endTime: number,
padding: number = 0.5,
audioStreamIndex: number | null = null,
): Promise<Buffer> {
const start = Math.max(0, startTime - padding);
const duration = endTime - startTime + 2 * padding;
return new Promise((resolve, reject) => {
const outputPath = path.join(this.tempDir, `audio_${Date.now()}.mp3`);
const args: string[] = [
"-ss",
start.toString(),
"-t",
duration.toString(),
"-i",
videoPath,
];
if (
typeof audioStreamIndex === "number" &&
Number.isInteger(audioStreamIndex) &&
audioStreamIndex >= 0
) {
args.push("-map", `0:${audioStreamIndex}`);
}
args.push(
"-vn",
"-acodec",
"libmp3lame",
"-q:a",
"2",
"-ar",
"44100",
"-y",
outputPath,
);
execFile("ffmpeg", args, { timeout: 30000 }, (error) => {
if (error) {
reject(this.ffmpegError("audio generation", error));
return;
}
try {
const data = fs.readFileSync(outputPath);
fs.unlinkSync(outputPath);
resolve(data);
} catch (err) {
reject(err);
}
});
});
}
async generateScreenshot(
videoPath: string,
timestamp: number,
options: {
format: "jpg" | "png" | "webp";
quality?: number;
maxWidth?: number;
maxHeight?: number;
},
): Promise<Buffer> {
const { format, quality = 92, maxWidth, maxHeight } = options;
const ext = format === "webp" ? "webp" : format === "png" ? "png" : "jpg";
const codecMap: Record<string, string> = {
jpg: "mjpeg",
png: "png",
webp: "webp",
};
const args: string[] = [
"-ss",
timestamp.toString(),
"-i",
videoPath,
"-vframes",
"1",
];
const vfParts: string[] = [];
if (maxWidth && maxWidth > 0 && maxHeight && maxHeight > 0) {
vfParts.push(
`scale=w=${maxWidth}:h=${maxHeight}:force_original_aspect_ratio=decrease`,
);
} else if (maxWidth && maxWidth > 0) {
vfParts.push(`scale=w=${maxWidth}:h=-2`);
} else if (maxHeight && maxHeight > 0) {
vfParts.push(`scale=w=-2:h=${maxHeight}`);
}
if (vfParts.length > 0) {
args.push("-vf", vfParts.join(","));
}
args.push("-c:v", codecMap[format]);
if (format !== "png") {
const clampedQuality = Math.max(1, Math.min(100, quality));
if (format === "jpg") {
const qv = Math.round(2 + (100 - clampedQuality) * (29 / 99));
args.push("-q:v", qv.toString());
} else {
args.push("-q:v", clampedQuality.toString());
}
}
args.push("-y");
return new Promise((resolve, reject) => {
const outputPath = path.join(
this.tempDir,
`screenshot_${Date.now()}.${ext}`,
);
args.push(outputPath);
execFile("ffmpeg", args, { timeout: 30000 }, (error) => {
if (error) {
reject(this.ffmpegError("screenshot generation", error));
return;
}
try {
const data = fs.readFileSync(outputPath);
fs.unlinkSync(outputPath);
resolve(data);
} catch (err) {
reject(err);
}
});
});
}
/**
* Generate a small PNG icon suitable for desktop notifications.
* Always outputs PNG format (known-good for Electron + Linux notification daemons).
* Scaled to 256px width for fast encoding and small file size.
*/
async generateNotificationIcon(
videoPath: string,
timestamp: number,
): Promise<Buffer> {
return new Promise((resolve, reject) => {
const outputPath = path.join(
this.tempDir,
`notify_icon_${Date.now()}.png`,
);
execFile(
"ffmpeg",
[
"-ss",
timestamp.toString(),
"-i",
videoPath,
"-vframes",
"1",
"-vf",
"scale=256:256:force_original_aspect_ratio=decrease,pad=256:256:(ow-iw)/2:(oh-ih)/2:black",
"-c:v",
"png",
"-y",
outputPath,
],
{ timeout: 30000 },
(error) => {
if (error) {
reject(this.ffmpegError("notification icon generation", error));
return;
}
try {
const data = fs.readFileSync(outputPath);
fs.unlinkSync(outputPath);
resolve(data);
} catch (err) {
reject(err);
}
},
);
});
}
async generateAnimatedImage(
videoPath: string,
startTime: number,
endTime: number,
padding: number = 0.5,
options: {
fps?: number;
maxWidth?: number;
maxHeight?: number;
crf?: number;
} = {},
): Promise<Buffer> {
const start = Math.max(0, startTime - padding);
const duration = endTime - startTime + 2 * padding;
const { fps = 10, maxWidth = 640, maxHeight, crf = 35 } = options;
const clampedFps = Math.max(1, Math.min(60, fps));
const clampedCrf = Math.max(0, Math.min(63, crf));
const vfParts: string[] = [];
vfParts.push(`fps=${clampedFps}`);
if (maxWidth && maxWidth > 0 && maxHeight && maxHeight > 0) {
vfParts.push(
`scale=w=${maxWidth}:h=${maxHeight}:force_original_aspect_ratio=decrease`,
);
} else if (maxWidth && maxWidth > 0) {
vfParts.push(`scale=w=${maxWidth}:h=-2`);
} else if (maxHeight && maxHeight > 0) {
vfParts.push(`scale=w=-2:h=${maxHeight}`);
}
const av1Encoder = await this.detectAv1Encoder();
if (!av1Encoder) {
throw new Error(
"No supported AV1 encoder found for animated AVIF (tried libaom-av1, libsvtav1, librav1e).",
);
}
return new Promise((resolve, reject) => {
const outputPath = path.join(
this.tempDir,
`animation_${Date.now()}.avif`,
);
const encoderArgs: string[] = ["-c:v", av1Encoder];
if (av1Encoder === "libaom-av1") {
encoderArgs.push(
"-crf",
clampedCrf.toString(),
"-b:v",
"0",
"-cpu-used",
"8",
);
} else if (av1Encoder === "libsvtav1") {
encoderArgs.push(
"-crf",
clampedCrf.toString(),
"-preset",
"8",
);
} else {
// librav1e
encoderArgs.push("-qp", clampedCrf.toString(), "-speed", "8");
}
execFile(
"ffmpeg",
[
"-ss",
start.toString(),
"-t",
duration.toString(),
"-i",
videoPath,
"-vf",
vfParts.join(","),
...encoderArgs,
"-y",
outputPath,
],
{ timeout: 60000 },
(error) => {
if (error) {
reject(this.ffmpegError("animation generation", error));
return;
}
try {
const data = fs.readFileSync(outputPath);
fs.unlinkSync(outputPath);
resolve(data);
} catch (err) {
reject(err);
}
},
);
});
}
cleanup(): void {
try {
if (fs.existsSync(this.tempDir)) {
fs.rmSync(this.tempDir, { recursive: true, force: true });
}
} catch (err) {
log.error("Failed to cleanup media generator temp directory:", err);
}
}
}

267
src/preload.ts Normal file
View File

@@ -0,0 +1,267 @@
/*
* SubMiner - All-in-one sentence mining overlay
* Copyright (C) 2024 sudacode
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import { contextBridge, ipcRenderer, IpcRendererEvent } from "electron";
import type {
SubtitleData,
SubtitlePosition,
MecabStatus,
Keybinding,
ElectronAPI,
SecondarySubMode,
SubtitleStyleConfig,
JimakuMediaInfo,
JimakuSearchQuery,
JimakuFilesQuery,
JimakuDownloadQuery,
JimakuEntry,
JimakuFileEntry,
JimakuApiResponse,
JimakuDownloadResult,
SubsyncManualPayload,
SubsyncManualRunRequest,
SubsyncResult,
KikuFieldGroupingRequestData,
KikuFieldGroupingChoice,
KikuMergePreviewRequest,
KikuMergePreviewResponse,
RuntimeOptionApplyResult,
RuntimeOptionId,
RuntimeOptionState,
RuntimeOptionValue,
MpvSubtitleRenderMetrics,
} from "./types";
const overlayLayerArg = process.argv.find((arg) =>
arg.startsWith("--overlay-layer="),
);
const overlayLayerFromArg = overlayLayerArg?.slice("--overlay-layer=".length);
const overlayLayer =
overlayLayerFromArg === "visible" || overlayLayerFromArg === "invisible"
? overlayLayerFromArg
: null;
const electronAPI: ElectronAPI = {
getOverlayLayer: () => overlayLayer,
onSubtitle: (callback: (data: SubtitleData) => void) => {
ipcRenderer.on(
"subtitle:set",
(_event: IpcRendererEvent, data: SubtitleData) => callback(data),
);
},
onVisibility: (callback: (visible: boolean) => void) => {
ipcRenderer.on(
"mpv:subVisibility",
(_event: IpcRendererEvent, visible: boolean) => callback(visible),
);
},
onSubtitlePosition: (
callback: (position: SubtitlePosition | null) => void,
) => {
ipcRenderer.on(
"subtitle-position:set",
(_event: IpcRendererEvent, position: SubtitlePosition | null) => {
callback(position);
},
);
},
getOverlayVisibility: (): Promise<boolean> =>
ipcRenderer.invoke("get-overlay-visibility"),
getCurrentSubtitle: (): Promise<SubtitleData> =>
ipcRenderer.invoke("get-current-subtitle"),
getCurrentSubtitleAss: (): Promise<string> =>
ipcRenderer.invoke("get-current-subtitle-ass"),
getMpvSubtitleRenderMetrics: () =>
ipcRenderer.invoke("get-mpv-subtitle-render-metrics"),
onMpvSubtitleRenderMetrics: (
callback: (metrics: MpvSubtitleRenderMetrics) => void,
) => {
ipcRenderer.on(
"mpv-subtitle-render-metrics:set",
(_event: IpcRendererEvent, metrics: MpvSubtitleRenderMetrics) => {
callback(metrics);
},
);
},
onSubtitleAss: (callback: (assText: string) => void) => {
ipcRenderer.on(
"subtitle-ass:set",
(_event: IpcRendererEvent, assText: string) => {
callback(assText);
},
);
},
onOverlayDebugVisualization: (callback: (enabled: boolean) => void) => {
ipcRenderer.on(
"overlay-debug-visualization:set",
(_event: IpcRendererEvent, enabled: boolean) => {
callback(enabled);
},
);
},
setIgnoreMouseEvents: (ignore: boolean, options?: { forward?: boolean }) => {
ipcRenderer.send("set-ignore-mouse-events", ignore, options);
},
openYomitanSettings: () => {
ipcRenderer.send("open-yomitan-settings");
},
getSubtitlePosition: (): Promise<SubtitlePosition | null> =>
ipcRenderer.invoke("get-subtitle-position"),
saveSubtitlePosition: (position: SubtitlePosition) => {
ipcRenderer.send("save-subtitle-position", position);
},
getMecabStatus: (): Promise<MecabStatus> =>
ipcRenderer.invoke("get-mecab-status"),
setMecabEnabled: (enabled: boolean) => {
ipcRenderer.send("set-mecab-enabled", enabled);
},
sendMpvCommand: (command: (string | number)[]) => {
ipcRenderer.send("mpv-command", command);
},
getKeybindings: (): Promise<Keybinding[]> =>
ipcRenderer.invoke("get-keybindings"),
getJimakuMediaInfo: (): Promise<JimakuMediaInfo> =>
ipcRenderer.invoke("jimaku:get-media-info"),
jimakuSearchEntries: (
query: JimakuSearchQuery,
): Promise<JimakuApiResponse<JimakuEntry[]>> =>
ipcRenderer.invoke("jimaku:search-entries", query),
jimakuListFiles: (
query: JimakuFilesQuery,
): Promise<JimakuApiResponse<JimakuFileEntry[]>> =>
ipcRenderer.invoke("jimaku:list-files", query),
jimakuDownloadFile: (
query: JimakuDownloadQuery,
): Promise<JimakuDownloadResult> =>
ipcRenderer.invoke("jimaku:download-file", query),
quitApp: () => {
ipcRenderer.send("quit-app");
},
toggleDevTools: () => {
ipcRenderer.send("toggle-dev-tools");
},
toggleOverlay: () => {
ipcRenderer.send("toggle-overlay");
},
getAnkiConnectStatus: (): Promise<boolean> =>
ipcRenderer.invoke("get-anki-connect-status"),
setAnkiConnectEnabled: (enabled: boolean) => {
ipcRenderer.send("set-anki-connect-enabled", enabled);
},
clearAnkiConnectHistory: () => {
ipcRenderer.send("clear-anki-connect-history");
},
onSecondarySub: (callback: (text: string) => void) => {
ipcRenderer.on(
"secondary-subtitle:set",
(_event: IpcRendererEvent, text: string) => callback(text),
);
},
onSecondarySubMode: (callback: (mode: SecondarySubMode) => void) => {
ipcRenderer.on(
"secondary-subtitle:mode",
(_event: IpcRendererEvent, mode: SecondarySubMode) => callback(mode),
);
},
getSecondarySubMode: (): Promise<SecondarySubMode> =>
ipcRenderer.invoke("get-secondary-sub-mode"),
getCurrentSecondarySub: (): Promise<string> =>
ipcRenderer.invoke("get-current-secondary-sub"),
getSubtitleStyle: (): Promise<SubtitleStyleConfig | null> =>
ipcRenderer.invoke("get-subtitle-style"),
onSubsyncManualOpen: (callback: (payload: SubsyncManualPayload) => void) => {
ipcRenderer.on(
"subsync:open-manual",
(_event: IpcRendererEvent, payload: SubsyncManualPayload) => {
callback(payload);
},
);
},
runSubsyncManual: (
request: SubsyncManualRunRequest,
): Promise<SubsyncResult> =>
ipcRenderer.invoke("subsync:run-manual", request),
onKikuFieldGroupingRequest: (
callback: (data: KikuFieldGroupingRequestData) => void,
) => {
ipcRenderer.on(
"kiku:field-grouping-request",
(_event: IpcRendererEvent, data: KikuFieldGroupingRequestData) =>
callback(data),
);
},
kikuBuildMergePreview: (
request: KikuMergePreviewRequest,
): Promise<KikuMergePreviewResponse> =>
ipcRenderer.invoke("kiku:build-merge-preview", request),
kikuFieldGroupingRespond: (choice: KikuFieldGroupingChoice) => {
ipcRenderer.send("kiku:field-grouping-respond", choice);
},
getRuntimeOptions: (): Promise<RuntimeOptionState[]> =>
ipcRenderer.invoke("runtime-options:get"),
setRuntimeOptionValue: (
id: RuntimeOptionId,
value: RuntimeOptionValue,
): Promise<RuntimeOptionApplyResult> =>
ipcRenderer.invoke("runtime-options:set", id, value),
cycleRuntimeOption: (
id: RuntimeOptionId,
direction: 1 | -1,
): Promise<RuntimeOptionApplyResult> =>
ipcRenderer.invoke("runtime-options:cycle", id, direction),
onRuntimeOptionsChanged: (
callback: (options: RuntimeOptionState[]) => void,
) => {
ipcRenderer.on(
"runtime-options:changed",
(_event: IpcRendererEvent, options: RuntimeOptionState[]) => {
callback(options);
},
);
},
onOpenRuntimeOptions: (callback: () => void) => {
ipcRenderer.on("runtime-options:open", () => {
callback();
});
},
notifyOverlayModalClosed: (modal: "runtime-options" | "subsync") => {
ipcRenderer.send("overlay:modal-closed", modal);
},
};
contextBridge.exposeInMainWorld("electronAPI", electronAPI);

265
src/renderer/index.html Normal file
View File

@@ -0,0 +1,265 @@
<!--
SubMiner - All-in-one sentence mining overlay
Copyright (C) 2024 sudacode
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
-->
<!doctype html>
<html lang="ja">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta
http-equiv="Content-Security-Policy"
content="default-src 'self' 'unsafe-inline' chrome-extension:; script-src 'self' 'unsafe-inline' chrome-extension:; style-src 'self' 'unsafe-inline' chrome-extension:;"
/>
<title>SubMiner</title>
<link rel="stylesheet" href="style.css" />
</head>
<body>
<div id="overlay">
<div id="secondarySubContainer" class="secondary-sub-hidden">
<div id="secondarySubRoot"></div>
</div>
<div id="subtitleContainer">
<div id="subtitleRoot"></div>
</div>
<div id="jimakuModal" class="modal hidden" aria-hidden="true">
<div class="modal-content">
<div class="modal-header">
<div class="modal-title">Jimaku Subtitles</div>
<button id="jimakuClose" class="modal-close" type="button">
Close
</button>
</div>
<div class="modal-body">
<div class="jimaku-form">
<label class="jimaku-field">
<span>Title</span>
<input id="jimakuTitle" type="text" placeholder="Anime title" />
</label>
<label class="jimaku-field">
<span>Season</span>
<input
id="jimakuSeason"
type="number"
min="1"
placeholder="1"
/>
</label>
<label class="jimaku-field">
<span>Episode</span>
<input
id="jimakuEpisode"
type="number"
min="1"
placeholder="1"
/>
</label>
<button id="jimakuSearch" class="jimaku-button" type="button">
Search
</button>
</div>
<div id="jimakuStatus" class="jimaku-status"></div>
<div id="jimakuEntriesSection" class="jimaku-section hidden">
<div class="jimaku-section-title">Entries</div>
<ul id="jimakuEntries" class="jimaku-list"></ul>
</div>
<div id="jimakuFilesSection" class="jimaku-section hidden">
<div class="jimaku-section-title">Files</div>
<ul id="jimakuFiles" class="jimaku-list"></ul>
<button
id="jimakuBroaden"
class="jimaku-link hidden"
type="button"
>
Broaden search (all files)
</button>
</div>
</div>
</div>
</div>
<div id="kikuFieldGroupingModal" class="modal hidden" aria-hidden="true">
<div class="modal-content">
<div class="modal-header">
<div class="modal-title">Duplicate Card Detected</div>
</div>
<div class="modal-body">
<div id="kikuSelectionStep">
<div class="kiku-info-text">
A card with the same expression already exists. Select which
card to keep. The other card's content will be merged using Kiku
field grouping. You can choose whether to delete the duplicate.
</div>
<div class="kiku-cards-container">
<div id="kikuCard1" class="kiku-card active" tabindex="0">
<div class="kiku-card-label">1 &mdash; Original Card</div>
<div
class="kiku-card-expression"
id="kikuCard1Expression"
></div>
<div class="kiku-card-sentence" id="kikuCard1Sentence"></div>
<div class="kiku-card-meta" id="kikuCard1Meta"></div>
</div>
<div id="kikuCard2" class="kiku-card" tabindex="0">
<div class="kiku-card-label">2 &mdash; New Card</div>
<div
class="kiku-card-expression"
id="kikuCard2Expression"
></div>
<div class="kiku-card-sentence" id="kikuCard2Sentence"></div>
<div class="kiku-card-meta" id="kikuCard2Meta"></div>
</div>
</div>
<div class="kiku-footer">
<label class="kiku-delete-toggle">
<input id="kikuDeleteDuplicate" type="checkbox" checked />
Delete duplicate card after merge
</label>
<button
id="kikuConfirmButton"
class="kiku-confirm-button"
type="button"
>
Continue
</button>
<button
id="kikuCancelButton"
class="kiku-cancel-button"
type="button"
>
Cancel
</button>
</div>
</div>
<div id="kikuPreviewStep" class="hidden">
<div class="kiku-preview-header">
<div class="kiku-preview-title">Final Merge Preview</div>
<div class="kiku-preview-toggle">
<button id="kikuPreviewCompact" type="button">Compact</button>
<button id="kikuPreviewFull" type="button">Full</button>
</div>
</div>
<div
id="kikuPreviewError"
class="kiku-preview-error hidden"
></div>
<pre id="kikuPreviewJson" class="kiku-preview-json"></pre>
<div class="kiku-footer">
<button
id="kikuBackButton"
class="kiku-cancel-button"
type="button"
>
Back
</button>
<button
id="kikuFinalConfirmButton"
class="kiku-confirm-button"
type="button"
>
Confirm Merge
</button>
<button
id="kikuFinalCancelButton"
class="kiku-cancel-button"
type="button"
>
Cancel
</button>
</div>
</div>
<div id="kikuHint" class="kiku-hint">
Press 1 or 2 to select &middot; Enter to confirm &middot; Esc to
cancel
</div>
</div>
</div>
</div>
<div id="runtimeOptionsModal" class="modal hidden" aria-hidden="true">
<div class="modal-content runtime-modal-content">
<div class="modal-header">
<div class="modal-title">Runtime Options</div>
<button
id="runtimeOptionsClose"
class="modal-close"
type="button"
>
Close
</button>
</div>
<div class="modal-body">
<div id="runtimeOptionsHint" class="runtime-options-hint">
Arrow keys: select/change · Enter or double-click: apply · Esc:
close
</div>
<ul id="runtimeOptionsList" class="runtime-options-list"></ul>
<div id="runtimeOptionsStatus" class="runtime-options-status"></div>
</div>
</div>
</div>
<div id="subsyncModal" class="modal hidden" aria-hidden="true">
<div class="modal-content subsync-modal-content">
<div class="modal-header">
<div class="modal-title">Auto Subtitle Sync</div>
<button id="subsyncClose" class="modal-close" type="button">
Close
</button>
</div>
<div class="modal-body">
<div class="subsync-form">
<div class="subsync-field">
<span>Engine</span>
<label class="subsync-radio">
<input
id="subsyncEngineAlass"
type="radio"
name="subsyncEngine"
checked
/>
alass
</label>
<label class="subsync-radio">
<input
id="subsyncEngineFfsubsync"
type="radio"
name="subsyncEngine"
/>
ffsubsync
</label>
</div>
<label id="subsyncSourceLabel" class="subsync-field">
<span>Source Subtitle (for alass)</span>
<select id="subsyncSourceSelect"></select>
</label>
</div>
<div id="subsyncStatus" class="runtime-options-status"></div>
<div class="subsync-footer">
<button
id="subsyncRun"
class="kiku-confirm-button"
type="button"
>
Run Sync
</button>
</div>
</div>
</div>
</div>
</div>
<script src="renderer.js"></script>
</body>
</html>

2443
src/renderer/renderer.ts Normal file

File diff suppressed because it is too large Load Diff

703
src/renderer/style.css Normal file
View File

@@ -0,0 +1,703 @@
/*
* SubMiner - All-in-one sentence mining overlay
* Copyright (C) 2024 sudacode
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html,
body {
width: 100%;
height: 100%;
overflow: hidden;
background: transparent;
font-family:
"Noto Sans CJK JP Regular", "Noto Sans CJK JP", "Arial Unicode MS", Arial,
sans-serif;
}
#overlay {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
justify-content: flex-end;
align-items: center;
pointer-events: none;
}
#overlay.interactive {
pointer-events: auto;
}
.modal {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
background: rgba(0, 0, 0, 0.55);
pointer-events: auto;
z-index: 1000;
}
.modal.hidden {
display: none;
}
.hidden {
display: none !important;
}
.modal-content {
width: min(720px, 92%);
max-height: 80%;
background: rgba(20, 20, 20, 0.95);
border: 1px solid rgba(255, 255, 255, 0.12);
border-radius: 12px;
padding: 16px;
color: #fff;
display: flex;
flex-direction: column;
gap: 12px;
}
.modal-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.modal-title {
font-size: 18px;
font-weight: 600;
}
.modal-close {
background: rgba(255, 255, 255, 0.1);
color: #fff;
border: 1px solid rgba(255, 255, 255, 0.2);
border-radius: 6px;
padding: 6px 10px;
cursor: pointer;
}
.modal-close:hover {
background: rgba(255, 255, 255, 0.2);
}
.modal-body {
display: flex;
flex-direction: column;
gap: 12px;
overflow: hidden;
}
.jimaku-form {
display: grid;
grid-template-columns: 1fr 120px 120px auto;
gap: 10px;
align-items: end;
}
.jimaku-field {
display: flex;
flex-direction: column;
gap: 6px;
font-size: 12px;
color: rgba(255, 255, 255, 0.7);
}
.jimaku-field input {
background: rgba(0, 0, 0, 0.5);
border: 1px solid rgba(255, 255, 255, 0.15);
border-radius: 6px;
color: #fff;
padding: 6px 8px;
}
.jimaku-button {
height: 36px;
padding: 0 14px;
border-radius: 6px;
border: 1px solid rgba(255, 255, 255, 0.25);
background: rgba(255, 255, 255, 0.15);
color: #fff;
cursor: pointer;
}
.jimaku-button:hover {
background: rgba(255, 255, 255, 0.25);
}
.jimaku-status {
min-height: 20px;
font-size: 13px;
color: rgba(255, 255, 255, 0.8);
}
.jimaku-section {
display: flex;
flex-direction: column;
gap: 6px;
max-height: 220px;
overflow: hidden;
}
.jimaku-section.hidden {
display: none;
}
.jimaku-section-title {
font-size: 12px;
text-transform: uppercase;
letter-spacing: 0.08em;
color: rgba(255, 255, 255, 0.6);
}
.jimaku-list {
list-style: none;
padding: 0;
margin: 0;
overflow-y: auto;
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 8px;
max-height: 180px;
}
.jimaku-list li {
padding: 8px 10px;
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
cursor: pointer;
display: flex;
flex-direction: column;
gap: 2px;
}
.jimaku-list li:last-child {
border-bottom: none;
}
.jimaku-list li.active {
background: rgba(255, 255, 255, 0.15);
}
.jimaku-list .jimaku-subtext {
font-size: 12px;
color: rgba(255, 255, 255, 0.6);
}
.jimaku-link {
align-self: flex-start;
background: transparent;
color: rgba(255, 255, 255, 0.8);
border: none;
padding: 0;
cursor: pointer;
text-decoration: underline;
}
.jimaku-link.hidden {
display: none;
}
@media (max-width: 640px) {
.jimaku-form {
grid-template-columns: 1fr 1fr;
}
.jimaku-button {
grid-column: span 2;
}
}
#subtitleContainer {
max-width: 80%;
margin-bottom: 60px;
padding: 12px 20px;
background: rgba(54, 58, 79, 0.5);
border-radius: 8px;
pointer-events: auto;
}
#subtitleRoot {
text-align: center;
font-size: 35px;
line-height: 1.5;
color: #cad3f5;
text-shadow:
2px 2px 4px rgba(0, 0, 0, 0.8),
-1px -1px 2px rgba(0, 0, 0, 0.5);
/* Enable text selection for Yomitan */
user-select: text;
cursor: text;
}
#subtitleRoot:empty {
display: none;
}
#subtitleContainer:has(#subtitleRoot:empty) {
display: none;
}
#subtitleRoot .c {
display: inline;
position: relative;
}
#subtitleRoot .c:hover {
background: rgba(255, 255, 255, 0.15);
border-radius: 2px;
}
#subtitleRoot .word {
display: inline;
position: relative;
}
#subtitleRoot .word:hover {
background: rgba(255, 255, 255, 0.2);
border-radius: 3px;
}
#subtitleRoot br {
display: block;
content: "";
margin-bottom: 0.3em;
}
#subtitleRoot.has-selection .word:hover,
#subtitleRoot.has-selection .c:hover {
background: transparent;
}
body.layer-invisible #subtitleContainer {
background: transparent !important;
border: 0 !important;
padding: 0 !important;
border-radius: 0 !important;
}
body.layer-invisible #subtitleRoot,
body.layer-invisible #subtitleRoot .word,
body.layer-invisible #subtitleRoot .c {
color: transparent !important;
text-shadow: none !important;
-webkit-text-stroke: 0 !important;
-webkit-text-fill-color: transparent !important;
background: transparent !important;
caret-color: transparent !important;
line-height: normal !important;
font-kerning: auto;
letter-spacing: normal;
font-variant-ligatures: normal;
font-feature-settings: normal;
text-rendering: auto;
}
body.layer-invisible #subtitleRoot br {
margin-bottom: 0 !important;
}
body.layer-invisible #subtitleRoot .word:hover,
body.layer-invisible #subtitleRoot .c:hover,
body.layer-invisible #subtitleRoot.has-selection .word:hover,
body.layer-invisible #subtitleRoot.has-selection .c:hover {
background: transparent !important;
}
body.layer-invisible #subtitleRoot::selection,
body.layer-invisible #subtitleRoot .word::selection,
body.layer-invisible #subtitleRoot .c::selection {
background: transparent !important;
color: transparent !important;
}
body.layer-invisible.debug-invisible-visualization #subtitleRoot,
body.layer-invisible.debug-invisible-visualization #subtitleRoot .word,
body.layer-invisible.debug-invisible-visualization #subtitleRoot .c {
color: #ed8796 !important;
-webkit-text-fill-color: #ed8796 !important;
-webkit-text-stroke: 0.55px rgba(0, 0, 0, 0.95) !important;
text-shadow:
0 0 8px rgba(237, 135, 150, 0.9),
0 2px 6px rgba(0, 0, 0, 0.95) !important;
}
#secondarySubContainer {
position: absolute;
top: 40px;
left: 50%;
transform: translateX(-50%);
max-width: 80%;
padding: 10px 18px;
background: transparent;
border-radius: 8px;
pointer-events: auto;
}
#secondarySubRoot {
text-align: center;
font-size: 24px;
line-height: 1.5;
color: #ffffff;
-webkit-text-stroke: 0.45px rgba(0, 0, 0, 0.7);
paint-order: stroke fill;
text-shadow:
0 2px 4px rgba(0, 0, 0, 0.95),
0 0 8px rgba(0, 0, 0, 0.8),
0 0 16px rgba(0, 0, 0, 0.55);
user-select: text;
cursor: text;
}
#secondarySubRoot:empty {
display: none;
}
#secondarySubContainer:has(#secondarySubRoot:empty) {
display: none;
}
.secondary-sub-hidden {
display: none !important;
}
#secondarySubContainer.secondary-sub-hover {
opacity: 0;
transition: opacity 0.2s ease;
pointer-events: auto;
top: 0;
left: 0;
right: 0;
transform: none;
max-width: 100%;
background: transparent;
padding: 40px 0 0 0;
border-radius: 0;
display: flex;
justify-content: center;
}
#secondarySubContainer.secondary-sub-hover #secondarySubRoot {
background: transparent;
border-radius: 8px;
padding: 10px 18px;
}
#secondarySubContainer.secondary-sub-hover:hover {
opacity: 1;
}
iframe[id^="yomitan-popup"] {
pointer-events: auto !important;
z-index: 2147483647 !important;
}
.kiku-info-text {
font-size: 14px;
color: rgba(255, 255, 255, 0.8);
line-height: 1.5;
}
.kiku-cards-container {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
}
.kiku-card {
background: rgba(40, 40, 40, 0.8);
border: 2px solid rgba(255, 255, 255, 0.1);
border-radius: 10px;
padding: 14px;
display: flex;
flex-direction: column;
gap: 8px;
cursor: pointer;
transition: border-color 0.15s;
outline: none;
}
.kiku-card:hover {
border-color: rgba(255, 255, 255, 0.3);
}
.kiku-card.active {
border-color: rgba(100, 180, 255, 0.8);
background: rgba(40, 60, 90, 0.5);
}
.kiku-card-label {
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.08em;
color: rgba(255, 255, 255, 0.5);
font-weight: 600;
}
.kiku-card.active .kiku-card-label {
color: rgba(100, 180, 255, 0.9);
}
.kiku-card-expression {
font-size: 22px;
font-weight: 600;
color: #fff;
}
.kiku-card-sentence {
font-size: 14px;
color: rgba(255, 255, 255, 0.75);
max-height: 52px;
overflow: hidden;
text-overflow: ellipsis;
line-height: 1.4;
}
.kiku-card-meta {
font-size: 12px;
color: rgba(255, 255, 255, 0.45);
display: flex;
gap: 10px;
}
.kiku-footer {
display: flex;
flex-wrap: wrap;
justify-content: center;
align-items: center;
gap: 10px;
padding-top: 8px;
border-top: 1px solid rgba(255, 255, 255, 0.08);
}
.kiku-preview-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 10px;
}
.kiku-preview-title {
font-size: 13px;
font-weight: 600;
color: rgba(255, 255, 255, 0.88);
}
.kiku-preview-toggle {
display: inline-flex;
gap: 6px;
}
.kiku-preview-toggle button {
padding: 5px 10px;
border-radius: 5px;
border: 1px solid rgba(255, 255, 255, 0.15);
background: rgba(255, 255, 255, 0.04);
color: rgba(255, 255, 255, 0.7);
cursor: pointer;
font-size: 12px;
}
.kiku-preview-toggle button.active {
border-color: rgba(100, 180, 255, 0.45);
background: rgba(100, 180, 255, 0.16);
color: rgba(100, 180, 255, 0.95);
}
.kiku-preview-json {
margin: 0;
min-height: 220px;
max-height: 320px;
overflow: auto;
white-space: pre-wrap;
word-break: break-word;
border: 1px solid rgba(255, 255, 255, 0.12);
border-radius: 8px;
background: rgba(0, 0, 0, 0.34);
padding: 10px;
font-size: 11px;
line-height: 1.45;
color: rgba(255, 255, 255, 0.88);
}
.kiku-preview-error {
margin: 0 0 10px;
font-size: 12px;
color: #ff8f8f;
}
.kiku-delete-toggle {
display: inline-flex;
align-items: center;
gap: 8px;
font-size: 12px;
color: rgba(255, 255, 255, 0.8);
user-select: none;
}
.kiku-delete-toggle input {
accent-color: rgba(100, 180, 255, 0.9);
}
.kiku-confirm-button {
padding: 8px 20px;
border-radius: 6px;
border: 1px solid rgba(100, 180, 255, 0.4);
background: rgba(100, 180, 255, 0.15);
color: rgba(100, 180, 255, 0.95);
font-weight: 600;
cursor: pointer;
}
.kiku-confirm-button:hover {
background: rgba(100, 180, 255, 0.25);
}
.subsync-modal-content {
width: min(560px, 92%);
}
.subsync-form {
display: flex;
flex-direction: column;
gap: 10px;
}
.subsync-field {
display: flex;
flex-direction: column;
gap: 6px;
font-size: 13px;
color: rgba(255, 255, 255, 0.85);
}
.subsync-radio {
display: inline-flex;
align-items: center;
gap: 8px;
margin-right: 14px;
font-size: 13px;
color: rgba(255, 255, 255, 0.8);
}
.subsync-field select {
background: rgba(0, 0, 0, 0.5);
border: 1px solid rgba(255, 255, 255, 0.15);
border-radius: 6px;
color: #fff;
padding: 8px 10px;
}
.subsync-footer {
display: flex;
justify-content: flex-end;
}
.kiku-cancel-button {
padding: 8px 16px;
border-radius: 6px;
border: 1px solid rgba(255, 255, 255, 0.15);
background: transparent;
color: rgba(255, 255, 255, 0.6);
cursor: pointer;
}
.kiku-cancel-button:hover {
background: rgba(255, 255, 255, 0.08);
color: #fff;
}
.kiku-hint {
text-align: center;
font-size: 11px;
color: rgba(255, 255, 255, 0.35);
}
.runtime-modal-content {
width: min(560px, 92%);
}
.runtime-options-hint {
font-size: 12px;
color: rgba(255, 255, 255, 0.65);
}
.runtime-options-list {
list-style: none;
margin: 0;
padding: 0;
border: 1px solid rgba(255, 255, 255, 0.12);
border-radius: 8px;
max-height: 320px;
overflow-y: auto;
}
.runtime-options-item {
display: flex;
flex-direction: column;
gap: 4px;
padding: 10px 12px;
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
cursor: pointer;
}
.runtime-options-item:last-child {
border-bottom: none;
}
.runtime-options-item.active {
background: rgba(100, 180, 255, 0.15);
}
.runtime-options-label {
font-size: 14px;
color: #fff;
}
.runtime-options-value {
font-size: 13px;
color: rgba(100, 180, 255, 0.9);
}
.runtime-options-allowed {
font-size: 11px;
color: rgba(255, 255, 255, 0.55);
}
.runtime-options-status {
min-height: 18px;
font-size: 12px;
color: rgba(255, 255, 255, 0.75);
}
.runtime-options-status.error {
color: #ff8f8f;
}
@media (max-width: 640px) {
.kiku-cards-container {
grid-template-columns: 1fr;
}
}

208
src/runtime-options.ts Normal file
View File

@@ -0,0 +1,208 @@
/*
* SubMiner - All-in-one sentence mining overlay
* Copyright (C) 2024 sudacode
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import {
AnkiConnectConfig,
RuntimeOptionApplyResult,
RuntimeOptionId,
RuntimeOptionState,
RuntimeOptionValue,
} from "./types";
import { RUNTIME_OPTION_REGISTRY, RuntimeOptionRegistryEntry } from "./config";
type RuntimeOverrides = Record<string, unknown>;
function deepClone<T>(value: T): T {
return JSON.parse(JSON.stringify(value)) as T;
}
function getPathValue(source: Record<string, unknown>, path: string): unknown {
const parts = path.split(".");
let current: unknown = source;
for (const part of parts) {
if (!current || typeof current !== "object" || Array.isArray(current)) {
return undefined;
}
current = (current as Record<string, unknown>)[part];
}
return current;
}
function setPathValue(target: Record<string, unknown>, path: string, value: unknown): void {
const parts = path.split(".");
let current = target;
for (let i = 0; i < parts.length; i += 1) {
const part = parts[i];
const isLeaf = i === parts.length - 1;
if (isLeaf) {
current[part] = value;
return;
}
const next = current[part];
if (!next || typeof next !== "object" || Array.isArray(next)) {
current[part] = {};
}
current = current[part] as Record<string, unknown>;
}
}
function allowedValues(definition: RuntimeOptionRegistryEntry): RuntimeOptionValue[] {
return [...definition.allowedValues];
}
function isAllowedValue(
definition: RuntimeOptionRegistryEntry,
value: RuntimeOptionValue,
): boolean {
if (definition.valueType === "boolean") {
return typeof value === "boolean";
}
return typeof value === "string" && definition.allowedValues.includes(value);
}
export class RuntimeOptionsManager {
private readonly getAnkiConfig: () => AnkiConnectConfig;
private readonly applyAnkiPatch: (patch: Partial<AnkiConnectConfig>) => void;
private readonly onOptionsChanged: (options: RuntimeOptionState[]) => void;
private runtimeOverrides: RuntimeOverrides = {};
private readonly definitions = new Map<RuntimeOptionId, RuntimeOptionRegistryEntry>();
constructor(
getAnkiConfig: () => AnkiConnectConfig,
callbacks: {
applyAnkiPatch: (patch: Partial<AnkiConnectConfig>) => void;
onOptionsChanged: (options: RuntimeOptionState[]) => void;
},
) {
this.getAnkiConfig = getAnkiConfig;
this.applyAnkiPatch = callbacks.applyAnkiPatch;
this.onOptionsChanged = callbacks.onOptionsChanged;
for (const definition of RUNTIME_OPTION_REGISTRY) {
this.definitions.set(definition.id, definition);
}
}
private getEffectiveValue(definition: RuntimeOptionRegistryEntry): RuntimeOptionValue {
const override = getPathValue(this.runtimeOverrides, definition.path);
if (override !== undefined) return override as RuntimeOptionValue;
const source = {
ankiConnect: this.getAnkiConfig(),
} as Record<string, unknown>;
const raw = getPathValue(source, definition.path);
if (raw === undefined || raw === null) {
return definition.defaultValue;
}
return raw as RuntimeOptionValue;
}
listOptions(): RuntimeOptionState[] {
const options: RuntimeOptionState[] = [];
for (const definition of RUNTIME_OPTION_REGISTRY) {
options.push({
id: definition.id,
label: definition.label,
scope: definition.scope,
valueType: definition.valueType,
value: this.getEffectiveValue(definition),
allowedValues: allowedValues(definition),
requiresRestart: definition.requiresRestart,
});
}
return options;
}
getOptionValue(id: RuntimeOptionId): RuntimeOptionValue | undefined {
const definition = this.definitions.get(id);
if (!definition) return undefined;
return this.getEffectiveValue(definition);
}
setOptionValue(id: RuntimeOptionId, value: RuntimeOptionValue): RuntimeOptionApplyResult {
const definition = this.definitions.get(id);
if (!definition) {
return { ok: false, error: `Unknown runtime option: ${id}` };
}
if (!isAllowedValue(definition, value)) {
return {
ok: false,
error: `Invalid value for ${id}: ${String(value)}`,
};
}
const next = deepClone(this.runtimeOverrides);
setPathValue(next, definition.path, value);
this.runtimeOverrides = next;
const ankiPatch = definition.toAnkiPatch(value);
this.applyAnkiPatch(ankiPatch);
const option = this.listOptions().find((item) => item.id === id);
if (!option) {
return { ok: false, error: `Failed to apply option: ${id}` };
}
const osdMessage = `Runtime option: ${definition.label} -> ${definition.formatValueForOsd(option.value)}`;
this.onOptionsChanged(this.listOptions());
return {
ok: true,
option,
osdMessage,
requiresRestart: definition.requiresRestart,
};
}
cycleOption(id: RuntimeOptionId, direction: 1 | -1): RuntimeOptionApplyResult {
const definition = this.definitions.get(id);
if (!definition) {
return { ok: false, error: `Unknown runtime option: ${id}` };
}
const values = allowedValues(definition);
if (values.length === 0) {
return { ok: false, error: `Option ${id} has no allowed values` };
}
const currentValue = this.getEffectiveValue(definition);
const currentIndex = values.findIndex((value) => value === currentValue);
const safeIndex = currentIndex >= 0 ? currentIndex : 0;
const nextIndex =
direction === 1
? (safeIndex + 1) % values.length
: (safeIndex - 1 + values.length) % values.length;
return this.setOptionValue(id, values[nextIndex]);
}
getEffectiveAnkiConnectConfig(baseConfig?: AnkiConnectConfig): AnkiConnectConfig {
const source = baseConfig ?? this.getAnkiConfig();
const effective: AnkiConnectConfig = deepClone(source);
for (const definition of RUNTIME_OPTION_REGISTRY) {
const override = getPathValue(this.runtimeOverrides, definition.path);
if (override === undefined) continue;
const subPath = definition.path.replace(/^ankiConnect\./, "");
setPathValue(effective as unknown as Record<string, unknown>, subPath, override);
}
return effective;
}
}

View File

@@ -0,0 +1,215 @@
/*
* SubMiner - Subtitle mining overlay for mpv
* Copyright (C) 2024 sudacode
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
interface TimingEntry {
startTime: number;
endTime: number;
timestamp: number;
}
interface HistoryEntry {
displayText: string;
timingKey: string;
startTime: number;
endTime: number;
timestamp: number;
}
export class SubtitleTimingTracker {
private timings = new Map<string, TimingEntry>();
private history: HistoryEntry[] = [];
private readonly maxHistory = 200;
private readonly ttlMs = 5 * 60 * 1000;
private cleanupInterval: ReturnType<typeof setInterval> | null = null;
constructor() {
this.startCleanup();
}
recordSubtitle(text: string, startTime: number, endTime: number): void {
const normalizedText = this.normalizeText(text);
if (!normalizedText) return;
const displayText = this.prepareDisplayText(text);
const timingKey = normalizedText;
this.timings.set(timingKey, {
startTime,
endTime,
timestamp: Date.now(),
});
// Check for duplicate of most recent entry (deduplicate adjacent repeats)
const lastEntry = this.history[this.history.length - 1];
if (lastEntry && lastEntry.timingKey === timingKey) {
// Update timing to most recent occurrence
lastEntry.startTime = startTime;
lastEntry.endTime = endTime;
lastEntry.timestamp = Date.now();
return;
}
this.history.push({
displayText,
timingKey,
startTime,
endTime,
timestamp: Date.now(),
});
// Prune history if too large
if (this.history.length > this.maxHistory) {
this.history = this.history.slice(-this.maxHistory);
}
}
findTiming(text: string): { startTime: number; endTime: number } | null {
const normalizedText = this.normalizeText(text);
if (!normalizedText) return null;
const entry = this.timings.get(normalizedText);
if (!entry) {
return this.findFuzzyMatch(normalizedText);
}
return {
startTime: entry.startTime,
endTime: entry.endTime,
};
}
/**
* Get recent subtitle blocks in chronological order.
* Returns the last `count` subtitle events (oldest → newest).
* Blocks preserve internal line breaks and are joined with blank lines.
*/
getRecentBlocks(count: number): string[] {
if (count <= 0) return [];
if (count > this.history.length) {
count = this.history.length;
}
return this.history.slice(-count).map((entry) => entry.displayText);
}
/**
* Get display text for the most recent subtitle.
*/
getCurrentSubtitle(): string | null {
const lastEntry = this.history[this.history.length - 1];
return lastEntry ? lastEntry.displayText : null;
}
private findFuzzyMatch(
text: string,
): { startTime: number; endTime: number } | null {
let bestMatch: TimingEntry | null = null;
let bestScore = 0;
for (const [key, entry] of this.timings.entries()) {
const score = this.calculateSimilarity(text, key);
if (score > bestScore && score > 0.7) {
bestScore = score;
bestMatch = entry;
}
}
if (bestMatch) {
return {
startTime: bestMatch.startTime,
endTime: bestMatch.endTime,
};
}
return null;
}
private calculateSimilarity(a: string, b: string): number {
const longer = a.length > b.length ? a : b;
const shorter = a.length > b.length ? b : a;
if (longer.length === 0) return 1;
const editDistance = this.getEditDistance(longer, shorter);
return (longer.length - editDistance) / longer.length;
}
private getEditDistance(longer: string, shorter: string): number {
const costs: number[] = [];
for (let i = 0; i <= shorter.length; i++) {
let lastValue = i;
for (let j = 1; j <= longer.length; j++) {
let newValue = costs[j - 1] || 0;
if (longer.charAt(j - 1) !== shorter.charAt(i - 1)) {
newValue = Math.min(Math.min(newValue, lastValue), costs[j] || 0) + 1;
}
costs[j - 1] = lastValue;
lastValue = newValue;
}
costs[shorter.length] = lastValue;
}
return costs[shorter.length] || 0;
}
private normalizeText(text: string): string {
return text
.replace(/\\N/g, " ")
.replace(/\\n/g, " ")
.replace(/\n/g, " ")
.replace(/{[^}]*}/g, "")
.replace(/\s+/g, " ")
.trim();
}
private prepareDisplayText(text: string): string {
// Convert ASS/SSA newlines to real newlines, strip tags
return text
.replace(/\\N/g, "\n")
.replace(/\\n/g, "\n")
.replace(/{[^}]*}/g, "")
.trim();
}
private startCleanup(): void {
this.cleanupInterval = setInterval(() => {
this.cleanup();
}, 60000);
}
cleanup(): void {
const now = Date.now();
// Clean up old timing entries
for (const [key, entry] of this.timings.entries()) {
if (now - entry.timestamp > this.ttlMs) {
this.timings.delete(key);
}
}
// Clean up old history entries
this.history = this.history.filter(
(entry) => now - entry.timestamp <= this.ttlMs,
);
}
destroy(): void {
if (this.cleanupInterval) {
clearInterval(this.cleanupInterval);
this.cleanupInterval = null;
}
this.timings.clear();
this.history = [];
}
}

233
src/token-merger.ts Normal file
View File

@@ -0,0 +1,233 @@
/*
* SubMiner - All-in-one sentence mining overlay
* Copyright (C) 2024 sudacode
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import { PartOfSpeech, Token, MergedToken } from "./types";
export function isNoun(tok: Token): boolean {
return tok.partOfSpeech === PartOfSpeech.noun;
}
export function isProperNoun(tok: Token): boolean {
return tok.partOfSpeech === PartOfSpeech.noun && tok.pos2 === "固有名詞";
}
export function ignoreReading(tok: Token): boolean {
return tok.partOfSpeech === PartOfSpeech.symbol && tok.pos2 === "文字";
}
export function isCopula(tok: Token): boolean {
const raw = tok.inflectionType;
if (!raw) {
return false;
}
return ["特殊・ダ", "特殊・デス", "特殊|だ", "特殊|デス"].includes(raw);
}
export function isAuxVerb(tok: Token): boolean {
return tok.partOfSpeech === PartOfSpeech.bound_auxiliary && !isCopula(tok);
}
export function isContinuativeForm(tok: Token): boolean {
if (!tok.inflectionForm) {
return false;
}
const inflectionForm = tok.inflectionForm;
const isContinuative =
inflectionForm === "連用デ接続" ||
inflectionForm === "連用タ接続" ||
inflectionForm.startsWith("連用形");
if (!isContinuative) {
return false;
}
return tok.headword !== "ない";
}
export function isVerbSuffix(tok: Token): boolean {
return (
tok.partOfSpeech === PartOfSpeech.verb &&
(tok.pos2 === "非自立" || tok.pos2 === "接尾")
);
}
export function isTatteParticle(tok: Token): boolean {
return (
tok.partOfSpeech === PartOfSpeech.particle &&
tok.pos2 === "接続助詞" &&
tok.headword === "たって"
);
}
export function isBaParticle(tok: Token): boolean {
return (
tok.partOfSpeech === PartOfSpeech.particle &&
tok.pos2 === "接続助詞" &&
tok.word === "ば"
);
}
export function isTeDeParticle(tok: Token): boolean {
return (
tok.partOfSpeech === PartOfSpeech.particle &&
tok.pos2 === "接続助詞" &&
["て", "で", "ちゃ"].includes(tok.word)
);
}
export function isTaDaParticle(tok: Token): boolean {
return isAuxVerb(tok) && ["た", "だ"].includes(tok.word);
}
export function isVerb(tok: Token): boolean {
return [PartOfSpeech.verb, PartOfSpeech.bound_auxiliary].includes(
tok.partOfSpeech,
);
}
export function isVerbNonIndependent(): boolean {
return true;
}
export function canReceiveAuxiliary(tok: Token): boolean {
return [
PartOfSpeech.verb,
PartOfSpeech.bound_auxiliary,
PartOfSpeech.i_adjective,
].includes(tok.partOfSpeech);
}
export function isNounSuffix(tok: Token): boolean {
return tok.partOfSpeech === PartOfSpeech.verb && tok.pos2 === "接尾";
}
export function isCounter(tok: Token): boolean {
return (
tok.partOfSpeech === PartOfSpeech.noun &&
tok.pos3 !== undefined &&
tok.pos3.startsWith("助数詞")
);
}
export function isNumeral(tok: Token): boolean {
return (
tok.partOfSpeech === PartOfSpeech.noun &&
tok.pos2 !== undefined &&
tok.pos2.startsWith("数")
);
}
export function shouldMerge(lastStandaloneToken: Token, token: Token): boolean {
if (isVerb(lastStandaloneToken)) {
if (isAuxVerb(token)) {
return true;
}
if (isContinuativeForm(lastStandaloneToken) && isVerbSuffix(token)) {
return true;
}
if (isVerbSuffix(token) && isVerbNonIndependent()) {
return true;
}
}
if (
isNoun(lastStandaloneToken) &&
!isProperNoun(lastStandaloneToken) &&
isNounSuffix(token)
) {
return true;
}
if (isCounter(token) && isNumeral(lastStandaloneToken)) {
return true;
}
if (isBaParticle(token) && canReceiveAuxiliary(lastStandaloneToken)) {
return true;
}
if (isTatteParticle(token) && canReceiveAuxiliary(lastStandaloneToken)) {
return true;
}
if (isTeDeParticle(token) && isContinuativeForm(lastStandaloneToken)) {
return true;
}
if (isTaDaParticle(token) && canReceiveAuxiliary(lastStandaloneToken)) {
return true;
}
if (isTeDeParticle(lastStandaloneToken) && isVerbSuffix(token)) {
return true;
}
return false;
}
export function mergeTokens(tokens: Token[]): MergedToken[] {
if (!tokens || tokens.length === 0) {
return [];
}
const result: MergedToken[] = [];
let charOffset = 0;
let lastStandaloneToken: Token | null = null;
for (const token of tokens) {
const start = charOffset;
const end = charOffset + token.word.length;
charOffset = end;
let shouldMergeToken = false;
if (result.length > 0 && lastStandaloneToken !== null) {
shouldMergeToken = shouldMerge(lastStandaloneToken, token);
}
const tokenReading = ignoreReading(token)
? ""
: token.katakanaReading || token.word;
if (shouldMergeToken && result.length > 0) {
const prev = result.pop()!;
result.push({
surface: prev.surface + token.word,
reading: prev.reading + tokenReading,
headword: prev.headword,
startPos: prev.startPos,
endPos: end,
partOfSpeech: prev.partOfSpeech,
isMerged: true,
});
} else {
result.push({
surface: token.word,
reading: tokenReading,
headword: token.headword,
startPos: start,
endPos: end,
partOfSpeech: token.partOfSpeech,
isMerged: false,
});
}
lastStandaloneToken = token;
}
return result;
}

616
src/types.ts Normal file
View File

@@ -0,0 +1,616 @@
/*
* SubMiner - All-in-one sentence mining overlay
* Copyright (C) 2024 sudacode
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
export enum PartOfSpeech {
noun = "noun",
verb = "verb",
i_adjective = "i_adjective",
na_adjective = "na_adjective",
particle = "particle",
bound_auxiliary = "bound_auxiliary",
symbol = "symbol",
other = "other",
}
export interface Token {
word: string;
partOfSpeech: PartOfSpeech;
pos1: string;
pos2: string;
pos3: string;
pos4: string;
inflectionType: string;
inflectionForm: string;
headword: string;
katakanaReading: string;
pronunciation: string;
}
export interface MergedToken {
surface: string;
reading: string;
headword: string;
startPos: number;
endPos: number;
partOfSpeech: PartOfSpeech;
isMerged: boolean;
}
export interface WindowGeometry {
x: number;
y: number;
width: number;
height: number;
}
export interface SubtitlePosition {
yPercent: number;
}
export interface SubtitleStyle {
fontSize: number;
}
export interface Keybinding {
key: string;
command: (string | number)[] | null;
}
export type SecondarySubMode = "hidden" | "visible" | "hover";
export interface SecondarySubConfig {
secondarySubLanguages?: string[];
autoLoadSecondarySub?: boolean;
defaultMode?: SecondarySubMode;
}
export type SubsyncMode = "auto" | "manual";
export interface SubsyncConfig {
defaultMode?: SubsyncMode;
alass_path?: string;
ffsubsync_path?: string;
ffmpeg_path?: string;
}
export interface WebSocketConfig {
enabled?: boolean | "auto";
port?: number;
}
export interface TexthookerConfig {
openBrowser?: boolean;
}
export interface NotificationOptions {
body?: string;
icon?: string;
}
export interface MpvClient {
currentSubText: string;
currentVideoPath: string;
currentTimePos: number;
currentSubStart: number;
currentSubEnd: number;
currentAudioStreamIndex: number | null;
send(command: { command: unknown[]; request_id?: number }): boolean;
}
export interface KikuDuplicateCardInfo {
noteId: number;
expression: string;
sentencePreview: string;
hasAudio: boolean;
hasImage: boolean;
isOriginal: boolean;
}
export interface KikuFieldGroupingRequestData {
original: KikuDuplicateCardInfo;
duplicate: KikuDuplicateCardInfo;
}
export interface KikuFieldGroupingChoice {
keepNoteId: number;
deleteNoteId: number;
deleteDuplicate: boolean;
cancelled: boolean;
}
export interface KikuMergePreviewRequest {
keepNoteId: number;
deleteNoteId: number;
deleteDuplicate: boolean;
}
export interface KikuMergePreviewResponse {
ok: boolean;
compact?: Record<string, unknown>;
full?: Record<string, unknown>;
error?: string;
}
export type RuntimeOptionId =
| "anki.autoUpdateNewCards"
| "anki.kikuFieldGrouping";
export type RuntimeOptionScope = "ankiConnect";
export type RuntimeOptionValueType = "boolean" | "enum";
export type RuntimeOptionValue = boolean | string;
export interface RuntimeOptionState {
id: RuntimeOptionId;
label: string;
scope: RuntimeOptionScope;
valueType: RuntimeOptionValueType;
value: RuntimeOptionValue;
allowedValues: RuntimeOptionValue[];
requiresRestart: boolean;
}
export interface RuntimeOptionApplyResult {
ok: boolean;
option?: RuntimeOptionState;
osdMessage?: string;
requiresRestart?: boolean;
error?: string;
}
export interface AnkiConnectConfig {
enabled?: boolean;
url?: string;
pollingRate?: number;
fields?: {
audio?: string;
image?: string;
sentence?: string;
miscInfo?: string;
translation?: string;
};
ai?: {
enabled?: boolean;
alwaysUseAiTranslation?: boolean;
apiKey?: string;
model?: string;
baseUrl?: string;
targetLanguage?: string;
systemPrompt?: string;
};
openRouter?: {
enabled?: boolean;
alwaysUseAiTranslation?: boolean;
apiKey?: string;
model?: string;
baseUrl?: string;
targetLanguage?: string;
systemPrompt?: string;
};
media?: {
generateAudio?: boolean;
generateImage?: boolean;
imageType?: "static" | "avif";
imageFormat?: "jpg" | "png" | "webp";
imageQuality?: number;
imageMaxWidth?: number;
imageMaxHeight?: number;
animatedFps?: number;
animatedMaxWidth?: number;
animatedMaxHeight?: number;
animatedCrf?: number;
audioPadding?: number;
fallbackDuration?: number;
maxMediaDuration?: number;
};
behavior?: {
overwriteAudio?: boolean;
overwriteImage?: boolean;
mediaInsertMode?: "append" | "prepend";
highlightWord?: boolean;
notificationType?: "osd" | "system" | "both" | "none";
autoUpdateNewCards?: boolean;
};
metadata?: {
pattern?: string;
};
deck?: string;
isLapis?: {
enabled?: boolean;
sentenceCardModel?: string;
sentenceCardSentenceField?: string;
sentenceCardAudioField?: string;
};
isKiku?: {
enabled?: boolean;
fieldGrouping?: "auto" | "manual" | "disabled";
deleteDuplicateInAuto?: boolean;
};
}
export interface SubtitleStyleConfig {
fontFamily?: string;
fontSize?: number;
fontColor?: string;
fontWeight?: string;
fontStyle?: string;
backgroundColor?: string;
secondary?: {
fontFamily?: string;
fontSize?: number;
fontColor?: string;
fontWeight?: string;
fontStyle?: string;
backgroundColor?: string;
};
}
export interface ShortcutsConfig {
toggleVisibleOverlayGlobal?: string | null;
toggleInvisibleOverlayGlobal?: string | null;
copySubtitle?: string | null;
copySubtitleMultiple?: string | null;
updateLastCardFromClipboard?: string | null;
triggerFieldGrouping?: string | null;
triggerSubsync?: string | null;
mineSentence?: string | null;
mineSentenceMultiple?: string | null;
multiCopyTimeoutMs?: number;
toggleSecondarySub?: string | null;
markAudioCard?: string | null;
openRuntimeOptions?: string | null;
}
export type JimakuLanguagePreference = "ja" | "en" | "none";
export interface JimakuConfig {
apiKey?: string;
apiKeyCommand?: string;
apiBaseUrl?: string;
languagePreference?: JimakuLanguagePreference;
maxEntryResults?: number;
}
export interface InvisibleOverlayConfig {
startupVisibility?: "platform-default" | "visible" | "hidden";
}
export type YoutubeSubgenMode = "automatic" | "preprocess" | "off";
export interface YoutubeSubgenConfig {
mode?: YoutubeSubgenMode;
whisperBin?: string;
whisperModel?: string;
primarySubLanguages?: string[];
}
export interface Config {
subtitlePosition?: SubtitlePosition;
keybindings?: Keybinding[];
websocket?: WebSocketConfig;
texthooker?: TexthookerConfig;
ankiConnect?: AnkiConnectConfig;
shortcuts?: ShortcutsConfig;
secondarySub?: SecondarySubConfig;
subsync?: SubsyncConfig;
subtitleStyle?: SubtitleStyleConfig;
auto_start_overlay?: boolean;
bind_visible_overlay_to_mpv_sub_visibility?: boolean;
jimaku?: JimakuConfig;
invisibleOverlay?: InvisibleOverlayConfig;
youtubeSubgen?: YoutubeSubgenConfig;
}
export type RawConfig = Config;
export interface ResolvedConfig {
subtitlePosition: SubtitlePosition;
keybindings: Keybinding[];
websocket: Required<WebSocketConfig>;
texthooker: Required<TexthookerConfig>;
ankiConnect: AnkiConnectConfig & {
enabled: boolean;
url: string;
pollingRate: number;
fields: {
audio: string;
image: string;
sentence: string;
miscInfo: string;
translation: string;
};
ai: {
enabled: boolean;
alwaysUseAiTranslation: boolean;
apiKey: string;
model: string;
baseUrl: string;
targetLanguage: string;
systemPrompt: string;
};
media: {
generateAudio: boolean;
generateImage: boolean;
imageType: "static" | "avif";
imageFormat: "jpg" | "png" | "webp";
imageQuality: number;
imageMaxWidth?: number;
imageMaxHeight?: number;
animatedFps: number;
animatedMaxWidth: number;
animatedMaxHeight?: number;
animatedCrf: number;
audioPadding: number;
fallbackDuration: number;
maxMediaDuration: number;
};
behavior: {
overwriteAudio: boolean;
overwriteImage: boolean;
mediaInsertMode: "append" | "prepend";
highlightWord: boolean;
notificationType: "osd" | "system" | "both" | "none";
autoUpdateNewCards: boolean;
};
metadata: {
pattern: string;
};
isLapis: {
enabled: boolean;
sentenceCardModel: string;
sentenceCardSentenceField: string;
sentenceCardAudioField: string;
};
isKiku: {
enabled: boolean;
fieldGrouping: "auto" | "manual" | "disabled";
deleteDuplicateInAuto: boolean;
};
};
shortcuts: Required<ShortcutsConfig>;
secondarySub: Required<SecondarySubConfig>;
subsync: Required<SubsyncConfig>;
subtitleStyle: Required<Omit<SubtitleStyleConfig, "secondary">> & {
secondary: Required<NonNullable<SubtitleStyleConfig["secondary"]>>;
};
auto_start_overlay: boolean;
bind_visible_overlay_to_mpv_sub_visibility: boolean;
jimaku: JimakuConfig & {
apiBaseUrl: string;
languagePreference: JimakuLanguagePreference;
maxEntryResults: number;
};
invisibleOverlay: Required<InvisibleOverlayConfig>;
youtubeSubgen: YoutubeSubgenConfig & {
mode: YoutubeSubgenMode;
whisperBin: string;
whisperModel: string;
primarySubLanguages: string[];
};
}
export interface ConfigValidationWarning {
path: string;
value: unknown;
fallback: unknown;
message: string;
}
export interface SubsyncSourceTrack {
id: number;
label: string;
}
export interface SubsyncManualPayload {
sourceTracks: SubsyncSourceTrack[];
}
export interface SubsyncManualRunRequest {
engine: "alass" | "ffsubsync";
sourceTrackId?: number | null;
}
export interface SubsyncResult {
ok: boolean;
message: string;
}
export interface SubtitleData {
text: string;
tokens: MergedToken[] | null;
}
export interface MpvSubtitleRenderMetrics {
subPos: number;
subFontSize: number;
subScale: number;
subMarginY: number;
subMarginX: number;
subFont: string;
subSpacing: number;
subBold: boolean;
subItalic: boolean;
subBorderSize: number;
subShadowOffset: number;
subAssOverride: string;
subScaleByWindow: boolean;
subUseMargins: boolean;
osdHeight: number;
osdDimensions: {
w: number;
h: number;
ml: number;
mr: number;
mt: number;
mb: number;
} | null;
}
export interface MecabStatus {
available: boolean;
enabled: boolean;
path: string | null;
}
export type JimakuConfidence = "high" | "medium" | "low";
export interface JimakuMediaInfo {
title: string;
season: number | null;
episode: number | null;
confidence: JimakuConfidence;
filename: string;
rawTitle: string;
}
export interface JimakuSearchQuery {
query: string;
}
export interface JimakuEntryFlags {
anime?: boolean;
movie?: boolean;
adult?: boolean;
external?: boolean;
unverified?: boolean;
}
export interface JimakuEntry {
id: number;
name: string;
english_name?: string | null;
japanese_name?: string | null;
flags?: JimakuEntryFlags;
last_modified?: string;
}
export interface JimakuFilesQuery {
entryId: number;
episode?: number | null;
}
export interface JimakuFileEntry {
name: string;
url: string;
size: number;
last_modified: string;
}
export interface JimakuDownloadQuery {
entryId: number;
url: string;
name: string;
}
export interface JimakuApiError {
error: string;
code?: number;
retryAfter?: number;
}
export type JimakuApiResponse<T> =
| { ok: true; data: T }
| { ok: false; error: JimakuApiError };
export type JimakuDownloadResult =
| { ok: true; path: string }
| { ok: false; error: JimakuApiError };
export interface ElectronAPI {
getOverlayLayer: () => "visible" | "invisible" | null;
onSubtitle: (callback: (data: SubtitleData) => void) => void;
onVisibility: (callback: (visible: boolean) => void) => void;
onSubtitlePosition: (
callback: (position: SubtitlePosition | null) => void,
) => void;
getOverlayVisibility: () => Promise<boolean>;
getCurrentSubtitle: () => Promise<SubtitleData>;
getCurrentSubtitleAss: () => Promise<string>;
getMpvSubtitleRenderMetrics: () => Promise<MpvSubtitleRenderMetrics>;
onMpvSubtitleRenderMetrics: (
callback: (metrics: MpvSubtitleRenderMetrics) => void,
) => void;
onSubtitleAss: (callback: (assText: string) => void) => void;
onOverlayDebugVisualization: (callback: (enabled: boolean) => void) => void;
setIgnoreMouseEvents: (
ignore: boolean,
options?: { forward?: boolean },
) => void;
openYomitanSettings: () => void;
getSubtitlePosition: () => Promise<SubtitlePosition | null>;
saveSubtitlePosition: (position: SubtitlePosition) => void;
getMecabStatus: () => Promise<MecabStatus>;
setMecabEnabled: (enabled: boolean) => void;
sendMpvCommand: (command: (string | number)[]) => void;
getKeybindings: () => Promise<Keybinding[]>;
getJimakuMediaInfo: () => Promise<JimakuMediaInfo>;
jimakuSearchEntries: (
query: JimakuSearchQuery,
) => Promise<JimakuApiResponse<JimakuEntry[]>>;
jimakuListFiles: (
query: JimakuFilesQuery,
) => Promise<JimakuApiResponse<JimakuFileEntry[]>>;
jimakuDownloadFile: (
query: JimakuDownloadQuery,
) => Promise<JimakuDownloadResult>;
quitApp: () => void;
toggleDevTools: () => void;
toggleOverlay: () => void;
getAnkiConnectStatus: () => Promise<boolean>;
setAnkiConnectEnabled: (enabled: boolean) => void;
clearAnkiConnectHistory: () => void;
onSecondarySub: (callback: (text: string) => void) => void;
onSecondarySubMode: (callback: (mode: SecondarySubMode) => void) => void;
getSecondarySubMode: () => Promise<SecondarySubMode>;
getCurrentSecondarySub: () => Promise<string>;
getSubtitleStyle: () => Promise<SubtitleStyleConfig | null>;
onSubsyncManualOpen: (
callback: (payload: SubsyncManualPayload) => void,
) => void;
runSubsyncManual: (
request: SubsyncManualRunRequest,
) => Promise<SubsyncResult>;
onKikuFieldGroupingRequest: (
callback: (data: KikuFieldGroupingRequestData) => void,
) => void;
kikuBuildMergePreview: (
request: KikuMergePreviewRequest,
) => Promise<KikuMergePreviewResponse>;
kikuFieldGroupingRespond: (choice: KikuFieldGroupingChoice) => void;
getRuntimeOptions: () => Promise<RuntimeOptionState[]>;
setRuntimeOptionValue: (
id: RuntimeOptionId,
value: RuntimeOptionValue,
) => Promise<RuntimeOptionApplyResult>;
cycleRuntimeOption: (
id: RuntimeOptionId,
direction: 1 | -1,
) => Promise<RuntimeOptionApplyResult>;
onRuntimeOptionsChanged: (
callback: (options: RuntimeOptionState[]) => void,
) => void;
onOpenRuntimeOptions: (callback: () => void) => void;
notifyOverlayModalClosed: (modal: "runtime-options" | "subsync") => void;
}
declare global {
interface Window {
electronAPI: ElectronAPI;
}
}

View File

@@ -0,0 +1,68 @@
/*
SubMiner - All-in-one sentence mining overlay
Copyright (C) 2024 sudacode
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import { WindowGeometry } from "../types";
export type GeometryChangeCallback = (geometry: WindowGeometry) => void;
export type WindowFoundCallback = (geometry: WindowGeometry) => void;
export type WindowLostCallback = () => void;
export abstract class BaseWindowTracker {
protected currentGeometry: WindowGeometry | null = null;
protected windowFound: boolean = false;
public onGeometryChange: GeometryChangeCallback | null = null;
public onWindowFound: WindowFoundCallback | null = null;
public onWindowLost: WindowLostCallback | null = null;
abstract start(): void;
abstract stop(): void;
getGeometry(): WindowGeometry | null {
return this.currentGeometry;
}
isTracking(): boolean {
return this.windowFound;
}
protected updateGeometry(newGeometry: WindowGeometry | null): void {
if (newGeometry) {
if (!this.windowFound) {
this.windowFound = true;
if (this.onWindowFound) this.onWindowFound(newGeometry);
}
if (
!this.currentGeometry ||
this.currentGeometry.x !== newGeometry.x ||
this.currentGeometry.y !== newGeometry.y ||
this.currentGeometry.width !== newGeometry.width ||
this.currentGeometry.height !== newGeometry.height
) {
this.currentGeometry = newGeometry;
if (this.onGeometryChange) this.onGeometryChange(newGeometry);
}
} else {
if (this.windowFound) {
this.windowFound = false;
this.currentGeometry = null;
if (this.onWindowLost) this.onWindowLost();
}
}
}
}

View File

@@ -0,0 +1,114 @@
/*
SubMiner - All-in-one sentence mining overlay
Copyright (C) 2024 sudacode
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import * as net from "net";
import { execSync } from "child_process";
import { BaseWindowTracker } from "./base-tracker";
import { createLogger } from "../logger";
const log = createLogger("tracker").child("hyprland");
interface HyprlandClient {
class: string;
at: [number, number];
size: [number, number];
}
export class HyprlandWindowTracker extends BaseWindowTracker {
private pollInterval: ReturnType<typeof setInterval> | null = null;
private eventSocket: net.Socket | null = null;
start(): void {
this.pollInterval = setInterval(() => this.pollGeometry(), 250);
this.pollGeometry();
this.connectEventSocket();
}
stop(): void {
if (this.pollInterval) {
clearInterval(this.pollInterval);
this.pollInterval = null;
}
if (this.eventSocket) {
this.eventSocket.destroy();
this.eventSocket = null;
}
}
private connectEventSocket(): void {
const hyprlandSig = process.env.HYPRLAND_INSTANCE_SIGNATURE;
if (!hyprlandSig) {
log.info("HYPRLAND_INSTANCE_SIGNATURE not set, skipping event socket");
return;
}
const xdgRuntime = process.env.XDG_RUNTIME_DIR || "/tmp";
const socketPath = `${xdgRuntime}/hypr/${hyprlandSig}/.socket2.sock`;
this.eventSocket = new net.Socket();
this.eventSocket.on("connect", () => {
log.info("Connected to Hyprland event socket");
});
this.eventSocket.on("data", (data: Buffer) => {
const events = data.toString().split("\n");
for (const event of events) {
if (
event.includes("movewindow") ||
event.includes("windowtitle") ||
event.includes("openwindow") ||
event.includes("closewindow") ||
event.includes("fullscreen")
) {
this.pollGeometry();
}
}
});
this.eventSocket.on("error", (err: Error) => {
log.error("Hyprland event socket error:", err.message);
});
this.eventSocket.on("close", () => {
log.info("Hyprland event socket closed");
});
this.eventSocket.connect(socketPath);
}
private pollGeometry(): void {
try {
const output = execSync("hyprctl clients -j", { encoding: "utf-8" });
const clients: HyprlandClient[] = JSON.parse(output);
const mpvWindow = clients.find((c) => c.class === "mpv");
if (mpvWindow) {
this.updateGeometry({
x: mpvWindow.at[0],
y: mpvWindow.at[1],
width: mpvWindow.size[0],
height: mpvWindow.size[1],
});
} else {
this.updateGeometry(null);
}
} catch (err) {
// hyprctl not available or failed - silent fail
}
}
}

View File

@@ -0,0 +1,86 @@
/*
SubMiner - All-in-one sentence mining overlay
Copyright (C) 2024 sudacode
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import { BaseWindowTracker } from "./base-tracker";
import { HyprlandWindowTracker } from "./hyprland-tracker";
import { SwayWindowTracker } from "./sway-tracker";
import { X11WindowTracker } from "./x11-tracker";
import { MacOSWindowTracker } from "./macos-tracker";
import { createLogger } from "../logger";
const log = createLogger("tracker");
export type Compositor = "hyprland" | "sway" | "x11" | "macos" | null;
export type Backend = "auto" | Exclude<Compositor, null>;
export function detectCompositor(): Compositor {
if (process.platform === "darwin") return "macos";
if (process.env.HYPRLAND_INSTANCE_SIGNATURE) return "hyprland";
if (process.env.SWAYSOCK) return "sway";
if (process.env.XDG_SESSION_TYPE === "x11") return "x11";
return null;
}
function normalizeCompositor(value: string): Compositor | null {
const normalized = value.trim().toLowerCase();
if (normalized === "hyprland") return "hyprland";
if (normalized === "sway") return "sway";
if (normalized === "x11") return "x11";
if (normalized === "macos") return "macos";
return null;
}
export function createWindowTracker(
override?: string | null,
): BaseWindowTracker | null {
let compositor = detectCompositor();
if (override && override !== "auto") {
const normalized = normalizeCompositor(override);
if (normalized) {
compositor = normalized;
} else {
log.warn(
`Unsupported backend override "${override}", falling back to auto.`,
);
}
}
log.info(`Detected compositor: ${compositor || "none"}`);
switch (compositor) {
case "hyprland":
return new HyprlandWindowTracker();
case "sway":
return new SwayWindowTracker();
case "x11":
return new X11WindowTracker();
case "macos":
return new MacOSWindowTracker();
default:
log.warn("No supported compositor detected. Window tracking disabled.");
return null;
}
}
export {
BaseWindowTracker,
HyprlandWindowTracker,
SwayWindowTracker,
X11WindowTracker,
MacOSWindowTracker,
};

View File

@@ -0,0 +1,114 @@
/*
subminer - Yomitan integration for mpv
Copyright (C) 2024 sudacode
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import { execFile } from "child_process";
import { BaseWindowTracker } from "./base-tracker";
export class MacOSWindowTracker extends BaseWindowTracker {
private pollInterval: ReturnType<typeof setInterval> | null = null;
private pollInFlight = false;
start(): void {
this.pollInterval = setInterval(() => this.pollGeometry(), 250);
this.pollGeometry();
}
stop(): void {
if (this.pollInterval) {
clearInterval(this.pollInterval);
this.pollInterval = null;
}
}
private pollGeometry(): void {
if (this.pollInFlight) {
return;
}
this.pollInFlight = true;
const script = `
set processNames to {"mpv", "MPV", "org.mpv.mpv"}
tell application "System Events"
repeat with procName in processNames
set procList to (every process whose name is procName)
repeat with p in procList
try
if (count of windows of p) > 0 then
set targetWindow to window 1 of p
set windowPos to position of targetWindow
set windowSize to size of targetWindow
return (item 1 of windowPos) & "," & (item 2 of windowPos) & "," & (item 1 of windowSize) & "," & (item 2 of windowSize)
end if
end try
end repeat
end repeat
end tell
return "not-found"
`;
execFile(
"osascript",
["-e", script],
{
encoding: "utf-8",
timeout: 1000,
maxBuffer: 1024 * 1024,
},
(err, stdout) => {
if (err) {
this.updateGeometry(null);
this.pollInFlight = false;
return;
}
const result = (stdout || "").trim();
if (result && result !== "not-found") {
const parts = result.split(",");
if (parts.length === 4) {
const x = parseInt(parts[0], 10);
const y = parseInt(parts[1], 10);
const width = parseInt(parts[2], 10);
const height = parseInt(parts[3], 10);
if (
Number.isFinite(x) &&
Number.isFinite(y) &&
Number.isFinite(width) &&
Number.isFinite(height) &&
width > 0 &&
height > 0
) {
this.updateGeometry({
x,
y,
width,
height,
});
this.pollInFlight = false;
return;
}
}
}
this.updateGeometry(null);
this.pollInFlight = false;
},
);
}
}

View File

@@ -0,0 +1,94 @@
/*
SubMiner - All-in-one sentence mining overlay
Copyright (C) 2024 sudacode
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import { execSync } from "child_process";
import { BaseWindowTracker } from "./base-tracker";
interface SwayRect {
x: number;
y: number;
width: number;
height: number;
}
interface SwayNode {
app_id?: string;
window_properties?: { class?: string };
rect?: SwayRect;
nodes?: SwayNode[];
floating_nodes?: SwayNode[];
}
export class SwayWindowTracker extends BaseWindowTracker {
private pollInterval: ReturnType<typeof setInterval> | null = null;
start(): void {
this.pollInterval = setInterval(() => this.pollGeometry(), 250);
this.pollGeometry();
}
stop(): void {
if (this.pollInterval) {
clearInterval(this.pollInterval);
this.pollInterval = null;
}
}
private findMpvWindow(node: SwayNode): SwayNode | null {
if (node.app_id === "mpv" || node.window_properties?.class === "mpv") {
return node;
}
if (node.nodes) {
for (const child of node.nodes) {
const found = this.findMpvWindow(child);
if (found) return found;
}
}
if (node.floating_nodes) {
for (const child of node.floating_nodes) {
const found = this.findMpvWindow(child);
if (found) return found;
}
}
return null;
}
private pollGeometry(): void {
try {
const output = execSync("swaymsg -t get_tree", { encoding: "utf-8" });
const tree: SwayNode = JSON.parse(output);
const mpvWindow = this.findMpvWindow(tree);
if (mpvWindow && mpvWindow.rect) {
this.updateGeometry({
x: mpvWindow.rect.x,
y: mpvWindow.rect.y,
width: mpvWindow.rect.width,
height: mpvWindow.rect.height,
});
} else {
this.updateGeometry(null);
}
} catch (err) {
// swaymsg not available or failed - silent fail
}
}
}

View File

@@ -0,0 +1,73 @@
/*
SubMiner - All-in-one sentence mining overlay
Copyright (C) 2024 sudacode
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import { execSync } from "child_process";
import { BaseWindowTracker } from "./base-tracker";
export class X11WindowTracker extends BaseWindowTracker {
private pollInterval: ReturnType<typeof setInterval> | null = null;
start(): void {
this.pollInterval = setInterval(() => this.pollGeometry(), 250);
this.pollGeometry();
}
stop(): void {
if (this.pollInterval) {
clearInterval(this.pollInterval);
this.pollInterval = null;
}
}
private pollGeometry(): void {
try {
const windowIds = execSync("xdotool search --class mpv", {
encoding: "utf-8",
}).trim();
if (!windowIds) {
this.updateGeometry(null);
return;
}
const windowId = windowIds.split("\n")[0];
const winInfo = execSync(`xwininfo -id ${windowId}`, {
encoding: "utf-8",
});
const xMatch = winInfo.match(/Absolute upper-left X:\s*(\d+)/);
const yMatch = winInfo.match(/Absolute upper-left Y:\s*(\d+)/);
const widthMatch = winInfo.match(/Width:\s*(\d+)/);
const heightMatch = winInfo.match(/Height:\s*(\d+)/);
if (xMatch && yMatch && widthMatch && heightMatch) {
this.updateGeometry({
x: parseInt(xMatch[1], 10),
y: parseInt(yMatch[1], 10),
width: parseInt(widthMatch[1], 10),
height: parseInt(heightMatch[1], 10),
});
} else {
this.updateGeometry(null);
}
} catch (err) {
this.updateGeometry(null);
}
}
}