feat(core): add Electron runtime, services, and app composition

This commit is contained in:
2026-02-22 21:43:43 -08:00
parent 448ce03fd4
commit d3fd47f0ec
562 changed files with 69719 additions and 0 deletions

View File

@@ -0,0 +1,61 @@
import { ResolvedConfig } from '../../types';
export const CORE_DEFAULT_CONFIG: Pick<
ResolvedConfig,
| 'subtitlePosition'
| 'keybindings'
| 'websocket'
| 'logging'
| 'texthooker'
| 'shortcuts'
| 'secondarySub'
| 'subsync'
| 'auto_start_overlay'
| 'bind_visible_overlay_to_mpv_sub_visibility'
| 'invisibleOverlay'
> = {
subtitlePosition: { yPercent: 10 },
keybindings: [],
websocket: {
enabled: 'auto',
port: 6677,
},
logging: {
level: 'info',
},
texthooker: {
openBrowser: 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',
openJimaku: 'Ctrl+Shift+J',
},
secondarySub: {
secondarySubLanguages: [],
autoLoadSecondarySub: false,
defaultMode: 'hover',
},
subsync: {
defaultMode: 'auto',
alass_path: '',
ffsubsync_path: '',
ffmpeg_path: '',
},
auto_start_overlay: false,
bind_visible_overlay_to_mpv_sub_visibility: true,
invisibleOverlay: {
startupVisibility: 'platform-default',
},
};

View File

@@ -0,0 +1,20 @@
import { ResolvedConfig } from '../../types';
export const IMMERSION_DEFAULT_CONFIG: Pick<ResolvedConfig, 'immersionTracking'> = {
immersionTracking: {
enabled: true,
dbPath: '',
batchSize: 25,
flushIntervalMs: 500,
queueCap: 1000,
payloadCapBytes: 256,
maintenanceIntervalMs: 24 * 60 * 60 * 1000,
retention: {
eventsDays: 7,
telemetryDays: 30,
dailyRollupsDays: 365,
monthlyRollupsDays: 5 * 365,
vacuumIntervalDays: 7,
},
},
};

View File

@@ -0,0 +1,113 @@
import { ResolvedConfig } from '../../types';
export const INTEGRATIONS_DEFAULT_CONFIG: Pick<
ResolvedConfig,
'ankiConnect' | 'jimaku' | 'anilist' | 'jellyfin' | 'discordPresence' | 'youtubeSubgen'
> = {
ankiConnect: {
enabled: false,
url: 'http://127.0.0.1:8765',
pollingRate: 3000,
tags: ['SubMiner'],
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,
},
nPlusOne: {
highlightEnabled: false,
refreshMinutes: 1440,
matchMode: 'headword',
decks: [],
minSentenceWords: 3,
nPlusOne: '#c6a0f6',
knownWord: '#a6da95',
},
metadata: {
pattern: '[SubMiner] %f (%t)',
},
isLapis: {
enabled: false,
sentenceCardModel: 'Japanese sentences',
},
isKiku: {
enabled: false,
fieldGrouping: 'disabled',
deleteDuplicateInAuto: true,
},
},
jimaku: {
apiBaseUrl: 'https://jimaku.cc',
languagePreference: 'ja',
maxEntryResults: 10,
},
anilist: {
enabled: false,
accessToken: '',
},
jellyfin: {
enabled: false,
serverUrl: '',
username: '',
deviceId: 'subminer',
clientName: 'SubMiner',
clientVersion: '0.1.0',
defaultLibraryId: '',
remoteControlEnabled: true,
remoteControlAutoConnect: true,
autoAnnounce: false,
remoteControlDeviceName: 'SubMiner',
pullPictures: false,
iconCacheDir: '/tmp/subminer-jellyfin-icons',
directPlayPreferred: true,
directPlayContainers: ['mkv', 'mp4', 'webm', 'mov', 'flac', 'mp3', 'aac'],
transcodeVideoCodec: 'h264',
},
discordPresence: {
enabled: false,
updateIntervalMs: 3_000,
debounceMs: 750,
},
youtubeSubgen: {
mode: 'automatic',
whisperBin: '',
whisperModel: '',
primarySubLanguages: ['ja', 'jpn'],
},
};

