mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-03-20 12:11:28 -07:00
feat(core): add Electron runtime, services, and app composition
This commit is contained in:
61
src/config/definitions/defaults-core.ts
Normal file
61
src/config/definitions/defaults-core.ts
Normal 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',
|
||||
},
|
||||
};
|
||||
20
src/config/definitions/defaults-immersion.ts
Normal file
20
src/config/definitions/defaults-immersion.ts
Normal 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,
|
||||
},
|
||||
},
|
||||
};
|
||||
113
src/config/definitions/defaults-integrations.ts
Normal file
113
src/config/definitions/defaults-integrations.ts
Normal 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'],
|
||||
},
|
||||
};
|
||||
42
src/config/definitions/defaults-subtitle.ts
Normal file
42
src/config/definitions/defaults-subtitle.ts
Normal 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',
|
||||
},
|
||||
},
|
||||
};
|
||||
59
src/config/definitions/domain-registry.test.ts
Normal file
59
src/config/definitions/domain-registry.test.ts
Normal 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)));
|
||||
}
|
||||
});
|
||||
49
src/config/definitions/options-core.ts
Normal file
49
src/config/definitions/options-core.ts
Normal 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).',
|
||||
},
|
||||
];
|
||||
}
|
||||
82
src/config/definitions/options-immersion.ts
Normal file
82
src/config/definitions/options-immersion.ts
Normal 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.',
|
||||
},
|
||||
];
|
||||
}
|
||||
235
src/config/definitions/options-integrations.ts
Normal file
235
src/config/definitions/options-integrations.ts
Normal 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.',
|
||||
},
|
||||
];
|
||||
}
|
||||
72
src/config/definitions/options-subtitle.ts
Normal file
72
src/config/definitions/options-subtitle.ts
Normal 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).',
|
||||
},
|
||||
];
|
||||
}
|
||||
56
src/config/definitions/runtime-options.ts
Normal file
56
src/config/definitions/runtime-options.ts
Normal 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',
|
||||
},
|
||||
}),
|
||||
},
|
||||
];
|
||||
}
|
||||
61
src/config/definitions/shared.ts
Normal file
61
src/config/definitions/shared.ts
Normal 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'] },
|
||||
];
|
||||
154
src/config/definitions/template-sections.ts
Normal file
154
src/config/definitions/template-sections.ts
Normal 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,
|
||||
];
|
||||
Reference in New Issue
Block a user