diff --git a/backlog/tasks/task-78 - Modularize-config-definitions-and-validation-by-domain.md b/backlog/tasks/task-78 - Modularize-config-definitions-and-validation-by-domain.md index 050cbcd..76b69c0 100644 --- a/backlog/tasks/task-78 - Modularize-config-definitions-and-validation-by-domain.md +++ b/backlog/tasks/task-78 - Modularize-config-definitions-and-validation-by-domain.md @@ -1,10 +1,10 @@ --- id: TASK-78 title: Modularize config definitions and validation by domain -status: To Do +status: Done assignee: [] created_date: '2026-02-18 11:43' -updated_date: '2026-02-18 11:43' +updated_date: '2026-02-22 00:06' labels: - config - refactor @@ -42,15 +42,38 @@ Config defaults and resolution are still concentrated in large files (`src/confi ## Acceptance Criteria -- [ ] #1 Config definitions/validation are split by domain with clear ownership -- [ ] #2 `ConfigService` API remains backward-compatible -- [ ] #3 Validation behavior remains stable under existing tests -- [ ] #4 New domain-level tests prevent regression in future config additions +- [x] #1 Config definitions/validation are split by domain with clear ownership +- [x] #2 `ConfigService` API remains backward-compatible +- [x] #3 Validation behavior remains stable under existing tests +- [x] #4 New domain-level tests prevent regression in future config additions +## Implementation Notes + + +2026-02-21: Started execution via writing-plans/executing-plans workflow in opencode session `opencode-task78-config-domain-20260221T235604Z-p9x2`. + +2026-02-22: Refactored `src/config/definitions.ts` into a composed facade over domain modules under `src/config/definitions/` (`defaults-*.ts`, `options-*.ts`, `runtime-options.ts`, `template-sections.ts`, `shared.ts`) while preserving exported API names and `ConfigService` behavior. + +Added domain-level regression tests in `src/config/definitions/domain-registry.test.ts` for critical registry paths, duplicate-path guard, template section key uniqueness, and per-domain contributor coverage. + +Updated contributor docs (`docs/development.md`) with config extension workflow by domain ownership and composition entrypoint guidance. + +Verification: `bun test src/config/definitions/domain-registry.test.ts` (3 pass), `bun run test:config:src` (49 pass), `bun run test:core:src` (219 pass, 6 skip). + +`make generate-config` currently blocked by pre-existing TypeScript errors in `src/main/state.test.ts` missing exports from `src/main/state` (outside TASK-78 scope). + +Follow-up: wired `src/config/definitions/domain-registry.test.ts` into `package.json` config test lanes (`test:config:src` + `test:config:dist`). Re-ran `bun run test:config:src` => 52 pass. + + +## Final Summary + + +Modularized config definition ownership by introducing domain-specific defaults, option registry builders, runtime-option metadata, and template-section modules under `src/config/definitions/`, with `src/config/definitions.ts` preserved as the single composed public API. Added domain-level registry tests and updated contributor docs for the new config extension workflow; config/core source test lanes pass. + + ## Definition of Done -- [ ] #1 Config and core tests pass -- [ ] #2 Documentation updated for config extension workflow +- [x] #1 Config and core tests pass +- [x] #2 Documentation updated for config extension workflow - diff --git a/docs/development.md b/docs/development.md index ee94a56..688d604 100644 --- a/docs/development.md +++ b/docs/development.md @@ -62,6 +62,7 @@ electron . --background # tray/background mode, minimal de ```bash bun run test:config # Source-level config schema/validation tests +bun run test:launcher # Launcher regression tests (config discovery + command routing) bun run test:core # Source-level core regression tests (default lane) bun run test:subtitle # Subtitle pipeline tests (build + run) bun run test:fast # Source-level config + core lane (no build prerequisite) @@ -115,7 +116,10 @@ Run `make help` for a full list of targets. Key ones: ## Contributor Notes -- To add or change a config option, update `src/config/definitions.ts` first. Defaults, runtime-option metadata, and generated `config.example.jsonc` are derived from this centralized source. +- To add/change a config default, edit the matching domain file in `src/config/definitions/defaults-*.ts`. +- To add/change config option metadata, edit the matching domain file in `src/config/definitions/options-*.ts`. +- To add/change generated config template blocks/comments, update `src/config/definitions/template-sections.ts`. +- Keep `src/config/definitions.ts` as the composed public API (`DEFAULT_CONFIG`, registries, template export) that wires domain modules together. - Overlay window/visibility state is owned by `src/core/services/overlay-manager.ts`. - Main process composition is split across `src/main/` modules plus focused runtime composers under `src/main/runtime/composers/*` (for example AniList tracking and MPV runtime assembly clusters). - Runtime composer contracts should use shared helpers in `src/main/runtime/composers/contracts.ts` (`ComposerInputs`, `ComposerOutputs`, and `BuiltMainDeps`) so required deps remain compile-time enforced. diff --git a/docs/subagents/agents/opencode-task78-config-domain-20260221T235604Z-p9x2.md b/docs/subagents/agents/opencode-task78-config-domain-20260221T235604Z-p9x2.md new file mode 100644 index 0000000..b0e95f4 --- /dev/null +++ b/docs/subagents/agents/opencode-task78-config-domain-20260221T235604Z-p9x2.md @@ -0,0 +1,36 @@ +# Agent Session: opencode-task78-config-domain-20260221T235604Z-p9x2 + +- alias: `opencode-task78-config-domain` +- mission: `Execute TASK-78 modularize config definitions and validation by domain end-to-end without commit` +- status: `done` +- last_update_utc: `2026-02-22T00:06:30Z` + +## Intent + +- Pull TASK-78 from Backlog MCP; use writing-plans then executing-plans workflow. +- Preserve `ConfigService` public API compatibility. +- Split config definitions/validation into domain modules; add focused domain tests. + +## Planned Files + +- `src/config/definitions.ts` +- `src/config/service.ts` +- `src/config/**` +- `docs/configuration.md` +- `docs/development.md` (if test workflow/docs update needed) + +## Assumptions + +- Backlog initialized; TASK-78 exists and ready. +- Existing config tests are baseline for behavior parity. + +## Log + +- `2026-02-21T23:56:04Z` started; loaded backlog overview + TASK-78 context; beginning planning workflow. +- `2026-02-21T23:58:00Z` wrote execution plan at `docs/plans/2026-02-21-task78-config-domain-modularization-plan.md` using writing-plans skill. +- `2026-02-22T00:03:00Z` implemented domain modularization: `src/config/definitions.ts` now composes domain modules under `src/config/definitions/*`. +- `2026-02-22T00:04:00Z` parallel subagents completed docs + domain-registry tests (`src/config/definitions/domain-registry.test.ts`, `docs/development.md`). +- `2026-02-22T00:05:00Z` verification: `bun test src/config/definitions/domain-registry.test.ts`, `bun run test:config:src`, `bun run test:core:src` passed. +- `2026-02-22T00:05:00Z` blocker outside scope: `make generate-config` fails due pre-existing `src/main/state.test.ts` missing exports from `src/main/state`. +- `2026-02-22T00:05:00Z` backlog TASK-78 set to Done with AC/DoD checked and final summary. +- `2026-02-22T00:06:30Z` wired domain registry test into package config lanes (`test:config:src`, `test:config:dist`); reran `bun run test:config:src` (52 pass). diff --git a/package.json b/package.json index 9e283cc..051583d 100644 --- a/package.json +++ b/package.json @@ -20,8 +20,8 @@ "check:file-budgets:strict": "bun run scripts/check-file-budgets.ts --strict", "check:main-fanin": "bun run scripts/check-main-runtime-fanin.ts", "check:main-fanin:strict": "bun run scripts/check-main-runtime-fanin.ts --strict", - "test:config:src": "bun test src/config/config.test.ts src/config/path-resolution.test.ts src/config/resolve/anki-connect.test.ts src/config/resolve/subtitle-style.test.ts src/config/resolve/jellyfin.test.ts", - "test:config:dist": "node --test dist/config/config.test.js dist/config/path-resolution.test.js dist/config/resolve/anki-connect.test.js dist/config/resolve/subtitle-style.test.js dist/config/resolve/jellyfin.test.js", + "test:config:src": "bun test src/config/config.test.ts src/config/path-resolution.test.ts src/config/resolve/anki-connect.test.ts src/config/resolve/subtitle-style.test.ts src/config/resolve/jellyfin.test.ts src/config/definitions/domain-registry.test.ts", + "test:config:dist": "node --test dist/config/config.test.js dist/config/path-resolution.test.js dist/config/resolve/anki-connect.test.js dist/config/resolve/subtitle-style.test.js dist/config/resolve/jellyfin.test.js dist/config/definitions/domain-registry.test.js", "test:config:smoke:dist": "node --test dist/config/path-resolution.test.js", "test:launcher:src": "bun test launcher/config.test.ts launcher/parse-args.test.ts launcher/main.test.ts", "test:core:src": "bun test src/cli/args.test.ts src/cli/help.test.ts src/core/services/cli-command.test.ts src/core/services/field-grouping-overlay.test.ts src/core/services/numeric-shortcut-session.test.ts src/core/services/secondary-subtitle.test.ts src/core/services/mpv-render-metrics.test.ts src/core/services/overlay-content-measurement.test.ts src/core/services/mpv-control.test.ts src/core/services/mpv.test.ts src/core/services/runtime-options-ipc.test.ts src/core/services/runtime-config.test.ts src/core/services/config-hot-reload.test.ts src/core/services/tokenizer.test.ts src/core/services/tokenizer/annotation-stage.test.ts src/core/services/tokenizer/parser-selection-stage.test.ts src/core/services/tokenizer/parser-enrichment-stage.test.ts src/core/services/subsync.test.ts src/core/services/overlay-bridge.test.ts src/core/services/overlay-shortcut-handler.test.ts src/core/services/mining.test.ts src/core/services/anki-jimaku.test.ts src/core/services/jellyfin.test.ts src/core/services/jellyfin-remote.test.ts src/core/services/immersion-tracker-service.test.ts src/core/services/app-ready.test.ts src/core/services/startup-bootstrap.test.ts src/core/services/subtitle-processing-controller.test.ts src/core/services/anilist/anilist-update-queue.test.ts src/renderer/error-recovery.test.ts src/subsync/utils.test.ts src/main/anilist-url-guard.test.ts src/window-trackers/x11-tracker.test.ts launcher/config.test.ts launcher/parse-args.test.ts launcher/main.test.ts", diff --git a/src/config/definitions.ts b/src/config/definitions.ts index c397d20..03ac76c 100644 --- a/src/config/definitions.ts +++ b/src/config/definitions.ts @@ -1,825 +1,73 @@ -import { - AnkiConnectConfig, - Config, - RawConfig, - ResolvedConfig, - RuntimeOptionId, - RuntimeOptionScope, - RuntimeOptionValue, - RuntimeOptionValueType, -} from '../types'; +import { RawConfig, ResolvedConfig } from '../types'; +import { CORE_DEFAULT_CONFIG } from './definitions/defaults-core'; +import { IMMERSION_DEFAULT_CONFIG } from './definitions/defaults-immersion'; +import { INTEGRATIONS_DEFAULT_CONFIG } from './definitions/defaults-integrations'; +import { SUBTITLE_DEFAULT_CONFIG } from './definitions/defaults-subtitle'; +import { buildCoreConfigOptionRegistry } from './definitions/options-core'; +import { buildImmersionConfigOptionRegistry } from './definitions/options-immersion'; +import { buildIntegrationConfigOptionRegistry } from './definitions/options-integrations'; +import { buildSubtitleConfigOptionRegistry } from './definitions/options-subtitle'; +import { buildRuntimeOptionRegistry } from './definitions/runtime-options'; +import { CONFIG_TEMPLATE_SECTIONS } from './definitions/template-sections'; -export type ConfigValueKind = 'boolean' | 'number' | 'string' | 'enum' | 'array' | 'object'; +export { DEFAULT_KEYBINDINGS, SPECIAL_COMMANDS } from './definitions/shared'; +export type { + ConfigOptionRegistryEntry, + ConfigTemplateSection, + ConfigValueKind, + RuntimeOptionRegistryEntry, +} from './definitions/shared'; -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; -} - -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 = [ - { 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'] }, -]; +const { + subtitlePosition, + keybindings, + websocket, + logging, + texthooker, + shortcuts, + secondarySub, + subsync, + auto_start_overlay, + bind_visible_overlay_to_mpv_sub_visibility, + invisibleOverlay, +} = CORE_DEFAULT_CONFIG; +const { ankiConnect, jimaku, anilist, jellyfin, youtubeSubgen } = INTEGRATIONS_DEFAULT_CONFIG; +const { subtitleStyle } = SUBTITLE_DEFAULT_CONFIG; +const { immersionTracking } = IMMERSION_DEFAULT_CONFIG; export const DEFAULT_CONFIG: ResolvedConfig = { - subtitlePosition: { yPercent: 10 }, - keybindings: [], - websocket: { - enabled: 'auto', - port: 6677, - }, - logging: { - level: 'info', - }, - texthooker: { - openBrowser: true, - }, - 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, - }, - }, - 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: '', - }, - subtitleStyle: { - enableJlpt: false, - preserveLineBreaks: false, - 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', - }, - }, - auto_start_overlay: false, - bind_visible_overlay_to_mpv_sub_visibility: 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', - }, - youtubeSubgen: { - mode: 'automatic', - whisperBin: '', - whisperModel: '', - primarySubLanguages: ['ja', 'jpn'], - }, - invisibleOverlay: { - startupVisibility: 'platform-default', - }, - 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, - }, - }, + subtitlePosition, + keybindings, + websocket, + logging, + texthooker, + ankiConnect, + shortcuts, + secondarySub, + subsync, + subtitleStyle, + auto_start_overlay, + bind_visible_overlay_to_mpv_sub_visibility, + jimaku, + anilist, + jellyfin, + youtubeSubgen, + invisibleOverlay, + immersionTracking, }; 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.nPlusOneMatchMode', - path: 'ankiConnect.nPlusOne.matchMode', - label: 'N+1 Match Mode', - scope: 'ankiConnect', - valueType: 'enum', - allowedValues: ['headword', 'surface'], - defaultValue: DEFAULT_CONFIG.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', - }, - }), - }, +export const RUNTIME_OPTION_REGISTRY = buildRuntimeOptionRegistry(DEFAULT_CONFIG); + +export const CONFIG_OPTION_REGISTRY = [ + ...buildCoreConfigOptionRegistry(DEFAULT_CONFIG), + ...buildSubtitleConfigOptionRegistry(DEFAULT_CONFIG), + ...buildIntegrationConfigOptionRegistry(DEFAULT_CONFIG, RUNTIME_OPTION_REGISTRY), + ...buildImmersionConfigOptionRegistry(DEFAULT_CONFIG), ]; -export const CONFIG_OPTION_REGISTRY: ConfigOptionRegistryEntry[] = [ - { - path: 'logging.level', - kind: 'enum', - enumValues: ['debug', 'info', 'warn', 'error'], - defaultValue: DEFAULT_CONFIG.logging.level, - description: 'Minimum log level for runtime logging.', - }, - { - 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: 'subtitleStyle.enableJlpt', - kind: 'boolean', - defaultValue: DEFAULT_CONFIG.subtitleStyle.enableJlpt, - description: - 'Enable JLPT vocabulary level underlines. ' + - 'When disabled, JLPT tagging lookup and underlines are skipped.', - }, - { - path: 'subtitleStyle.preserveLineBreaks', - kind: 'boolean', - defaultValue: DEFAULT_CONFIG.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.frequencyDictionary.enabled', - kind: 'boolean', - defaultValue: DEFAULT_CONFIG.subtitleStyle.frequencyDictionary.enabled, - description: 'Enable frequency-dictionary-based highlighting based on token rank.', - }, - { - path: 'subtitleStyle.frequencyDictionary.sourcePath', - kind: 'string', - defaultValue: DEFAULT_CONFIG.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: DEFAULT_CONFIG.subtitleStyle.frequencyDictionary.topX, - description: 'Only color tokens with frequency rank <= topX (default: 1000).', - }, - { - path: 'subtitleStyle.frequencyDictionary.mode', - kind: 'enum', - enumValues: ['single', 'banded'], - defaultValue: DEFAULT_CONFIG.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: DEFAULT_CONFIG.subtitleStyle.frequencyDictionary.singleColor, - description: 'Color used when frequencyDictionary.mode is `single`.', - }, - { - path: 'subtitleStyle.frequencyDictionary.bandedColors', - kind: 'array', - defaultValue: DEFAULT_CONFIG.subtitleStyle.frequencyDictionary.bandedColors, - description: - 'Five colors used for rank bands when mode is `banded` (from most common to least within topX).', - }, - { - 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.tags', - kind: 'array', - defaultValue: DEFAULT_CONFIG.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: DEFAULT_CONFIG.ankiConnect.behavior.autoUpdateNewCards, - description: 'Automatically update newly added cards.', - runtime: RUNTIME_OPTION_REGISTRY[0], - }, - { - path: 'ankiConnect.nPlusOne.matchMode', - kind: 'enum', - enumValues: ['headword', 'surface'], - defaultValue: DEFAULT_CONFIG.ankiConnect.nPlusOne.matchMode, - description: 'Known-word matching strategy for N+1 highlighting.', - }, - { - path: 'ankiConnect.nPlusOne.highlightEnabled', - kind: 'boolean', - defaultValue: DEFAULT_CONFIG.ankiConnect.nPlusOne.highlightEnabled, - description: 'Enable fast local highlighting for words already known in Anki.', - }, - { - path: 'ankiConnect.nPlusOne.refreshMinutes', - kind: 'number', - defaultValue: DEFAULT_CONFIG.ankiConnect.nPlusOne.refreshMinutes, - description: 'Minutes between known-word cache refreshes.', - }, - { - path: 'ankiConnect.nPlusOne.minSentenceWords', - kind: 'number', - defaultValue: DEFAULT_CONFIG.ankiConnect.nPlusOne.minSentenceWords, - description: 'Minimum sentence word count required for N+1 targeting (default: 3).', - }, - { - path: 'ankiConnect.nPlusOne.decks', - kind: 'array', - defaultValue: DEFAULT_CONFIG.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: DEFAULT_CONFIG.ankiConnect.nPlusOne.nPlusOne, - description: 'Color used for the single N+1 target token highlight.', - }, - { - path: 'ankiConnect.nPlusOne.knownWord', - kind: 'string', - defaultValue: DEFAULT_CONFIG.ankiConnect.nPlusOne.knownWord, - description: 'Color used for legacy known-word highlights.', - }, - { - 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: 'anilist.enabled', - kind: 'boolean', - defaultValue: DEFAULT_CONFIG.anilist.enabled, - description: 'Enable AniList post-watch progress updates.', - }, - { - path: 'anilist.accessToken', - kind: 'string', - defaultValue: DEFAULT_CONFIG.anilist.accessToken, - description: - 'Optional explicit AniList access token override; leave empty to use locally stored token from setup.', - }, - { - path: 'jellyfin.enabled', - kind: 'boolean', - defaultValue: DEFAULT_CONFIG.jellyfin.enabled, - description: 'Enable optional Jellyfin integration and CLI control commands.', - }, - { - path: 'jellyfin.serverUrl', - kind: 'string', - defaultValue: DEFAULT_CONFIG.jellyfin.serverUrl, - description: 'Base Jellyfin server URL (for example: http://localhost:8096).', - }, - { - path: 'jellyfin.username', - kind: 'string', - defaultValue: DEFAULT_CONFIG.jellyfin.username, - description: 'Default Jellyfin username used during CLI login.', - }, - { - path: 'jellyfin.defaultLibraryId', - kind: 'string', - defaultValue: DEFAULT_CONFIG.jellyfin.defaultLibraryId, - description: 'Optional default Jellyfin library ID for item listing.', - }, - { - path: 'jellyfin.remoteControlEnabled', - kind: 'boolean', - defaultValue: DEFAULT_CONFIG.jellyfin.remoteControlEnabled, - description: 'Enable Jellyfin remote cast control mode.', - }, - { - path: 'jellyfin.remoteControlAutoConnect', - kind: 'boolean', - defaultValue: DEFAULT_CONFIG.jellyfin.remoteControlAutoConnect, - description: 'Auto-connect to the configured remote control target.', - }, - { - path: 'jellyfin.autoAnnounce', - kind: 'boolean', - defaultValue: DEFAULT_CONFIG.jellyfin.autoAnnounce, - description: - 'When enabled, automatically trigger remote announce/visibility check on websocket connect.', - }, - { - path: 'jellyfin.remoteControlDeviceName', - kind: 'string', - defaultValue: DEFAULT_CONFIG.jellyfin.remoteControlDeviceName, - description: 'Device name reported for Jellyfin remote control sessions.', - }, - { - path: 'jellyfin.pullPictures', - kind: 'boolean', - defaultValue: DEFAULT_CONFIG.jellyfin.pullPictures, - description: 'Enable Jellyfin poster/icon fetching for launcher menus.', - }, - { - path: 'jellyfin.iconCacheDir', - kind: 'string', - defaultValue: DEFAULT_CONFIG.jellyfin.iconCacheDir, - description: 'Directory used by launcher for cached Jellyfin poster icons.', - }, - { - path: 'jellyfin.directPlayPreferred', - kind: 'boolean', - defaultValue: DEFAULT_CONFIG.jellyfin.directPlayPreferred, - description: 'Try direct play before server-managed transcoding when possible.', - }, - { - path: 'jellyfin.directPlayContainers', - kind: 'array', - defaultValue: DEFAULT_CONFIG.jellyfin.directPlayContainers, - description: 'Container allowlist for direct play decisions.', - }, - { - path: 'jellyfin.transcodeVideoCodec', - kind: 'string', - defaultValue: DEFAULT_CONFIG.jellyfin.transcodeVideoCodec, - description: 'Preferred transcode video codec when direct play is unavailable.', - }, - { - 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.', - }, - { - path: 'immersionTracking.enabled', - kind: 'boolean', - defaultValue: DEFAULT_CONFIG.immersionTracking.enabled, - description: 'Enable immersion tracking for mined subtitle metadata.', - }, - { - path: 'immersionTracking.dbPath', - kind: 'string', - defaultValue: DEFAULT_CONFIG.immersionTracking.dbPath, - description: - 'Optional SQLite database path for immersion tracking. Empty value uses the default app data path.', - }, - { - path: 'immersionTracking.batchSize', - kind: 'number', - defaultValue: DEFAULT_CONFIG.immersionTracking.batchSize, - description: 'Buffered telemetry/event writes per SQLite transaction.', - }, - { - path: 'immersionTracking.flushIntervalMs', - kind: 'number', - defaultValue: DEFAULT_CONFIG.immersionTracking.flushIntervalMs, - description: 'Max delay before queue flush in milliseconds.', - }, - { - path: 'immersionTracking.queueCap', - kind: 'number', - defaultValue: DEFAULT_CONFIG.immersionTracking.queueCap, - description: 'In-memory write queue cap before overflow policy applies.', - }, - { - path: 'immersionTracking.payloadCapBytes', - kind: 'number', - defaultValue: DEFAULT_CONFIG.immersionTracking.payloadCapBytes, - description: 'Max JSON payload size per event before truncation.', - }, - { - path: 'immersionTracking.maintenanceIntervalMs', - kind: 'number', - defaultValue: DEFAULT_CONFIG.immersionTracking.maintenanceIntervalMs, - description: 'Maintenance cadence (prune + rollup + vacuum checks).', - }, - { - path: 'immersionTracking.retention.eventsDays', - kind: 'number', - defaultValue: DEFAULT_CONFIG.immersionTracking.retention.eventsDays, - description: 'Raw event retention window in days.', - }, - { - path: 'immersionTracking.retention.telemetryDays', - kind: 'number', - defaultValue: DEFAULT_CONFIG.immersionTracking.retention.telemetryDays, - description: 'Telemetry retention window in days.', - }, - { - path: 'immersionTracking.retention.dailyRollupsDays', - kind: 'number', - defaultValue: DEFAULT_CONFIG.immersionTracking.retention.dailyRollupsDays, - description: 'Daily rollup retention window in days.', - }, - { - path: 'immersionTracking.retention.monthlyRollupsDays', - kind: 'number', - defaultValue: DEFAULT_CONFIG.immersionTracking.retention.monthlyRollupsDays, - description: 'Monthly rollup retention window in days.', - }, - { - path: 'immersionTracking.retention.vacuumIntervalDays', - kind: 'number', - defaultValue: DEFAULT_CONFIG.immersionTracking.retention.vacuumIntervalDays, - description: 'Minimum days between VACUUM runs.', - }, -]; - -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: 'Logging', - description: ['Controls logging verbosity.', 'Set to debug for full runtime diagnostics.'], - key: 'logging', - }, - { - 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: '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: 'Subtitle Appearance', - description: ['Primary and secondary subtitle styling.'], - notes: ['Hot-reload: subtitle style changes apply live without restarting SubMiner.'], - key: 'subtitleStyle', - }, - { - 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', - }, - { - 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.', - 'Auth session (access token + user id) is stored in local encrypted storage after login/setup.', - 'Optional env overrides: SUBMINER_JELLYFIN_ACCESS_TOKEN and SUBMINER_JELLYFIN_USER_ID.', - ], - key: 'jellyfin', - }, - { - 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 { CONFIG_TEMPLATE_SECTIONS }; export function deepCloneConfig(config: ResolvedConfig): ResolvedConfig { return JSON.parse(JSON.stringify(config)) as ResolvedConfig; diff --git a/src/config/definitions/defaults-core.ts b/src/config/definitions/defaults-core.ts new file mode 100644 index 0000000..9097066 --- /dev/null +++ b/src/config/definitions/defaults-core.ts @@ -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', + }, +}; diff --git a/src/config/definitions/defaults-immersion.ts b/src/config/definitions/defaults-immersion.ts new file mode 100644 index 0000000..f648739 --- /dev/null +++ b/src/config/definitions/defaults-immersion.ts @@ -0,0 +1,20 @@ +import { ResolvedConfig } from '../../types'; + +export const IMMERSION_DEFAULT_CONFIG: Pick = { + 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, + }, + }, +}; diff --git a/src/config/definitions/defaults-integrations.ts b/src/config/definitions/defaults-integrations.ts new file mode 100644 index 0000000..abc6a20 --- /dev/null +++ b/src/config/definitions/defaults-integrations.ts @@ -0,0 +1,110 @@ +import { ResolvedConfig } from '../../types'; + +export const INTEGRATIONS_DEFAULT_CONFIG: Pick< + ResolvedConfig, + 'ankiConnect' | 'jimaku' | 'anilist' | 'jellyfin' | '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: '', + accessToken: '', + userId: '', + 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', + }, + youtubeSubgen: { + mode: 'automatic', + whisperBin: '', + whisperModel: '', + primarySubLanguages: ['ja', 'jpn'], + }, +}; diff --git a/src/config/definitions/defaults-subtitle.ts b/src/config/definitions/defaults-subtitle.ts new file mode 100644 index 0000000..ca0b464 --- /dev/null +++ b/src/config/definitions/defaults-subtitle.ts @@ -0,0 +1,41 @@ +import { ResolvedConfig } from '../../types'; + +export const SUBTITLE_DEFAULT_CONFIG: Pick = { + subtitleStyle: { + enableJlpt: false, + preserveLineBreaks: false, + 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', + }, + }, +}; diff --git a/src/config/definitions/domain-registry.test.ts b/src/config/definitions/domain-registry.test.ts new file mode 100644 index 0000000..40f32ac --- /dev/null +++ b/src/config/definitions/domain-registry.test.ts @@ -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))); + } +}); diff --git a/src/config/definitions/options-core.ts b/src/config/definitions/options-core.ts new file mode 100644 index 0000000..a2e44a1 --- /dev/null +++ b/src/config/definitions/options-core.ts @@ -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).', + }, + ]; +} diff --git a/src/config/definitions/options-immersion.ts b/src/config/definitions/options-immersion.ts new file mode 100644 index 0000000..ccd6a99 --- /dev/null +++ b/src/config/definitions/options-immersion.ts @@ -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.', + }, + ]; +} diff --git a/src/config/definitions/options-integrations.ts b/src/config/definitions/options-integrations.ts new file mode 100644 index 0000000..252cf3f --- /dev/null +++ b/src/config/definitions/options-integrations.ts @@ -0,0 +1,217 @@ +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: '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.', + }, + ]; +} diff --git a/src/config/definitions/options-subtitle.ts b/src/config/definitions/options-subtitle.ts new file mode 100644 index 0000000..5083322 --- /dev/null +++ b/src/config/definitions/options-subtitle.ts @@ -0,0 +1,66 @@ +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.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).', + }, + ]; +} diff --git a/src/config/definitions/runtime-options.ts b/src/config/definitions/runtime-options.ts new file mode 100644 index 0000000..e35dade --- /dev/null +++ b/src/config/definitions/runtime-options.ts @@ -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', + }, + }), + }, + ]; +} diff --git a/src/config/definitions/shared.ts b/src/config/definitions/shared.ts new file mode 100644 index 0000000..ad648a5 --- /dev/null +++ b/src/config/definitions/shared.ts @@ -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; +} + +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 = [ + { 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'] }, +]; diff --git a/src/config/definitions/template-sections.ts b/src/config/definitions/template-sections.ts new file mode 100644 index 0000000..10f71e6 --- /dev/null +++ b/src/config/definitions/template-sections.ts @@ -0,0 +1,146 @@ +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', + }, +]; + +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, +];