View File

@@ -0,0 +1,42 @@
import { ResolvedConfig } from '../../types';
export const SUBTITLE_DEFAULT_CONFIG: Pick<ResolvedConfig, 'subtitleStyle'> = {
subtitleStyle: {
enableJlpt: false,
preserveLineBreaks: false,
hoverTokenColor: '#c6a0f6',
fontFamily:
'M PLUS 1, Noto Sans CJK JP Regular, Noto Sans CJK JP, Hiragino Sans, Hiragino Kaku Gothic ProN, Yu Gothic, Arial Unicode MS, Arial, sans-serif',
fontSize: 35,
fontColor: '#cad3f5',
fontWeight: 'normal',
fontStyle: 'normal',
backgroundColor: 'rgb(30, 32, 48, 0.88)',
nPlusOneColor: '#c6a0f6',
knownWordColor: '#a6da95',
jlptColors: {
N1: '#ed8796',
N2: '#f5a97f',
N3: '#f9e2af',
N4: '#a6e3a1',
N5: '#8aadf4',
},
frequencyDictionary: {
enabled: false,
sourcePath: '',
topX: 1000,
mode: 'single',
singleColor: '#f5a97f',
bandedColors: ['#ed8796', '#f5a97f', '#f9e2af', '#a6e3a1', '#8aadf4'],
},
secondary: {
fontSize: 24,
fontColor: '#ffffff',
backgroundColor: 'transparent',
fontWeight: 'normal',
fontStyle: 'normal',
fontFamily:
'M PLUS 1, Noto Sans CJK JP Regular, Noto Sans CJK JP, Hiragino Sans, Hiragino Kaku Gothic ProN, Yu Gothic, Arial Unicode MS, Arial, sans-serif',
},
},
};

View File

@@ -0,0 +1,59 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
CONFIG_OPTION_REGISTRY,
CONFIG_TEMPLATE_SECTIONS,
DEFAULT_CONFIG,
RUNTIME_OPTION_REGISTRY,
} from '../definitions';
import { buildCoreConfigOptionRegistry } from './options-core';
import { buildImmersionConfigOptionRegistry } from './options-immersion';
import { buildIntegrationConfigOptionRegistry } from './options-integrations';
import { buildSubtitleConfigOptionRegistry } from './options-subtitle';
test('config option registry includes critical paths and has unique entries', () => {
const paths = CONFIG_OPTION_REGISTRY.map((entry) => entry.path);
for (const requiredPath of [
'logging.level',
'subtitleStyle.enableJlpt',
'ankiConnect.enabled',
'immersionTracking.enabled',
]) {
assert.ok(paths.includes(requiredPath), `missing config path: ${requiredPath}`);
}
assert.equal(new Set(paths).size, paths.length);
});
test('config template sections include expected domains and unique keys', () => {
const keys = CONFIG_TEMPLATE_SECTIONS.map((section) => section.key);
const requiredKeys: (typeof keys)[number][] = [
'websocket',
'subtitleStyle',
'ankiConnect',
'immersionTracking',
];
for (const requiredKey of requiredKeys) {
assert.ok(keys.includes(requiredKey), `missing template section key: ${requiredKey}`);
}
assert.equal(new Set(keys).size, keys.length);
});
test('domain registry builders each contribute entries to composed registry', () => {
const domainEntries = [
buildCoreConfigOptionRegistry(DEFAULT_CONFIG),
buildSubtitleConfigOptionRegistry(DEFAULT_CONFIG),
buildIntegrationConfigOptionRegistry(DEFAULT_CONFIG, RUNTIME_OPTION_REGISTRY),
buildImmersionConfigOptionRegistry(DEFAULT_CONFIG),
];
const composedPaths = new Set(CONFIG_OPTION_REGISTRY.map((entry) => entry.path));
for (const entries of domainEntries) {
assert.ok(entries.length > 0);
assert.ok(entries.some((entry) => composedPaths.has(entry.path)));
}
});

View File

@@ -0,0 +1,49 @@
import { ResolvedConfig } from '../../types';
import { ConfigOptionRegistryEntry } from './shared';
export function buildCoreConfigOptionRegistry(
defaultConfig: ResolvedConfig,
): ConfigOptionRegistryEntry[] {
return [
{
path: 'logging.level',
kind: 'enum',
enumValues: ['debug', 'info', 'warn', 'error'],
defaultValue: defaultConfig.logging.level,
description: 'Minimum log level for runtime logging.',
},
{
path: 'websocket.enabled',
kind: 'enum',
enumValues: ['auto', 'true', 'false'],
defaultValue: defaultConfig.websocket.enabled,
description: 'Built-in subtitle websocket server mode.',
},
{
path: 'websocket.port',
kind: 'number',
defaultValue: defaultConfig.websocket.port,
description: 'Built-in subtitle websocket server port.',
},
{
path: 'subsync.defaultMode',
kind: 'enum',
enumValues: ['auto', 'manual'],
defaultValue: defaultConfig.subsync.defaultMode,
description: 'Subsync default mode.',
},
{
path: 'shortcuts.multiCopyTimeoutMs',
kind: 'number',
defaultValue: defaultConfig.shortcuts.multiCopyTimeoutMs,
description: 'Timeout for multi-copy/mine modes.',
},
{
path: 'bind_visible_overlay_to_mpv_sub_visibility',
kind: 'boolean',
defaultValue: defaultConfig.bind_visible_overlay_to_mpv_sub_visibility,
description:
'Link visible overlay toggles to MPV subtitle visibility (primary and secondary).',
},
];
}

View File

@@ -0,0 +1,82 @@
import { ResolvedConfig } from '../../types';
import { ConfigOptionRegistryEntry } from './shared';
export function buildImmersionConfigOptionRegistry(
defaultConfig: ResolvedConfig,
): ConfigOptionRegistryEntry[] {
return [
{
path: 'immersionTracking.enabled',
kind: 'boolean',
defaultValue: defaultConfig.immersionTracking.enabled,
description: 'Enable immersion tracking for mined subtitle metadata.',
},
{
path: 'immersionTracking.dbPath',
kind: 'string',
defaultValue: defaultConfig.immersionTracking.dbPath,
description:
'Optional SQLite database path for immersion tracking. Empty value uses the default app data path.',
},
{
path: 'immersionTracking.batchSize',
kind: 'number',
defaultValue: defaultConfig.immersionTracking.batchSize,
description: 'Buffered telemetry/event writes per SQLite transaction.',
},
{
path: 'immersionTracking.flushIntervalMs',
kind: 'number',
defaultValue: defaultConfig.immersionTracking.flushIntervalMs,
description: 'Max delay before queue flush in milliseconds.',
},
{
path: 'immersionTracking.queueCap',
kind: 'number',
defaultValue: defaultConfig.immersionTracking.queueCap,
description: 'In-memory write queue cap before overflow policy applies.',
},
{
path: 'immersionTracking.payloadCapBytes',
kind: 'number',
defaultValue: defaultConfig.immersionTracking.payloadCapBytes,
description: 'Max JSON payload size per event before truncation.',
},
{
path: 'immersionTracking.maintenanceIntervalMs',
kind: 'number',
defaultValue: defaultConfig.immersionTracking.maintenanceIntervalMs,
description: 'Maintenance cadence (prune + rollup + vacuum checks).',
},
{
path: 'immersionTracking.retention.eventsDays',
kind: 'number',
defaultValue: defaultConfig.immersionTracking.retention.eventsDays,
description: 'Raw event retention window in days.',
},
{
path: 'immersionTracking.retention.telemetryDays',
kind: 'number',
defaultValue: defaultConfig.immersionTracking.retention.telemetryDays,
description: 'Telemetry retention window in days.',
},
{
path: 'immersionTracking.retention.dailyRollupsDays',
kind: 'number',
defaultValue: defaultConfig.immersionTracking.retention.dailyRollupsDays,
description: 'Daily rollup retention window in days.',
},
{
path: 'immersionTracking.retention.monthlyRollupsDays',
kind: 'number',
defaultValue: defaultConfig.immersionTracking.retention.monthlyRollupsDays,
description: 'Monthly rollup retention window in days.',
},
{
path: 'immersionTracking.retention.vacuumIntervalDays',
kind: 'number',
defaultValue: defaultConfig.immersionTracking.retention.vacuumIntervalDays,
description: 'Minimum days between VACUUM runs.',
},
];
}

View File

@@ -0,0 +1,235 @@
import { ResolvedConfig } from '../../types';
import { ConfigOptionRegistryEntry, RuntimeOptionRegistryEntry } from './shared';
export function buildIntegrationConfigOptionRegistry(
defaultConfig: ResolvedConfig,
runtimeOptionRegistry: RuntimeOptionRegistryEntry[],
): ConfigOptionRegistryEntry[] {
return [
{
path: 'ankiConnect.enabled',
kind: 'boolean',
defaultValue: defaultConfig.ankiConnect.enabled,
description: 'Enable AnkiConnect integration.',
},
{
path: 'ankiConnect.pollingRate',
kind: 'number',
defaultValue: defaultConfig.ankiConnect.pollingRate,
description: 'Polling interval in milliseconds.',
},
{
path: 'ankiConnect.tags',
kind: 'array',
defaultValue: defaultConfig.ankiConnect.tags,
description:
'Tags to add to cards mined or updated by SubMiner. Provide an empty array to disable automatic tagging.',
},
{
path: 'ankiConnect.behavior.autoUpdateNewCards',
kind: 'boolean',
defaultValue: defaultConfig.ankiConnect.behavior.autoUpdateNewCards,
description: 'Automatically update newly added cards.',
runtime: runtimeOptionRegistry[0],
},
{
path: 'ankiConnect.nPlusOne.matchMode',
kind: 'enum',
enumValues: ['headword', 'surface'],
defaultValue: defaultConfig.ankiConnect.nPlusOne.matchMode,
description: 'Known-word matching strategy for N+1 highlighting.',
},
{
path: 'ankiConnect.nPlusOne.highlightEnabled',
kind: 'boolean',
defaultValue: defaultConfig.ankiConnect.nPlusOne.highlightEnabled,
description: 'Enable fast local highlighting for words already known in Anki.',
},
{
path: 'ankiConnect.nPlusOne.refreshMinutes',
kind: 'number',
defaultValue: defaultConfig.ankiConnect.nPlusOne.refreshMinutes,
description: 'Minutes between known-word cache refreshes.',
},
{
path: 'ankiConnect.nPlusOne.minSentenceWords',
kind: 'number',
defaultValue: defaultConfig.ankiConnect.nPlusOne.minSentenceWords,
description: 'Minimum sentence word count required for N+1 targeting (default: 3).',
},
{
path: 'ankiConnect.nPlusOne.decks',
kind: 'array',
defaultValue: defaultConfig.ankiConnect.nPlusOne.decks,
description: 'Decks used for N+1 known-word cache scope. Supports one or more deck names.',
},
{
path: 'ankiConnect.nPlusOne.nPlusOne',
kind: 'string',
defaultValue: defaultConfig.ankiConnect.nPlusOne.nPlusOne,
description: 'Color used for the single N+1 target token highlight.',
},
{
path: 'ankiConnect.nPlusOne.knownWord',
kind: 'string',
defaultValue: defaultConfig.ankiConnect.nPlusOne.knownWord,
description: 'Color used for legacy known-word highlights.',
},
{
path: 'ankiConnect.isKiku.fieldGrouping',
kind: 'enum',
enumValues: ['auto', 'manual', 'disabled'],
defaultValue: defaultConfig.ankiConnect.isKiku.fieldGrouping,
description: 'Kiku duplicate-card field grouping mode.',
runtime: runtimeOptionRegistry[1],
},
{
path: 'jimaku.languagePreference',
kind: 'enum',
enumValues: ['ja', 'en', 'none'],
defaultValue: defaultConfig.jimaku.languagePreference,
description: 'Preferred language used in Jimaku search.',
},
{
path: 'jimaku.maxEntryResults',
kind: 'number',
defaultValue: defaultConfig.jimaku.maxEntryResults,
description: 'Maximum Jimaku search results returned.',
},
{
path: 'anilist.enabled',
kind: 'boolean',
defaultValue: defaultConfig.anilist.enabled,
description: 'Enable AniList post-watch progress updates.',
},
{
path: 'anilist.accessToken',
kind: 'string',
defaultValue: defaultConfig.anilist.accessToken,
description:
'Optional explicit AniList access token override; leave empty to use locally stored token from setup.',
},
{
path: 'jellyfin.enabled',
kind: 'boolean',
defaultValue: defaultConfig.jellyfin.enabled,
description: 'Enable optional Jellyfin integration and CLI control commands.',
},
{
path: 'jellyfin.serverUrl',
kind: 'string',
defaultValue: defaultConfig.jellyfin.serverUrl,
description: 'Base Jellyfin server URL (for example: http://localhost:8096).',
},
{
path: 'jellyfin.username',
kind: 'string',
defaultValue: defaultConfig.jellyfin.username,
description: 'Default Jellyfin username used during CLI login.',
},
{
path: 'jellyfin.defaultLibraryId',
kind: 'string',
defaultValue: defaultConfig.jellyfin.defaultLibraryId,
description: 'Optional default Jellyfin library ID for item listing.',
},
{
path: 'jellyfin.remoteControlEnabled',
kind: 'boolean',
defaultValue: defaultConfig.jellyfin.remoteControlEnabled,
description: 'Enable Jellyfin remote cast control mode.',
},
{
path: 'jellyfin.remoteControlAutoConnect',
kind: 'boolean',
defaultValue: defaultConfig.jellyfin.remoteControlAutoConnect,
description: 'Auto-connect to the configured remote control target.',
},
{
path: 'jellyfin.autoAnnounce',
kind: 'boolean',
defaultValue: defaultConfig.jellyfin.autoAnnounce,
description:
'When enabled, automatically trigger remote announce/visibility check on websocket connect.',
},
{
path: 'jellyfin.remoteControlDeviceName',
kind: 'string',
defaultValue: defaultConfig.jellyfin.remoteControlDeviceName,
description: 'Device name reported for Jellyfin remote control sessions.',
},
{
path: 'jellyfin.pullPictures',
kind: 'boolean',
defaultValue: defaultConfig.jellyfin.pullPictures,
description: 'Enable Jellyfin poster/icon fetching for launcher menus.',
},
{
path: 'jellyfin.iconCacheDir',
kind: 'string',
defaultValue: defaultConfig.jellyfin.iconCacheDir,
description: 'Directory used by launcher for cached Jellyfin poster icons.',
},
{
path: 'jellyfin.directPlayPreferred',
kind: 'boolean',
defaultValue: defaultConfig.jellyfin.directPlayPreferred,
description: 'Try direct play before server-managed transcoding when possible.',
},
{
path: 'jellyfin.directPlayContainers',
kind: 'array',
defaultValue: defaultConfig.jellyfin.directPlayContainers,
description: 'Container allowlist for direct play decisions.',
},
{
path: 'jellyfin.transcodeVideoCodec',
kind: 'string',
defaultValue: defaultConfig.jellyfin.transcodeVideoCodec,
description: 'Preferred transcode video codec when direct play is unavailable.',
},
{
path: 'discordPresence.enabled',
kind: 'boolean',
defaultValue: defaultConfig.discordPresence.enabled,
description: 'Enable optional Discord Rich Presence updates.',
},
{
path: 'discordPresence.updateIntervalMs',
kind: 'number',
defaultValue: defaultConfig.discordPresence.updateIntervalMs,
description: 'Minimum interval between presence payload updates.',
},
{
path: 'discordPresence.debounceMs',
kind: 'number',
defaultValue: defaultConfig.discordPresence.debounceMs,
description: 'Debounce delay used to collapse bursty presence updates.',
},
{
path: 'youtubeSubgen.mode',
kind: 'enum',
enumValues: ['automatic', 'preprocess', 'off'],
defaultValue: defaultConfig.youtubeSubgen.mode,
description: 'YouTube subtitle generation mode for the launcher script.',
},
{
path: 'youtubeSubgen.whisperBin',
kind: 'string',
defaultValue: defaultConfig.youtubeSubgen.whisperBin,
description: 'Path to whisper.cpp CLI used as fallback transcription engine.',
},
{
path: 'youtubeSubgen.whisperModel',
kind: 'string',
defaultValue: defaultConfig.youtubeSubgen.whisperModel,
description: 'Path to whisper model used for fallback transcription.',
},
{
path: 'youtubeSubgen.primarySubLanguages',
kind: 'string',
defaultValue: defaultConfig.youtubeSubgen.primarySubLanguages.join(','),
description: 'Comma-separated primary subtitle language priority used by the launcher.',
},
];
}

View File

@@ -0,0 +1,72 @@
import { ResolvedConfig } from '../../types';
import { ConfigOptionRegistryEntry } from './shared';
export function buildSubtitleConfigOptionRegistry(
defaultConfig: ResolvedConfig,
): ConfigOptionRegistryEntry[] {
return [
{
path: 'subtitleStyle.enableJlpt',
kind: 'boolean',
defaultValue: defaultConfig.subtitleStyle.enableJlpt,
description:
'Enable JLPT vocabulary level underlines. ' +
'When disabled, JLPT tagging lookup and underlines are skipped.',
},
{
path: 'subtitleStyle.preserveLineBreaks',
kind: 'boolean',
defaultValue: defaultConfig.subtitleStyle.preserveLineBreaks,
description:
'Preserve line breaks in visible overlay subtitle rendering. ' +
'When false, line breaks are flattened to spaces for a single-line flow.',
},
{
path: 'subtitleStyle.hoverTokenColor',
kind: 'string',
defaultValue: defaultConfig.subtitleStyle.hoverTokenColor,
description: 'Hex color used for hovered subtitle token highlight in mpv.',
},
{
path: 'subtitleStyle.frequencyDictionary.enabled',
kind: 'boolean',
defaultValue: defaultConfig.subtitleStyle.frequencyDictionary.enabled,
description: 'Enable frequency-dictionary-based highlighting based on token rank.',
},
{
path: 'subtitleStyle.frequencyDictionary.sourcePath',
kind: 'string',
defaultValue: defaultConfig.subtitleStyle.frequencyDictionary.sourcePath,
description:
'Optional absolute path to a frequency dictionary directory.' +
' If empty, built-in discovery search paths are used.',
},
{
path: 'subtitleStyle.frequencyDictionary.topX',
kind: 'number',
defaultValue: defaultConfig.subtitleStyle.frequencyDictionary.topX,
description: 'Only color tokens with frequency rank <= topX (default: 1000).',
},
{
path: 'subtitleStyle.frequencyDictionary.mode',
kind: 'enum',
enumValues: ['single', 'banded'],
defaultValue: defaultConfig.subtitleStyle.frequencyDictionary.mode,
description:
'single: use one color for all matching tokens. banded: use color ramp by frequency band.',
},
{
path: 'subtitleStyle.frequencyDictionary.singleColor',
kind: 'string',
defaultValue: defaultConfig.subtitleStyle.frequencyDictionary.singleColor,
description: 'Color used when frequencyDictionary.mode is `single`.',
},
{
path: 'subtitleStyle.frequencyDictionary.bandedColors',
kind: 'array',
defaultValue: defaultConfig.subtitleStyle.frequencyDictionary.bandedColors,
description:
'Five colors used for rank bands when mode is `banded` (from most common to least within topX).',
},
];
}

View File

@@ -0,0 +1,56 @@
import { ResolvedConfig } from '../../types';
import { RuntimeOptionRegistryEntry } from './shared';
export function buildRuntimeOptionRegistry(
defaultConfig: ResolvedConfig,
): RuntimeOptionRegistryEntry[] {
return [
{
id: 'anki.autoUpdateNewCards',
path: 'ankiConnect.behavior.autoUpdateNewCards',
label: 'Auto Update New Cards',
scope: 'ankiConnect',
valueType: 'boolean',
allowedValues: [true, false],
defaultValue: defaultConfig.ankiConnect.behavior.autoUpdateNewCards,
requiresRestart: false,
formatValueForOsd: (value) => (value === true ? 'On' : 'Off'),
toAnkiPatch: (value) => ({
behavior: { autoUpdateNewCards: value === true },
}),
},
{
id: 'anki.nPlusOneMatchMode',
path: 'ankiConnect.nPlusOne.matchMode',
label: 'N+1 Match Mode',
scope: 'ankiConnect',
valueType: 'enum',
allowedValues: ['headword', 'surface'],
defaultValue: defaultConfig.ankiConnect.nPlusOne.matchMode,
requiresRestart: false,
formatValueForOsd: (value) => String(value),
toAnkiPatch: (value) => ({
nPlusOne: {
matchMode: value === 'headword' || value === 'surface' ? value : 'headword',
},
}),
},
{
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',
},
}),
},
];
}

View File

@@ -0,0 +1,61 @@
import {
AnkiConnectConfig,
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'] },
];

View File

@@ -0,0 +1,154 @@
import { ConfigTemplateSection } from './shared';
const CORE_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: 'Logging',
description: ['Controls logging verbosity.', 'Set to debug for full runtime diagnostics.'],
key: 'logging',
},
{
title: 'Keyboard Shortcuts',
description: ['Overlay keyboard shortcuts. Set a shortcut to null to disable.'],
notes: ['Hot-reload: shortcut changes apply live and update the session help modal on reopen.'],
key: 'shortcuts',
},
{
title: 'Invisible Overlay',
description: ['Startup behavior for the invisible interactive subtitle mining layer.'],
notes: [
'Invisible subtitle position edit mode: Ctrl/Cmd+Shift+P to toggle, arrow keys to move, Enter or Ctrl/Cmd+S to save, Esc to cancel.',
'This edit-mode shortcut is fixed and is not currently configurable.',
],
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.',
],
notes: [
'Hot-reload: keybinding changes apply live and update the session help modal on reopen.',
],
key: 'keybindings',
},
{
title: 'Secondary Subtitles',
description: [
'Dual subtitle track options.',
'Used by subminer YouTube subtitle generation as secondary language preferences.',
],
notes: ['Hot-reload: defaultMode updates live while SubMiner is running.'],
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',
},
];
const SUBTITLE_TEMPLATE_SECTIONS: ConfigTemplateSection[] = [
{
title: 'Subtitle Appearance',
description: ['Primary and secondary subtitle styling.'],
notes: ['Hot-reload: subtitle style changes apply live without restarting SubMiner.'],
key: 'subtitleStyle',
},
];
const INTEGRATION_TEMPLATE_SECTIONS: ConfigTemplateSection[] = [
{
title: 'AnkiConnect Integration',
description: ['Automatic Anki updates and media generation options.'],
notes: [
'Hot-reload: AI translation settings update live while SubMiner is running.',
'Most other AnkiConnect settings still require restart.',
],
key: 'ankiConnect',
},
{
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',
},
{
title: 'Anilist',
description: ['Anilist API credentials and update behavior.'],
key: 'anilist',
},
{
title: 'Jellyfin',
description: [
'Optional Jellyfin integration for auth, browsing, and playback launch.',
'Access token is stored in local encrypted token storage after login/setup.',
'jellyfin.accessToken remains an optional explicit override in config.',
],
key: 'jellyfin',
},
{
title: 'Discord Rich Presence',
description: [
'Optional Discord Rich Presence activity card updates for current playback/study session.',
'Uses official SubMiner Discord app assets for polished card visuals.',
],
key: 'discordPresence',
},
];
const IMMERSION_TEMPLATE_SECTIONS: ConfigTemplateSection[] = [
{
title: 'Immersion Tracking',
description: [
'Enable/disable immersion tracking.',
'Set dbPath to override the default sqlite database location.',
'Policy tuning is available for queue, flush, and retention values.',
],
key: 'immersionTracking',
},
];
export const CONFIG_TEMPLATE_SECTIONS: ConfigTemplateSection[] = [
...CORE_TEMPLATE_SECTIONS,
...SUBTITLE_TEMPLATE_SECTIONS,
...INTEGRATION_TEMPLATE_SECTIONS,
...IMMERSION_TEMPLATE_SECTIONS,
];