From 420b985c7a1c896fd2fb8a10ae123c0ef2311bd5 Mon Sep 17 00:00:00 2001 From: sudacode Date: Sun, 22 Feb 2026 12:01:04 -0800 Subject: [PATCH] refactor(launcher): split config parser and CLI builder Decompose launcher/config.ts into focused domain parser and CLI normalization modules to reduce refactor risk while preserving command behavior. Align Jellyfin launcher config with session-based auth by dropping config token/userId dependency. --- ....ts-into-domain-parsers-and-CLI-builder.md | 88 +++ ....ts-into-domain-parsers-and-cli-builder.md | 55 -- launcher/config-domain-parsers.test.ts | 60 ++ launcher/config.ts | 693 +----------------- launcher/config/args-normalizer.ts | 257 +++++++ launcher/config/cli-parser-builder.ts | 294 ++++++++ launcher/config/jellyfin-config.ts | 16 + launcher/config/plugin-runtime-config.ts | 57 ++ launcher/config/shared-config-reader.ts | 25 + launcher/config/youtube-subgen-config.ts | 54 ++ launcher/jellyfin.ts | 9 +- launcher/parse-args.test.ts | 21 + launcher/types.ts | 2 - package.json | 4 +- 14 files changed, 907 insertions(+), 728 deletions(-) create mode 100644 backlog/tasks/task-104 - Split-launcher-config.ts-into-domain-parsers-and-CLI-builder.md delete mode 100644 backlog/tasks/task-104 - Split-launcher-config.ts-into-domain-parsers-and-cli-builder.md create mode 100644 launcher/config-domain-parsers.test.ts create mode 100644 launcher/config/args-normalizer.ts create mode 100644 launcher/config/cli-parser-builder.ts create mode 100644 launcher/config/jellyfin-config.ts create mode 100644 launcher/config/plugin-runtime-config.ts create mode 100644 launcher/config/shared-config-reader.ts create mode 100644 launcher/config/youtube-subgen-config.ts diff --git a/backlog/tasks/task-104 - Split-launcher-config.ts-into-domain-parsers-and-CLI-builder.md b/backlog/tasks/task-104 - Split-launcher-config.ts-into-domain-parsers-and-CLI-builder.md new file mode 100644 index 0000000..5dea9f0 --- /dev/null +++ b/backlog/tasks/task-104 - Split-launcher-config.ts-into-domain-parsers-and-CLI-builder.md @@ -0,0 +1,88 @@ +--- +id: TASK-104 +title: Split launcher config.ts into domain parsers and CLI builder +status: Done +assignee: + - codex +created_date: '2026-02-22 07:13' +updated_date: '2026-02-22 19:56' +labels: + - refactor + - launcher + - maintainability +dependencies: + - TASK-81 + - TASK-102 +priority: medium +--- + +## Description + + +`launcher/config.ts` is still a large multi-responsibility file (~700 LOC) combining: +- config file reading/parsing for multiple domains, +- plugin runtime config parsing, +- CLI command tree construction, +- root/subcommand arg normalization. + +This file remains a cleanup hotspot and makes contract changes (like Jellyfin session migration) expensive to land safely. + + +## Action Steps + + +1. Extract launcher config-file readers into domain loaders (YouTube/Jimaku, Jellyfin, plugin runtime). +2. Extract Commander command-tree setup into a dedicated CLI builder module. +3. Extract post-parse normalization into focused argument-normalization helpers. +4. Remove stale Jellyfin config auth field assumptions from launcher config readers. +5. Add focused tests per extracted module while preserving existing `launcher/config.test.ts` behavior expectations. +6. Keep `parseArgs` API stable for launcher call sites. + + +## Acceptance Criteria + +- [x] #1 `launcher/config.ts` is reduced to thin orchestration over extracted modules. +- [x] #2 Each extracted module has focused tests that assert current behavior. +- [x] #3 Launcher still passes `bun run test:launcher` without CLI behavior regressions. +- [x] #4 Launcher config readers align with current Jellyfin session contract (no config token/userId dependency). + + +## Implementation Plan + + +Plan recorded at `docs/plans/2026-02-22-task-104-launcher-config-domain-parsers-cli-builder.md`. + +Execution phases: +1. Extract shared config readers and domain parser modules (`launcher/config/shared-config-reader.ts`, `launcher/config/youtube-subgen-config.ts`, `launcher/config/jellyfin-config.ts`) and reduce `launcher/config.ts` to orchestration. +2. Extract plugin runtime parser into `launcher/config/plugin-runtime-config.ts` with focused behavior tests. +3. Extract CLI parser builder/normalization into `launcher/config/cli-parser-builder.ts`, `launcher/config/parse-helpers.ts`, and `launcher/config/args-normalizer.ts`; keep `parseArgs` API unchanged. +4. Align Jellyfin config contract by removing launcher config token/userId dependence and update caller/type wiring. +5. Verify with `bun run test:launcher` and `bun run test:fast`, then finalize TASK-104 evidence (AC/DoD checks + summary). + +Validation-first loop per phase: add/expand focused tests, run targeted launcher tests, implement minimal refactor to pass, then run full required suites. + + +## Implementation Notes + + +Implemented launcher config decomposition by extracting domain-focused modules under `launcher/config/` (`shared-config-reader`, `youtube-subgen-config`, `jellyfin-config`, `plugin-runtime-config`, `cli-parser-builder`, `args-normalizer`) and reducing `launcher/config.ts` to an orchestration facade with unchanged exported API. + +Aligned launcher Jellyfin config contract by removing `accessToken`/`userId` from `LauncherJellyfinConfig` and parser output; launcher Jellyfin play now requires `SUBMINER_JELLYFIN_ACCESS_TOKEN` + `SUBMINER_JELLYFIN_USER_ID` env session values instead of config fields. + +Added focused parser regression tests in `launcher/config-domain-parsers.test.ts` (youtube domain normalization, jellyfin legacy token/userId omission, plugin socket parsing) and expanded `launcher/parse-args.test.ts` branch coverage for jellyfin/config/mpv command mappings. + +Verification: `bun test launcher/config-domain-parsers.test.ts launcher/parse-args.test.ts`, `bun run test:launcher`, and `bun run test:fast` all pass. + + +## Final Summary + + +Split `launcher/config.ts` into focused domain parser and CLI parsing modules while preserving the public launcher parsing API (`parseArgs`, config readers, plugin runtime reader). Added focused launcher parser tests, expanded parse-args coverage, and removed launcher config dependency on Jellyfin token/userId fields to match the current session contract. Verified behavior with `bun run test:launcher` and `bun run test:fast` passing. + + +## Definition of Done + +- [x] #1 Public launcher parsing API unchanged for downstream callers. +- [x] #2 Help text and subcommand option behavior remains unchanged. +- [x] #3 `bun run test:launcher` and `bun run test:fast` pass. + diff --git a/backlog/tasks/task-104 - Split-launcher-config.ts-into-domain-parsers-and-cli-builder.md b/backlog/tasks/task-104 - Split-launcher-config.ts-into-domain-parsers-and-cli-builder.md deleted file mode 100644 index 899c1d2..0000000 --- a/backlog/tasks/task-104 - Split-launcher-config.ts-into-domain-parsers-and-cli-builder.md +++ /dev/null @@ -1,55 +0,0 @@ ---- -id: TASK-104 -title: Split launcher config.ts into domain parsers and CLI builder -status: To Do -assignee: [] -created_date: '2026-02-22 07:13' -updated_date: '2026-02-22 07:13' -labels: - - refactor - - launcher - - maintainability -dependencies: - - TASK-81 - - TASK-102 -priority: medium ---- - -## Description - - -`launcher/config.ts` is still a large multi-responsibility file (~700 LOC) combining: -- config file reading/parsing for multiple domains, -- plugin runtime config parsing, -- CLI command tree construction, -- root/subcommand arg normalization. - -This file remains a cleanup hotspot and makes contract changes (like Jellyfin session migration) expensive to land safely. - - -## Action Steps - - -1. Extract launcher config-file readers into domain loaders (YouTube/Jimaku, Jellyfin, plugin runtime). -2. Extract Commander command-tree setup into a dedicated CLI builder module. -3. Extract post-parse normalization into focused argument-normalization helpers. -4. Remove stale Jellyfin config auth field assumptions from launcher config readers. -5. Add focused tests per extracted module while preserving existing `launcher/config.test.ts` behavior expectations. -6. Keep `parseArgs` API stable for launcher call sites. - - -## Acceptance Criteria - -- [ ] #1 `launcher/config.ts` is reduced to thin orchestration over extracted modules. -- [ ] #2 Each extracted module has focused tests that assert current behavior. -- [ ] #3 Launcher still passes `bun run test:launcher` without CLI behavior regressions. -- [ ] #4 Launcher config readers align with current Jellyfin session contract (no config token/userId dependency). - - -## Definition of Done - -- [ ] #1 Public launcher parsing API unchanged for downstream callers. -- [ ] #2 Help text and subcommand option behavior remains unchanged. -- [ ] #3 `bun run test:launcher` and `bun run test:fast` pass. - - diff --git a/launcher/config-domain-parsers.test.ts b/launcher/config-domain-parsers.test.ts new file mode 100644 index 0000000..fecb9f0 --- /dev/null +++ b/launcher/config-domain-parsers.test.ts @@ -0,0 +1,60 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { parseLauncherYoutubeSubgenConfig } from './config/youtube-subgen-config.js'; +import { parseLauncherJellyfinConfig } from './config/jellyfin-config.js'; +import { parsePluginRuntimeConfigContent } from './config/plugin-runtime-config.js'; + +test('parseLauncherYoutubeSubgenConfig keeps only valid typed values', () => { + const parsed = parseLauncherYoutubeSubgenConfig({ + youtubeSubgen: { + mode: 'preprocess', + whisperBin: '/usr/bin/whisper', + whisperModel: '/models/base.bin', + primarySubLanguages: ['ja', 42, 'en'], + }, + secondarySub: { + secondarySubLanguages: ['eng', true, 'deu'], + }, + jimaku: { + apiKey: 'abc', + apiKeyCommand: 'pass show key', + apiBaseUrl: 'https://jimaku.cc', + languagePreference: 'ja', + maxEntryResults: 8.7, + }, + }); + + assert.equal(parsed.mode, 'preprocess'); + assert.deepEqual(parsed.primarySubLanguages, ['ja', 'en']); + assert.deepEqual(parsed.secondarySubLanguages, ['eng', 'deu']); + assert.equal(parsed.jimakuLanguagePreference, 'ja'); + assert.equal(parsed.jimakuMaxEntryResults, 8); +}); + +test('parseLauncherJellyfinConfig omits legacy token and user id fields', () => { + const parsed = parseLauncherJellyfinConfig({ + jellyfin: { + enabled: true, + serverUrl: 'https://jf.example', + username: 'alice', + accessToken: 'legacy-token', + userId: 'legacy-user', + pullPictures: true, + }, + }); + + assert.equal(parsed.enabled, true); + assert.equal(parsed.serverUrl, 'https://jf.example'); + assert.equal(parsed.username, 'alice'); + assert.equal(parsed.pullPictures, true); + assert.equal('accessToken' in parsed, false); + assert.equal('userId' in parsed, false); +}); + +test('parsePluginRuntimeConfigContent reads socket_path and ignores inline comments', () => { + const parsed = parsePluginRuntimeConfigContent(` +# comment +socket_path = /tmp/custom.sock # trailing comment +`); + assert.equal(parsed.socketPath, '/tmp/custom.sock'); +}); diff --git a/launcher/config.ts b/launcher/config.ts index b1c5307..5cbc196 100644 --- a/launcher/config.ts +++ b/launcher/config.ts @@ -1,321 +1,36 @@ -import fs from 'node:fs'; -import path from 'node:path'; -import os from 'node:os'; -import { Command } from 'commander'; -import { parse as parseJsonc } from 'jsonc-parser'; -import { resolveConfigFilePath } from '../src/config/path-resolution.js'; +import { fail } from './log.js'; import type { - LogLevel, - YoutubeSubgenMode, - Backend, Args, - LauncherYoutubeSubgenConfig, LauncherJellyfinConfig, + LauncherYoutubeSubgenConfig, + LogLevel, PluginRuntimeConfig, } from './types.js'; import { - DEFAULT_SOCKET_PATH, - DEFAULT_YOUTUBE_PRIMARY_SUB_LANGS, - DEFAULT_YOUTUBE_SECONDARY_SUB_LANGS, - DEFAULT_YOUTUBE_SUBGEN_OUT_DIR, - DEFAULT_JIMAKU_API_BASE_URL, -} from './types.js'; -import { log, fail } from './log.js'; -import { - resolvePathMaybe, - isUrlTarget, - uniqueNormalizedLangCodes, - inferWhisperLanguage, -} from './util.js'; - -function resolveLauncherMainConfigPath(): string { - return resolveConfigFilePath({ - xdgConfigHome: process.env.XDG_CONFIG_HOME, - homeDir: os.homedir(), - existsSync: fs.existsSync, - }); -} + applyInvocationsToArgs, + applyRootOptionsToArgs, + createDefaultArgs, +} from './config/args-normalizer.js'; +import { parseCliPrograms, resolveTopLevelCommand } from './config/cli-parser-builder.js'; +import { parseLauncherJellyfinConfig } from './config/jellyfin-config.js'; +import { readPluginRuntimeConfig as readPluginRuntimeConfigValue } from './config/plugin-runtime-config.js'; +import { readLauncherMainConfigObject } from './config/shared-config-reader.js'; +import { parseLauncherYoutubeSubgenConfig } from './config/youtube-subgen-config.js'; export function loadLauncherYoutubeSubgenConfig(): LauncherYoutubeSubgenConfig { - const configPath = resolveLauncherMainConfigPath(); - if (!fs.existsSync(configPath)) return {}; - - try { - const data = fs.readFileSync(configPath, 'utf8'); - const parsed = configPath.endsWith('.jsonc') ? parseJsonc(data) : JSON.parse(data); - if (!parsed || typeof parsed !== 'object') return {}; - const root = parsed as { - youtubeSubgen?: unknown; - secondarySub?: { secondarySubLanguages?: unknown }; - jimaku?: unknown; - }; - const youtubeSubgen = root.youtubeSubgen; - const mode = - youtubeSubgen && typeof youtubeSubgen === 'object' - ? (youtubeSubgen as { mode?: unknown }).mode - : undefined; - const whisperBin = - youtubeSubgen && typeof youtubeSubgen === 'object' - ? (youtubeSubgen as { whisperBin?: unknown }).whisperBin - : undefined; - const whisperModel = - youtubeSubgen && typeof youtubeSubgen === 'object' - ? (youtubeSubgen as { whisperModel?: unknown }).whisperModel - : undefined; - const primarySubLanguagesRaw = - youtubeSubgen && typeof youtubeSubgen === 'object' - ? (youtubeSubgen as { primarySubLanguages?: unknown }).primarySubLanguages - : undefined; - const secondarySubLanguagesRaw = root.secondarySub?.secondarySubLanguages; - const primarySubLanguages = Array.isArray(primarySubLanguagesRaw) - ? primarySubLanguagesRaw.filter((value): value is string => typeof value === 'string') - : undefined; - const secondarySubLanguages = Array.isArray(secondarySubLanguagesRaw) - ? secondarySubLanguagesRaw.filter((value): value is string => typeof value === 'string') - : undefined; - const jimaku = root.jimaku; - const jimakuApiKey = - jimaku && typeof jimaku === 'object' ? (jimaku as { apiKey?: unknown }).apiKey : undefined; - const jimakuApiKeyCommand = - jimaku && typeof jimaku === 'object' - ? (jimaku as { apiKeyCommand?: unknown }).apiKeyCommand - : undefined; - const jimakuApiBaseUrl = - jimaku && typeof jimaku === 'object' - ? (jimaku as { apiBaseUrl?: unknown }).apiBaseUrl - : undefined; - const jimakuLanguagePreference = - jimaku && typeof jimaku === 'object' - ? (jimaku as { languagePreference?: unknown }).languagePreference - : undefined; - const jimakuMaxEntryResults = - jimaku && typeof jimaku === 'object' - ? (jimaku as { maxEntryResults?: unknown }).maxEntryResults - : undefined; - const resolvedJimakuLanguagePreference = - jimakuLanguagePreference === 'ja' || - jimakuLanguagePreference === 'en' || - jimakuLanguagePreference === 'none' - ? jimakuLanguagePreference - : undefined; - const resolvedJimakuMaxEntryResults = - typeof jimakuMaxEntryResults === 'number' && - Number.isFinite(jimakuMaxEntryResults) && - jimakuMaxEntryResults > 0 - ? Math.floor(jimakuMaxEntryResults) - : undefined; - - return { - mode: mode === 'automatic' || mode === 'preprocess' || mode === 'off' ? mode : undefined, - whisperBin: typeof whisperBin === 'string' ? whisperBin : undefined, - whisperModel: typeof whisperModel === 'string' ? whisperModel : undefined, - primarySubLanguages, - secondarySubLanguages, - jimakuApiKey: typeof jimakuApiKey === 'string' ? jimakuApiKey : undefined, - jimakuApiKeyCommand: - typeof jimakuApiKeyCommand === 'string' ? jimakuApiKeyCommand : undefined, - jimakuApiBaseUrl: typeof jimakuApiBaseUrl === 'string' ? jimakuApiBaseUrl : undefined, - jimakuLanguagePreference: resolvedJimakuLanguagePreference, - jimakuMaxEntryResults: resolvedJimakuMaxEntryResults, - }; - } catch { - return {}; - } + const root = readLauncherMainConfigObject(); + if (!root) return {}; + return parseLauncherYoutubeSubgenConfig(root); } export function loadLauncherJellyfinConfig(): LauncherJellyfinConfig { - const configPath = resolveLauncherMainConfigPath(); - if (!fs.existsSync(configPath)) return {}; - - try { - const data = fs.readFileSync(configPath, 'utf8'); - const parsed = configPath.endsWith('.jsonc') ? parseJsonc(data) : JSON.parse(data); - if (!parsed || typeof parsed !== 'object') return {}; - const jellyfin = (parsed as { jellyfin?: unknown }).jellyfin; - if (!jellyfin || typeof jellyfin !== 'object') return {}; - const typed = jellyfin as Record; - return { - enabled: typeof typed.enabled === 'boolean' ? typed.enabled : undefined, - serverUrl: typeof typed.serverUrl === 'string' ? typed.serverUrl : undefined, - username: typeof typed.username === 'string' ? typed.username : undefined, - accessToken: typeof typed.accessToken === 'string' ? typed.accessToken : undefined, - userId: typeof typed.userId === 'string' ? typed.userId : undefined, - defaultLibraryId: - typeof typed.defaultLibraryId === 'string' ? typed.defaultLibraryId : undefined, - pullPictures: typeof typed.pullPictures === 'boolean' ? typed.pullPictures : undefined, - iconCacheDir: typeof typed.iconCacheDir === 'string' ? typed.iconCacheDir : undefined, - }; - } catch { - return {}; - } -} - -function getPluginConfigCandidates(): string[] { - const xdgConfigHome = process.env.XDG_CONFIG_HOME || path.join(os.homedir(), '.config'); - return Array.from( - new Set([ - path.join(xdgConfigHome, 'mpv', 'script-opts', 'subminer.conf'), - path.join(os.homedir(), '.config', 'mpv', 'script-opts', 'subminer.conf'), - ]), - ); + const root = readLauncherMainConfigObject(); + if (!root) return {}; + return parseLauncherJellyfinConfig(root); } export function readPluginRuntimeConfig(logLevel: LogLevel): PluginRuntimeConfig { - const runtimeConfig: PluginRuntimeConfig = { - socketPath: DEFAULT_SOCKET_PATH, - }; - const candidates = getPluginConfigCandidates(); - - for (const configPath of candidates) { - if (!fs.existsSync(configPath)) continue; - try { - const content = fs.readFileSync(configPath, 'utf8'); - const lines = content.split(/\r?\n/); - for (const line of lines) { - const trimmed = line.trim(); - if (trimmed.length === 0 || trimmed.startsWith('#')) continue; - const socketMatch = trimmed.match(/^socket_path\s*=\s*(.+)$/i); - if (socketMatch) { - const value = (socketMatch[1] || '').split('#', 1)[0]?.trim() || ''; - if (value) runtimeConfig.socketPath = value; - } - } - log( - 'debug', - logLevel, - `Using mpv plugin settings from ${configPath}: socket_path=${runtimeConfig.socketPath}`, - ); - return runtimeConfig; - } catch { - log('warn', logLevel, `Failed to read ${configPath}; using launcher defaults`); - return runtimeConfig; - } - } - - log( - 'debug', - logLevel, - `No mpv subminer.conf found; using launcher defaults (socket_path=${runtimeConfig.socketPath})`, - ); - return runtimeConfig; -} - -function ensureTarget(target: string, parsed: Args): void { - if (isUrlTarget(target)) { - parsed.target = target; - parsed.targetKind = 'url'; - return; - } - const resolved = resolvePathMaybe(target); - let stat: fs.Stats | null = null; - try { - stat = fs.statSync(resolved); - } catch { - stat = null; - } - if (stat?.isFile()) { - parsed.target = resolved; - parsed.targetKind = 'file'; - return; - } - if (stat?.isDirectory()) { - parsed.directory = resolved; - return; - } - fail(`Not a file, directory, or supported URL: ${target}`); -} - -function parseLogLevel(value: string): LogLevel { - if (value === 'debug' || value === 'info' || value === 'warn' || value === 'error') { - return value; - } - fail(`Invalid log level: ${value} (must be debug, info, warn, or error)`); -} - -function parseYoutubeMode(value: string): YoutubeSubgenMode { - const normalized = value.toLowerCase(); - if (normalized === 'automatic' || normalized === 'preprocess' || normalized === 'off') { - return normalized as YoutubeSubgenMode; - } - fail(`Invalid yt-subgen mode: ${value} (must be automatic, preprocess, or off)`); -} - -function parseBackend(value: string): Backend { - if (value === 'auto' || value === 'hyprland' || value === 'x11' || value === 'macos') { - return value as Backend; - } - fail(`Invalid backend: ${value} (must be auto, hyprland, x11, or macos)`); -} - -function applyRootOptions(program: Command): void { - program - .option('-b, --backend ', 'Display backend') - .option('-d, --directory ', 'Directory to browse') - .option('-r, --recursive', 'Search directories recursively') - .option('-p, --profile ', 'MPV profile') - .option('--start', 'Explicitly start overlay') - .option('--log-level ', 'Log level') - .option('-R, --rofi', 'Use rofi picker') - .option('-S, --start-overlay', 'Auto-start overlay') - .option('-T, --no-texthooker', 'Disable texthooker-ui server'); -} - -function buildSubcommandHelpText(program: Command): string { - const subcommands = program.commands - .filter((command) => command.name() !== 'help') - .map((command) => { - const aliases = command.aliases(); - const term = aliases.length > 0 ? `${command.name()}|${aliases[0]}` : command.name(); - return { term, description: command.description() }; - }); - - if (subcommands.length === 0) return ''; - const longestTerm = Math.max(...subcommands.map((entry) => entry.term.length)); - const lines = subcommands.map( - (entry) => ` ${entry.term.padEnd(longestTerm)} ${entry.description || ''}`.trimEnd(), - ); - return `\nCommands:\n${lines.join('\n')}\n`; -} - -function hasTopLevelCommand(argv: string[]): boolean { - return getTopLevelCommand(argv) !== null; -} - -function getTopLevelCommand(argv: string[]): { name: string; index: number } | null { - const commandNames = new Set([ - 'jellyfin', - 'jf', - 'yt', - 'youtube', - 'doctor', - 'config', - 'mpv', - 'texthooker', - 'app', - 'bin', - 'help', - ]); - const optionsWithValue = new Set([ - '-b', - '--backend', - '-d', - '--directory', - '-p', - '--profile', - '--log-level', - ]); - for (let i = 0; i < argv.length; i += 1) { - const token = argv[i] || ''; - if (token === '--') return null; - if (token.startsWith('-')) { - if (optionsWithValue.has(token)) { - i += 1; - } - continue; - } - return commandNames.has(token) ? { name: token, index: i } : null; - } - return null; + return readPluginRuntimeConfigValue(logLevel); } export function parseArgs( @@ -323,378 +38,24 @@ export function parseArgs( scriptName: string, launcherConfig: LauncherYoutubeSubgenConfig, ): Args { - const topLevelCommand = getTopLevelCommand(argv); - const envMode = (process.env.SUBMINER_YT_SUBGEN_MODE || '').toLowerCase(); - const defaultMode: YoutubeSubgenMode = - envMode === 'preprocess' || envMode === 'off' || envMode === 'automatic' - ? (envMode as YoutubeSubgenMode) - : launcherConfig.mode - ? launcherConfig.mode - : 'automatic'; - const configuredSecondaryLangs = uniqueNormalizedLangCodes( - launcherConfig.secondarySubLanguages ?? [], - ); - const configuredPrimaryLangs = uniqueNormalizedLangCodes( - launcherConfig.primarySubLanguages ?? [], - ); - const primarySubLangs = - configuredPrimaryLangs.length > 0 - ? configuredPrimaryLangs - : [...DEFAULT_YOUTUBE_PRIMARY_SUB_LANGS]; - const secondarySubLangs = - configuredSecondaryLangs.length > 0 - ? configuredSecondaryLangs - : [...DEFAULT_YOUTUBE_SECONDARY_SUB_LANGS]; - const youtubeAudioLangs = uniqueNormalizedLangCodes([...primarySubLangs, ...secondarySubLangs]); - const parsed: Args = { - backend: 'auto', - directory: '.', - recursive: false, - profile: 'subminer', - startOverlay: false, - youtubeSubgenMode: defaultMode, - whisperBin: process.env.SUBMINER_WHISPER_BIN || launcherConfig.whisperBin || '', - whisperModel: process.env.SUBMINER_WHISPER_MODEL || launcherConfig.whisperModel || '', - youtubeSubgenOutDir: process.env.SUBMINER_YT_SUBGEN_OUT_DIR || DEFAULT_YOUTUBE_SUBGEN_OUT_DIR, - youtubeSubgenAudioFormat: process.env.SUBMINER_YT_SUBGEN_AUDIO_FORMAT || 'm4a', - youtubeSubgenKeepTemp: process.env.SUBMINER_YT_SUBGEN_KEEP_TEMP === '1', - jimakuApiKey: process.env.SUBMINER_JIMAKU_API_KEY || '', - jimakuApiKeyCommand: process.env.SUBMINER_JIMAKU_API_KEY_COMMAND || '', - jimakuApiBaseUrl: process.env.SUBMINER_JIMAKU_API_BASE_URL || DEFAULT_JIMAKU_API_BASE_URL, - jimakuLanguagePreference: launcherConfig.jimakuLanguagePreference || 'ja', - jimakuMaxEntryResults: launcherConfig.jimakuMaxEntryResults || 10, - jellyfin: false, - jellyfinLogin: false, - jellyfinLogout: false, - jellyfinPlay: false, - jellyfinDiscovery: false, - doctor: false, - configPath: false, - configShow: false, - mpvIdle: false, - mpvSocket: false, - mpvStatus: false, - appPassthrough: false, - appArgs: [], - jellyfinServer: '', - jellyfinUsername: '', - jellyfinPassword: '', - youtubePrimarySubLangs: primarySubLangs, - youtubeSecondarySubLangs: secondarySubLangs, - youtubeAudioLangs, - youtubeWhisperSourceLanguage: inferWhisperLanguage(primarySubLangs, 'ja'), - useTexthooker: true, - autoStartOverlay: false, - texthookerOnly: false, - useRofi: false, - logLevel: 'info', - target: '', - targetKind: '', - }; + const topLevelCommand = resolveTopLevelCommand(argv); + const parsed = createDefaultArgs(launcherConfig); - if (launcherConfig.jimakuApiKey) parsed.jimakuApiKey = launcherConfig.jimakuApiKey; - if (launcherConfig.jimakuApiKeyCommand) - parsed.jimakuApiKeyCommand = launcherConfig.jimakuApiKeyCommand; - if (launcherConfig.jimakuApiBaseUrl) parsed.jimakuApiBaseUrl = launcherConfig.jimakuApiBaseUrl; - if (launcherConfig.jimakuLanguagePreference) - parsed.jimakuLanguagePreference = launcherConfig.jimakuLanguagePreference; - if (launcherConfig.jimakuMaxEntryResults !== undefined) - parsed.jimakuMaxEntryResults = launcherConfig.jimakuMaxEntryResults; if (topLevelCommand && (topLevelCommand.name === 'app' || topLevelCommand.name === 'bin')) { parsed.appPassthrough = true; parsed.appArgs = argv.slice(topLevelCommand.index + 1); return parsed; } - let jellyfinInvocation: { - action?: string; - discovery?: boolean; - play?: boolean; - login?: boolean; - logout?: boolean; - setup?: boolean; - server?: string; - username?: string; - password?: string; - logLevel?: string; - } | null = null; - let ytInvocation: { - target?: string; - mode?: string; - outDir?: string; - keepTemp?: boolean; - whisperBin?: string; - whisperModel?: string; - ytSubgenAudioFormat?: string; - logLevel?: string; - } | null = null; - let configInvocation: { action: string; logLevel?: string } | null = null; - let mpvInvocation: { action: string; logLevel?: string } | null = null; - let appInvocation: { appArgs: string[] } | null = null; - let doctorLogLevel: string | null = null; - let texthookerLogLevel: string | null = null; - - const commandProgram = new Command(); - commandProgram - .name(scriptName) - .description('Launch MPV with SubMiner sentence mining overlay') - .showHelpAfterError(true) - .enablePositionalOptions() - .allowExcessArguments(false) - .allowUnknownOption(false) - .exitOverride(); - applyRootOptions(commandProgram); - - const rootProgram = new Command(); - rootProgram - .name(scriptName) - .description('Launch MPV with SubMiner sentence mining overlay') - .usage('[options] [command] [target]') - .showHelpAfterError(true) - .allowExcessArguments(false) - .allowUnknownOption(false) - .exitOverride() - .argument('[target]', 'file, directory, or URL'); - applyRootOptions(rootProgram); - - commandProgram - .command('jellyfin') - .alias('jf') - .description('Jellyfin workflows') - .argument('[action]', 'setup|discovery|play|login|logout') - .option('-d, --discovery', 'Cast discovery mode') - .option('-p, --play', 'Interactive play picker') - .option('-l, --login', 'Login flow') - .option('--logout', 'Clear token/session') - .option('--setup', 'Open setup window') - .option('-s, --server ', 'Jellyfin server URL') - .option('-u, --username ', 'Jellyfin username') - .option('-w, --password ', 'Jellyfin password') - .option('--log-level ', 'Log level') - .action((action: string | undefined, options: Record) => { - jellyfinInvocation = { - action, - discovery: options.discovery === true, - play: options.play === true, - login: options.login === true, - logout: options.logout === true, - setup: options.setup === true, - server: typeof options.server === 'string' ? options.server : undefined, - username: typeof options.username === 'string' ? options.username : undefined, - password: typeof options.password === 'string' ? options.password : undefined, - logLevel: typeof options.logLevel === 'string' ? options.logLevel : undefined, - }; - }); - - commandProgram - .command('yt') - .alias('youtube') - .description('YouTube workflows') - .argument('[target]', 'YouTube URL or ytsearch: query') - .option('-m, --mode ', 'Subtitle generation mode') - .option('-o, --out-dir ', 'Subtitle output dir') - .option('--keep-temp', 'Keep temp files') - .option('--whisper-bin ', 'whisper.cpp CLI path') - .option('--whisper-model ', 'whisper model path') - .option('--yt-subgen-audio-format ', 'Audio extraction format') - .option('--log-level ', 'Log level') - .action((target: string | undefined, options: Record) => { - ytInvocation = { - target, - mode: typeof options.mode === 'string' ? options.mode : undefined, - outDir: typeof options.outDir === 'string' ? options.outDir : undefined, - keepTemp: options.keepTemp === true, - whisperBin: typeof options.whisperBin === 'string' ? options.whisperBin : undefined, - whisperModel: typeof options.whisperModel === 'string' ? options.whisperModel : undefined, - ytSubgenAudioFormat: - typeof options.ytSubgenAudioFormat === 'string' ? options.ytSubgenAudioFormat : undefined, - logLevel: typeof options.logLevel === 'string' ? options.logLevel : undefined, - }; - }); - - commandProgram - .command('doctor') - .description('Run dependency and environment checks') - .option('--log-level ', 'Log level') - .action((options: Record) => { - parsed.doctor = true; - doctorLogLevel = typeof options.logLevel === 'string' ? options.logLevel : null; - }); - - commandProgram - .command('config') - .description('Config helpers') - .argument('[action]', 'path|show', 'path') - .option('--log-level ', 'Log level') - .action((action: string, options: Record) => { - configInvocation = { - action, - logLevel: typeof options.logLevel === 'string' ? options.logLevel : undefined, - }; - }); - - commandProgram - .command('mpv') - .description('MPV helpers') - .argument('[action]', 'status|socket|idle', 'status') - .option('--log-level ', 'Log level') - .action((action: string, options: Record) => { - mpvInvocation = { - action, - logLevel: typeof options.logLevel === 'string' ? options.logLevel : undefined, - }; - }); - - commandProgram - .command('texthooker') - .description('Launch texthooker-only mode') - .option('--log-level ', 'Log level') - .action((options: Record) => { - parsed.texthookerOnly = true; - texthookerLogLevel = typeof options.logLevel === 'string' ? options.logLevel : null; - }); - - commandProgram - .command('app') - .alias('bin') - .description('Pass arguments directly to SubMiner binary') - .allowUnknownOption(true) - .allowExcessArguments(true) - .argument('[appArgs...]', 'Arguments forwarded to SubMiner app binary') - .action((appArgs: string[] | undefined) => { - appInvocation = { - appArgs: Array.isArray(appArgs) ? appArgs : [], - }; - }); - - rootProgram.addHelpText('after', buildSubcommandHelpText(commandProgram)); - - const selectedProgram = hasTopLevelCommand(argv) ? commandProgram : rootProgram; + let cliResult: ReturnType; try { - selectedProgram.parse(['node', scriptName, ...argv]); + cliResult = parseCliPrograms(argv, scriptName); } catch (error) { - const commanderError = error as { code?: string; message?: string }; - if (commanderError?.code === 'commander.helpDisplayed') { - process.exit(0); - } - fail(commanderError?.message || String(error)); - } - - const options = selectedProgram.opts>(); - if (typeof options.backend === 'string') { - parsed.backend = parseBackend(options.backend); - } - if (typeof options.directory === 'string') { - parsed.directory = options.directory; - } - if (options.recursive === true) parsed.recursive = true; - if (typeof options.profile === 'string') { - parsed.profile = options.profile; - } - if (options.start === true) parsed.startOverlay = true; - if (typeof options.logLevel === 'string') { - parsed.logLevel = parseLogLevel(options.logLevel); - } - if (options.rofi === true) parsed.useRofi = true; - if (options.startOverlay === true) parsed.autoStartOverlay = true; - if (options.texthooker === false) parsed.useTexthooker = false; - - const rootTarget = rootProgram.processedArgs[0]; - if (typeof rootTarget === 'string' && rootTarget) { - ensureTarget(rootTarget, parsed); - } - - if (jellyfinInvocation) { - if (jellyfinInvocation.logLevel) { - parsed.logLevel = parseLogLevel(jellyfinInvocation.logLevel); - } - const action = (jellyfinInvocation.action || '').toLowerCase(); - if (action && !['setup', 'discovery', 'play', 'login', 'logout'].includes(action)) { - fail(`Unknown jellyfin action: ${jellyfinInvocation.action}`); - } - - parsed.jellyfinServer = jellyfinInvocation.server || ''; - parsed.jellyfinUsername = jellyfinInvocation.username || ''; - parsed.jellyfinPassword = jellyfinInvocation.password || ''; - - const modeFlags = { - setup: jellyfinInvocation.setup || action === 'setup', - discovery: jellyfinInvocation.discovery || action === 'discovery', - play: jellyfinInvocation.play || action === 'play', - login: jellyfinInvocation.login || action === 'login', - logout: jellyfinInvocation.logout || action === 'logout', - }; - if ( - !modeFlags.setup && - !modeFlags.discovery && - !modeFlags.play && - !modeFlags.login && - !modeFlags.logout - ) { - modeFlags.setup = true; - } - - parsed.jellyfin = Boolean(modeFlags.setup); - parsed.jellyfinDiscovery = Boolean(modeFlags.discovery); - parsed.jellyfinPlay = Boolean(modeFlags.play); - parsed.jellyfinLogin = Boolean(modeFlags.login); - parsed.jellyfinLogout = Boolean(modeFlags.logout); - } - - if (ytInvocation) { - if (ytInvocation.logLevel) { - parsed.logLevel = parseLogLevel(ytInvocation.logLevel); - } - const mode = ytInvocation.mode; - if (mode) parsed.youtubeSubgenMode = parseYoutubeMode(mode); - const outDir = ytInvocation.outDir; - if (outDir) parsed.youtubeSubgenOutDir = outDir; - if (ytInvocation.keepTemp) { - parsed.youtubeSubgenKeepTemp = true; - } - if (ytInvocation.whisperBin) parsed.whisperBin = ytInvocation.whisperBin; - if (ytInvocation.whisperModel) parsed.whisperModel = ytInvocation.whisperModel; - if (ytInvocation.ytSubgenAudioFormat) { - parsed.youtubeSubgenAudioFormat = ytInvocation.ytSubgenAudioFormat; - } - if (ytInvocation.target) { - ensureTarget(ytInvocation.target, parsed); - } - } - - if (doctorLogLevel) { - parsed.logLevel = parseLogLevel(doctorLogLevel); - } - - if (texthookerLogLevel) { - parsed.logLevel = parseLogLevel(texthookerLogLevel); - } - - if (configInvocation !== null) { - if (configInvocation.logLevel) { - parsed.logLevel = parseLogLevel(configInvocation.logLevel); - } - const action = (configInvocation.action || 'path').toLowerCase(); - if (action === 'path') parsed.configPath = true; - else if (action === 'show') parsed.configShow = true; - else fail(`Unknown config action: ${configInvocation.action}`); - } - - if (mpvInvocation !== null) { - if (mpvInvocation.logLevel) { - parsed.logLevel = parseLogLevel(mpvInvocation.logLevel); - } - const action = (mpvInvocation.action || 'status').toLowerCase(); - if (action === 'status') parsed.mpvStatus = true; - else if (action === 'socket') parsed.mpvSocket = true; - else if (action === 'idle' || action === 'start') parsed.mpvIdle = true; - else fail(`Unknown mpv action: ${mpvInvocation.action}`); - } - - if (appInvocation !== null) { - parsed.appPassthrough = true; - parsed.appArgs = appInvocation.appArgs; + const message = error instanceof Error ? error.message : String(error); + fail(message); } + applyRootOptionsToArgs(parsed, cliResult.options, cliResult.rootTarget); + applyInvocationsToArgs(parsed, cliResult.invocations); return parsed; } diff --git a/launcher/config/args-normalizer.ts b/launcher/config/args-normalizer.ts new file mode 100644 index 0000000..3408b81 --- /dev/null +++ b/launcher/config/args-normalizer.ts @@ -0,0 +1,257 @@ +import fs from 'node:fs'; +import { fail } from '../log.js'; +import type { + Args, + Backend, + LauncherYoutubeSubgenConfig, + LogLevel, + YoutubeSubgenMode, +} from '../types.js'; +import { + DEFAULT_JIMAKU_API_BASE_URL, + DEFAULT_YOUTUBE_PRIMARY_SUB_LANGS, + DEFAULT_YOUTUBE_SECONDARY_SUB_LANGS, + DEFAULT_YOUTUBE_SUBGEN_OUT_DIR, +} from '../types.js'; +import { + inferWhisperLanguage, + isUrlTarget, + resolvePathMaybe, + uniqueNormalizedLangCodes, +} from '../util.js'; +import type { CliInvocations } from './cli-parser-builder.js'; + +function ensureTarget(target: string, parsed: Args): void { + if (isUrlTarget(target)) { + parsed.target = target; + parsed.targetKind = 'url'; + return; + } + const resolved = resolvePathMaybe(target); + let stat: fs.Stats | null = null; + try { + stat = fs.statSync(resolved); + } catch { + stat = null; + } + if (stat?.isFile()) { + parsed.target = resolved; + parsed.targetKind = 'file'; + return; + } + if (stat?.isDirectory()) { + parsed.directory = resolved; + return; + } + fail(`Not a file, directory, or supported URL: ${target}`); +} + +function parseLogLevel(value: string): LogLevel { + if (value === 'debug' || value === 'info' || value === 'warn' || value === 'error') { + return value; + } + fail(`Invalid log level: ${value} (must be debug, info, warn, or error)`); +} + +function parseYoutubeMode(value: string): YoutubeSubgenMode { + const normalized = value.toLowerCase(); + if (normalized === 'automatic' || normalized === 'preprocess' || normalized === 'off') { + return normalized as YoutubeSubgenMode; + } + fail(`Invalid yt-subgen mode: ${value} (must be automatic, preprocess, or off)`); +} + +function parseBackend(value: string): Backend { + if (value === 'auto' || value === 'hyprland' || value === 'x11' || value === 'macos') { + return value as Backend; + } + fail(`Invalid backend: ${value} (must be auto, hyprland, x11, or macos)`); +} + +export function createDefaultArgs(launcherConfig: LauncherYoutubeSubgenConfig): Args { + const envMode = (process.env.SUBMINER_YT_SUBGEN_MODE || '').toLowerCase(); + const defaultMode: YoutubeSubgenMode = + envMode === 'preprocess' || envMode === 'off' || envMode === 'automatic' + ? (envMode as YoutubeSubgenMode) + : launcherConfig.mode + ? launcherConfig.mode + : 'automatic'; + const configuredSecondaryLangs = uniqueNormalizedLangCodes( + launcherConfig.secondarySubLanguages ?? [], + ); + const configuredPrimaryLangs = uniqueNormalizedLangCodes( + launcherConfig.primarySubLanguages ?? [], + ); + const primarySubLangs = + configuredPrimaryLangs.length > 0 + ? configuredPrimaryLangs + : [...DEFAULT_YOUTUBE_PRIMARY_SUB_LANGS]; + const secondarySubLangs = + configuredSecondaryLangs.length > 0 + ? configuredSecondaryLangs + : [...DEFAULT_YOUTUBE_SECONDARY_SUB_LANGS]; + const youtubeAudioLangs = uniqueNormalizedLangCodes([...primarySubLangs, ...secondarySubLangs]); + + const parsed: Args = { + backend: 'auto', + directory: '.', + recursive: false, + profile: 'subminer', + startOverlay: false, + youtubeSubgenMode: defaultMode, + whisperBin: process.env.SUBMINER_WHISPER_BIN || launcherConfig.whisperBin || '', + whisperModel: process.env.SUBMINER_WHISPER_MODEL || launcherConfig.whisperModel || '', + youtubeSubgenOutDir: process.env.SUBMINER_YT_SUBGEN_OUT_DIR || DEFAULT_YOUTUBE_SUBGEN_OUT_DIR, + youtubeSubgenAudioFormat: process.env.SUBMINER_YT_SUBGEN_AUDIO_FORMAT || 'm4a', + youtubeSubgenKeepTemp: process.env.SUBMINER_YT_SUBGEN_KEEP_TEMP === '1', + jimakuApiKey: process.env.SUBMINER_JIMAKU_API_KEY || '', + jimakuApiKeyCommand: process.env.SUBMINER_JIMAKU_API_KEY_COMMAND || '', + jimakuApiBaseUrl: process.env.SUBMINER_JIMAKU_API_BASE_URL || DEFAULT_JIMAKU_API_BASE_URL, + jimakuLanguagePreference: launcherConfig.jimakuLanguagePreference || 'ja', + jimakuMaxEntryResults: launcherConfig.jimakuMaxEntryResults || 10, + jellyfin: false, + jellyfinLogin: false, + jellyfinLogout: false, + jellyfinPlay: false, + jellyfinDiscovery: false, + doctor: false, + configPath: false, + configShow: false, + mpvIdle: false, + mpvSocket: false, + mpvStatus: false, + appPassthrough: false, + appArgs: [], + jellyfinServer: '', + jellyfinUsername: '', + jellyfinPassword: '', + youtubePrimarySubLangs: primarySubLangs, + youtubeSecondarySubLangs: secondarySubLangs, + youtubeAudioLangs, + youtubeWhisperSourceLanguage: inferWhisperLanguage(primarySubLangs, 'ja'), + useTexthooker: true, + autoStartOverlay: false, + texthookerOnly: false, + useRofi: false, + logLevel: 'info', + target: '', + targetKind: '', + }; + + if (launcherConfig.jimakuApiKey) parsed.jimakuApiKey = launcherConfig.jimakuApiKey; + if (launcherConfig.jimakuApiKeyCommand) + parsed.jimakuApiKeyCommand = launcherConfig.jimakuApiKeyCommand; + if (launcherConfig.jimakuApiBaseUrl) parsed.jimakuApiBaseUrl = launcherConfig.jimakuApiBaseUrl; + if (launcherConfig.jimakuLanguagePreference) + parsed.jimakuLanguagePreference = launcherConfig.jimakuLanguagePreference; + if (launcherConfig.jimakuMaxEntryResults !== undefined) + parsed.jimakuMaxEntryResults = launcherConfig.jimakuMaxEntryResults; + + return parsed; +} + +export function applyRootOptionsToArgs( + parsed: Args, + options: Record, + rootTarget: unknown, +): void { + if (typeof options.backend === 'string') parsed.backend = parseBackend(options.backend); + if (typeof options.directory === 'string') parsed.directory = options.directory; + if (options.recursive === true) parsed.recursive = true; + if (typeof options.profile === 'string') parsed.profile = options.profile; + if (options.start === true) parsed.startOverlay = true; + if (typeof options.logLevel === 'string') parsed.logLevel = parseLogLevel(options.logLevel); + if (options.rofi === true) parsed.useRofi = true; + if (options.startOverlay === true) parsed.autoStartOverlay = true; + if (options.texthooker === false) parsed.useTexthooker = false; + if (typeof rootTarget === 'string' && rootTarget) ensureTarget(rootTarget, parsed); +} + +export function applyInvocationsToArgs(parsed: Args, invocations: CliInvocations): void { + if (invocations.doctorTriggered) parsed.doctor = true; + if (invocations.texthookerTriggered) parsed.texthookerOnly = true; + + if (invocations.jellyfinInvocation) { + if (invocations.jellyfinInvocation.logLevel) { + parsed.logLevel = parseLogLevel(invocations.jellyfinInvocation.logLevel); + } + const action = (invocations.jellyfinInvocation.action || '').toLowerCase(); + if (action && !['setup', 'discovery', 'play', 'login', 'logout'].includes(action)) { + fail(`Unknown jellyfin action: ${invocations.jellyfinInvocation.action}`); + } + parsed.jellyfinServer = invocations.jellyfinInvocation.server || ''; + parsed.jellyfinUsername = invocations.jellyfinInvocation.username || ''; + parsed.jellyfinPassword = invocations.jellyfinInvocation.password || ''; + + const modeFlags = { + setup: invocations.jellyfinInvocation.setup || action === 'setup', + discovery: invocations.jellyfinInvocation.discovery || action === 'discovery', + play: invocations.jellyfinInvocation.play || action === 'play', + login: invocations.jellyfinInvocation.login || action === 'login', + logout: invocations.jellyfinInvocation.logout || action === 'logout', + }; + if ( + !modeFlags.setup && + !modeFlags.discovery && + !modeFlags.play && + !modeFlags.login && + !modeFlags.logout + ) { + modeFlags.setup = true; + } + + parsed.jellyfin = Boolean(modeFlags.setup); + parsed.jellyfinDiscovery = Boolean(modeFlags.discovery); + parsed.jellyfinPlay = Boolean(modeFlags.play); + parsed.jellyfinLogin = Boolean(modeFlags.login); + parsed.jellyfinLogout = Boolean(modeFlags.logout); + } + + if (invocations.ytInvocation) { + if (invocations.ytInvocation.logLevel) + parsed.logLevel = parseLogLevel(invocations.ytInvocation.logLevel); + if (invocations.ytInvocation.mode) + parsed.youtubeSubgenMode = parseYoutubeMode(invocations.ytInvocation.mode); + if (invocations.ytInvocation.outDir) + parsed.youtubeSubgenOutDir = invocations.ytInvocation.outDir; + if (invocations.ytInvocation.keepTemp) parsed.youtubeSubgenKeepTemp = true; + if (invocations.ytInvocation.whisperBin) + parsed.whisperBin = invocations.ytInvocation.whisperBin; + if (invocations.ytInvocation.whisperModel) + parsed.whisperModel = invocations.ytInvocation.whisperModel; + if (invocations.ytInvocation.ytSubgenAudioFormat) { + parsed.youtubeSubgenAudioFormat = invocations.ytInvocation.ytSubgenAudioFormat; + } + if (invocations.ytInvocation.target) ensureTarget(invocations.ytInvocation.target, parsed); + } + + if (invocations.doctorLogLevel) parsed.logLevel = parseLogLevel(invocations.doctorLogLevel); + if (invocations.texthookerLogLevel) + parsed.logLevel = parseLogLevel(invocations.texthookerLogLevel); + + if (invocations.configInvocation) { + if (invocations.configInvocation.logLevel) { + parsed.logLevel = parseLogLevel(invocations.configInvocation.logLevel); + } + const action = (invocations.configInvocation.action || 'path').toLowerCase(); + if (action === 'path') parsed.configPath = true; + else if (action === 'show') parsed.configShow = true; + else fail(`Unknown config action: ${invocations.configInvocation.action}`); + } + + if (invocations.mpvInvocation) { + if (invocations.mpvInvocation.logLevel) { + parsed.logLevel = parseLogLevel(invocations.mpvInvocation.logLevel); + } + const action = (invocations.mpvInvocation.action || 'status').toLowerCase(); + if (action === 'status') parsed.mpvStatus = true; + else if (action === 'socket') parsed.mpvSocket = true; + else if (action === 'idle' || action === 'start') parsed.mpvIdle = true; + else fail(`Unknown mpv action: ${invocations.mpvInvocation.action}`); + } + + if (invocations.appInvocation) { + parsed.appPassthrough = true; + parsed.appArgs = invocations.appInvocation.appArgs; + } +} diff --git a/launcher/config/cli-parser-builder.ts b/launcher/config/cli-parser-builder.ts new file mode 100644 index 0000000..f486f7a --- /dev/null +++ b/launcher/config/cli-parser-builder.ts @@ -0,0 +1,294 @@ +import { Command } from 'commander'; + +export interface JellyfinInvocation { + action?: string; + discovery?: boolean; + play?: boolean; + login?: boolean; + logout?: boolean; + setup?: boolean; + server?: string; + username?: string; + password?: string; + logLevel?: string; +} + +export interface YtInvocation { + target?: string; + mode?: string; + outDir?: string; + keepTemp?: boolean; + whisperBin?: string; + whisperModel?: string; + ytSubgenAudioFormat?: string; + logLevel?: string; +} + +export interface CommandActionInvocation { + action: string; + logLevel?: string; +} + +export interface CliInvocations { + jellyfinInvocation: JellyfinInvocation | null; + ytInvocation: YtInvocation | null; + configInvocation: CommandActionInvocation | null; + mpvInvocation: CommandActionInvocation | null; + appInvocation: { appArgs: string[] } | null; + doctorTriggered: boolean; + doctorLogLevel: string | null; + texthookerTriggered: boolean; + texthookerLogLevel: string | null; +} + +function applyRootOptions(program: Command): void { + program + .option('-b, --backend ', 'Display backend') + .option('-d, --directory ', 'Directory to browse') + .option('-r, --recursive', 'Search directories recursively') + .option('-p, --profile ', 'MPV profile') + .option('--start', 'Explicitly start overlay') + .option('--log-level ', 'Log level') + .option('-R, --rofi', 'Use rofi picker') + .option('-S, --start-overlay', 'Auto-start overlay') + .option('-T, --no-texthooker', 'Disable texthooker-ui server'); +} + +function buildSubcommandHelpText(program: Command): string { + const subcommands = program.commands + .filter((command) => command.name() !== 'help') + .map((command) => { + const aliases = command.aliases(); + const term = aliases.length > 0 ? `${command.name()}|${aliases[0]}` : command.name(); + return { term, description: command.description() }; + }); + + if (subcommands.length === 0) return ''; + const longestTerm = Math.max(...subcommands.map((entry) => entry.term.length)); + const lines = subcommands.map((entry) => + ` ${entry.term.padEnd(longestTerm)} ${entry.description || ''}`.trimEnd(), + ); + return `\nCommands:\n${lines.join('\n')}\n`; +} + +function getTopLevelCommand(argv: string[]): { name: string; index: number } | null { + const commandNames = new Set([ + 'jellyfin', + 'jf', + 'yt', + 'youtube', + 'doctor', + 'config', + 'mpv', + 'texthooker', + 'app', + 'bin', + 'help', + ]); + const optionsWithValue = new Set([ + '-b', + '--backend', + '-d', + '--directory', + '-p', + '--profile', + '--log-level', + ]); + for (let i = 0; i < argv.length; i += 1) { + const token = argv[i] || ''; + if (token === '--') return null; + if (token.startsWith('-')) { + if (optionsWithValue.has(token)) i += 1; + continue; + } + return commandNames.has(token) ? { name: token, index: i } : null; + } + return null; +} + +function hasTopLevelCommand(argv: string[]): boolean { + return getTopLevelCommand(argv) !== null; +} + +export function resolveTopLevelCommand(argv: string[]): { name: string; index: number } | null { + return getTopLevelCommand(argv); +} + +export function parseCliPrograms( + argv: string[], + scriptName: string, +): { + options: Record; + rootTarget: unknown; + invocations: CliInvocations; +} { + let jellyfinInvocation: JellyfinInvocation | null = null; + let ytInvocation: YtInvocation | null = null; + let configInvocation: CommandActionInvocation | null = null; + let mpvInvocation: CommandActionInvocation | null = null; + let appInvocation: { appArgs: string[] } | null = null; + let doctorLogLevel: string | null = null; + let texthookerLogLevel: string | null = null; + let doctorTriggered = false; + let texthookerTriggered = false; + + const commandProgram = new Command(); + commandProgram + .name(scriptName) + .description('Launch MPV with SubMiner sentence mining overlay') + .showHelpAfterError(true) + .enablePositionalOptions() + .allowExcessArguments(false) + .allowUnknownOption(false) + .exitOverride(); + applyRootOptions(commandProgram); + + const rootProgram = new Command(); + rootProgram + .name(scriptName) + .description('Launch MPV with SubMiner sentence mining overlay') + .usage('[options] [command] [target]') + .showHelpAfterError(true) + .allowExcessArguments(false) + .allowUnknownOption(false) + .exitOverride() + .argument('[target]', 'file, directory, or URL'); + applyRootOptions(rootProgram); + + commandProgram + .command('jellyfin') + .alias('jf') + .description('Jellyfin workflows') + .argument('[action]', 'setup|discovery|play|login|logout') + .option('-d, --discovery', 'Cast discovery mode') + .option('-p, --play', 'Interactive play picker') + .option('-l, --login', 'Login flow') + .option('--logout', 'Clear token/session') + .option('--setup', 'Open setup window') + .option('-s, --server ', 'Jellyfin server URL') + .option('-u, --username ', 'Jellyfin username') + .option('-w, --password ', 'Jellyfin password') + .option('--log-level ', 'Log level') + .action((action: string | undefined, options: Record) => { + jellyfinInvocation = { + action, + discovery: options.discovery === true, + play: options.play === true, + login: options.login === true, + logout: options.logout === true, + setup: options.setup === true, + server: typeof options.server === 'string' ? options.server : undefined, + username: typeof options.username === 'string' ? options.username : undefined, + password: typeof options.password === 'string' ? options.password : undefined, + logLevel: typeof options.logLevel === 'string' ? options.logLevel : undefined, + }; + }); + + commandProgram + .command('yt') + .alias('youtube') + .description('YouTube workflows') + .argument('[target]', 'YouTube URL or ytsearch: query') + .option('-m, --mode ', 'Subtitle generation mode') + .option('-o, --out-dir ', 'Subtitle output dir') + .option('--keep-temp', 'Keep temp files') + .option('--whisper-bin ', 'whisper.cpp CLI path') + .option('--whisper-model ', 'whisper model path') + .option('--yt-subgen-audio-format ', 'Audio extraction format') + .option('--log-level ', 'Log level') + .action((target: string | undefined, options: Record) => { + ytInvocation = { + target, + mode: typeof options.mode === 'string' ? options.mode : undefined, + outDir: typeof options.outDir === 'string' ? options.outDir : undefined, + keepTemp: options.keepTemp === true, + whisperBin: typeof options.whisperBin === 'string' ? options.whisperBin : undefined, + whisperModel: typeof options.whisperModel === 'string' ? options.whisperModel : undefined, + ytSubgenAudioFormat: + typeof options.ytSubgenAudioFormat === 'string' ? options.ytSubgenAudioFormat : undefined, + logLevel: typeof options.logLevel === 'string' ? options.logLevel : undefined, + }; + }); + + commandProgram + .command('doctor') + .description('Run dependency and environment checks') + .option('--log-level ', 'Log level') + .action((options: Record) => { + doctorTriggered = true; + doctorLogLevel = typeof options.logLevel === 'string' ? options.logLevel : null; + }); + + commandProgram + .command('config') + .description('Config helpers') + .argument('[action]', 'path|show', 'path') + .option('--log-level ', 'Log level') + .action((action: string, options: Record) => { + configInvocation = { + action, + logLevel: typeof options.logLevel === 'string' ? options.logLevel : undefined, + }; + }); + + commandProgram + .command('mpv') + .description('MPV helpers') + .argument('[action]', 'status|socket|idle', 'status') + .option('--log-level ', 'Log level') + .action((action: string, options: Record) => { + mpvInvocation = { + action, + logLevel: typeof options.logLevel === 'string' ? options.logLevel : undefined, + }; + }); + + commandProgram + .command('texthooker') + .description('Launch texthooker-only mode') + .option('--log-level ', 'Log level') + .action((options: Record) => { + texthookerTriggered = true; + texthookerLogLevel = typeof options.logLevel === 'string' ? options.logLevel : null; + }); + + commandProgram + .command('app') + .alias('bin') + .description('Pass arguments directly to SubMiner binary') + .allowUnknownOption(true) + .allowExcessArguments(true) + .argument('[appArgs...]', 'Arguments forwarded to SubMiner app binary') + .action((appArgs: string[] | undefined) => { + appInvocation = { appArgs: Array.isArray(appArgs) ? appArgs : [] }; + }); + + rootProgram.addHelpText('after', buildSubcommandHelpText(commandProgram)); + + const selectedProgram = hasTopLevelCommand(argv) ? commandProgram : rootProgram; + try { + selectedProgram.parse(['node', scriptName, ...argv]); + } catch (error) { + const commanderError = error as { code?: string; message?: string }; + if (commanderError?.code === 'commander.helpDisplayed') { + process.exit(0); + } + throw new Error(commanderError?.message || String(error)); + } + + return { + options: selectedProgram.opts>(), + rootTarget: rootProgram.processedArgs[0], + invocations: { + jellyfinInvocation, + ytInvocation, + configInvocation, + mpvInvocation, + appInvocation, + doctorTriggered, + doctorLogLevel, + texthookerTriggered, + texthookerLogLevel, + }, + }; +} diff --git a/launcher/config/jellyfin-config.ts b/launcher/config/jellyfin-config.ts new file mode 100644 index 0000000..19d75c9 --- /dev/null +++ b/launcher/config/jellyfin-config.ts @@ -0,0 +1,16 @@ +import type { LauncherJellyfinConfig } from '../types.js'; + +export function parseLauncherJellyfinConfig(root: Record): LauncherJellyfinConfig { + const jellyfinRaw = root.jellyfin; + if (!jellyfinRaw || typeof jellyfinRaw !== 'object') return {}; + const jellyfin = jellyfinRaw as Record; + return { + enabled: typeof jellyfin.enabled === 'boolean' ? jellyfin.enabled : undefined, + serverUrl: typeof jellyfin.serverUrl === 'string' ? jellyfin.serverUrl : undefined, + username: typeof jellyfin.username === 'string' ? jellyfin.username : undefined, + defaultLibraryId: + typeof jellyfin.defaultLibraryId === 'string' ? jellyfin.defaultLibraryId : undefined, + pullPictures: typeof jellyfin.pullPictures === 'boolean' ? jellyfin.pullPictures : undefined, + iconCacheDir: typeof jellyfin.iconCacheDir === 'string' ? jellyfin.iconCacheDir : undefined, + }; +} diff --git a/launcher/config/plugin-runtime-config.ts b/launcher/config/plugin-runtime-config.ts new file mode 100644 index 0000000..13baabe --- /dev/null +++ b/launcher/config/plugin-runtime-config.ts @@ -0,0 +1,57 @@ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { log } from '../log.js'; +import type { LogLevel, PluginRuntimeConfig } from '../types.js'; +import { DEFAULT_SOCKET_PATH } from '../types.js'; + +export function getPluginConfigCandidates(): string[] { + const xdgConfigHome = process.env.XDG_CONFIG_HOME || path.join(os.homedir(), '.config'); + return Array.from( + new Set([ + path.join(xdgConfigHome, 'mpv', 'script-opts', 'subminer.conf'), + path.join(os.homedir(), '.config', 'mpv', 'script-opts', 'subminer.conf'), + ]), + ); +} + +export function parsePluginRuntimeConfigContent(content: string): PluginRuntimeConfig { + const runtimeConfig: PluginRuntimeConfig = { socketPath: DEFAULT_SOCKET_PATH }; + for (const line of content.split(/\r?\n/)) { + const trimmed = line.trim(); + if (trimmed.length === 0 || trimmed.startsWith('#')) continue; + const socketMatch = trimmed.match(/^socket_path\s*=\s*(.+)$/i); + if (!socketMatch) continue; + const value = (socketMatch[1] || '').split('#', 1)[0]?.trim() || ''; + if (value) runtimeConfig.socketPath = value; + } + return runtimeConfig; +} + +export function readPluginRuntimeConfig(logLevel: LogLevel): PluginRuntimeConfig { + const candidates = getPluginConfigCandidates(); + const defaults: PluginRuntimeConfig = { socketPath: DEFAULT_SOCKET_PATH }; + + for (const configPath of candidates) { + if (!fs.existsSync(configPath)) continue; + try { + const parsed = parsePluginRuntimeConfigContent(fs.readFileSync(configPath, 'utf8')); + log( + 'debug', + logLevel, + `Using mpv plugin settings from ${configPath}: socket_path=${parsed.socketPath}`, + ); + return parsed; + } catch { + log('warn', logLevel, `Failed to read ${configPath}; using launcher defaults`); + return defaults; + } + } + + log( + 'debug', + logLevel, + `No mpv subminer.conf found; using launcher defaults (socket_path=${defaults.socketPath})`, + ); + return defaults; +} diff --git a/launcher/config/shared-config-reader.ts b/launcher/config/shared-config-reader.ts new file mode 100644 index 0000000..3d9228b --- /dev/null +++ b/launcher/config/shared-config-reader.ts @@ -0,0 +1,25 @@ +import fs from 'node:fs'; +import os from 'node:os'; +import { parse as parseJsonc } from 'jsonc-parser'; +import { resolveConfigFilePath } from '../../src/config/path-resolution.js'; + +export function resolveLauncherMainConfigPath(): string { + return resolveConfigFilePath({ + xdgConfigHome: process.env.XDG_CONFIG_HOME, + homeDir: os.homedir(), + existsSync: fs.existsSync, + }); +} + +export function readLauncherMainConfigObject(): Record | null { + const configPath = resolveLauncherMainConfigPath(); + if (!fs.existsSync(configPath)) return null; + try { + const data = fs.readFileSync(configPath, 'utf8'); + const parsed = configPath.endsWith('.jsonc') ? parseJsonc(data) : JSON.parse(data); + if (!parsed || typeof parsed !== 'object') return null; + return parsed as Record; + } catch { + return null; + } +} diff --git a/launcher/config/youtube-subgen-config.ts b/launcher/config/youtube-subgen-config.ts new file mode 100644 index 0000000..bfe7c34 --- /dev/null +++ b/launcher/config/youtube-subgen-config.ts @@ -0,0 +1,54 @@ +import type { LauncherYoutubeSubgenConfig } from '../types.js'; + +function asStringArray(value: unknown): string[] | undefined { + if (!Array.isArray(value)) return undefined; + return value.filter((entry): entry is string => typeof entry === 'string'); +} + +export function parseLauncherYoutubeSubgenConfig( + root: Record, +): LauncherYoutubeSubgenConfig { + const youtubeSubgenRaw = root.youtubeSubgen; + const youtubeSubgen = + youtubeSubgenRaw && typeof youtubeSubgenRaw === 'object' + ? (youtubeSubgenRaw as Record) + : null; + const secondarySubRaw = root.secondarySub; + const secondarySub = + secondarySubRaw && typeof secondarySubRaw === 'object' + ? (secondarySubRaw as Record) + : null; + const jimakuRaw = root.jimaku; + const jimaku = + jimakuRaw && typeof jimakuRaw === 'object' ? (jimakuRaw as Record) : null; + + const mode = youtubeSubgen?.mode; + const jimakuLanguagePreference = jimaku?.languagePreference; + const jimakuMaxEntryResults = jimaku?.maxEntryResults; + + return { + mode: mode === 'automatic' || mode === 'preprocess' || mode === 'off' ? mode : undefined, + whisperBin: + typeof youtubeSubgen?.whisperBin === 'string' ? youtubeSubgen.whisperBin : undefined, + whisperModel: + typeof youtubeSubgen?.whisperModel === 'string' ? youtubeSubgen.whisperModel : undefined, + primarySubLanguages: asStringArray(youtubeSubgen?.primarySubLanguages), + secondarySubLanguages: asStringArray(secondarySub?.secondarySubLanguages), + jimakuApiKey: typeof jimaku?.apiKey === 'string' ? jimaku.apiKey : undefined, + jimakuApiKeyCommand: + typeof jimaku?.apiKeyCommand === 'string' ? jimaku.apiKeyCommand : undefined, + jimakuApiBaseUrl: typeof jimaku?.apiBaseUrl === 'string' ? jimaku.apiBaseUrl : undefined, + jimakuLanguagePreference: + jimakuLanguagePreference === 'ja' || + jimakuLanguagePreference === 'en' || + jimakuLanguagePreference === 'none' + ? jimakuLanguagePreference + : undefined, + jimakuMaxEntryResults: + typeof jimakuMaxEntryResults === 'number' && + Number.isFinite(jimakuMaxEntryResults) && + jimakuMaxEntryResults > 0 + ? Math.floor(jimakuMaxEntryResults) + : undefined, + }; +} diff --git a/launcher/jellyfin.ts b/launcher/jellyfin.ts index 6c4ada1..61b1711 100644 --- a/launcher/jellyfin.ts +++ b/launcher/jellyfin.ts @@ -127,6 +127,7 @@ export async function resolveJellyfinSelection( left.localeCompare(right, undefined, { sensitivity: 'base', numeric: true }); const sortEntries = ( entries: Array<{ + id: string; type: string; name: string; parentIndex: number | null; @@ -355,10 +356,12 @@ export async function runJellyfinPlayMenu( mpvSocketPath: string, ): Promise { const config = loadLauncherJellyfinConfig(); + const envAccessToken = (process.env.SUBMINER_JELLYFIN_ACCESS_TOKEN || '').trim(); + const envUserId = (process.env.SUBMINER_JELLYFIN_USER_ID || '').trim(); const session: JellyfinSessionConfig = { serverUrl: sanitizeServerUrl(args.jellyfinServer || config.serverUrl || ''), - accessToken: config.accessToken || '', - userId: config.userId || '', + accessToken: envAccessToken, + userId: envUserId, defaultLibraryId: config.defaultLibraryId || '', pullPictures: config.pullPictures === true, iconCacheDir: config.iconCacheDir || '', @@ -366,7 +369,7 @@ export async function runJellyfinPlayMenu( if (!session.serverUrl || !session.accessToken || !session.userId) { fail( - 'Missing Jellyfin session config. Run `subminer --jellyfin` or `subminer --jellyfin-login` first.', + 'Missing Jellyfin session. Set SUBMINER_JELLYFIN_ACCESS_TOKEN and SUBMINER_JELLYFIN_USER_ID, then retry.', ); } diff --git a/launcher/parse-args.test.ts b/launcher/parse-args.test.ts index 8a56023..1b921db 100644 --- a/launcher/parse-args.test.ts +++ b/launcher/parse-args.test.ts @@ -22,3 +22,24 @@ test('parseArgs keeps all args after app verbatim', () => { assert.equal(parsed.appPassthrough, true); assert.deepEqual(parsed.appArgs, ['--start', '--anilist-setup', '-h']); }); + +test('parseArgs maps jellyfin play action and log-level override', () => { + const parsed = parseArgs(['jellyfin', 'play', '--log-level', 'debug'], 'subminer', {}); + + assert.equal(parsed.jellyfinPlay, true); + assert.equal(parsed.logLevel, 'debug'); +}); + +test('parseArgs maps config show action', () => { + const parsed = parseArgs(['config', 'show'], 'subminer', {}); + + assert.equal(parsed.configShow, true); + assert.equal(parsed.configPath, false); +}); + +test('parseArgs maps mpv idle action', () => { + const parsed = parseArgs(['mpv', 'idle'], 'subminer', {}); + + assert.equal(parsed.mpvIdle, true); + assert.equal(parsed.mpvStatus, false); +}); diff --git a/launcher/types.ts b/launcher/types.ts index dee8c2d..c0d217a 100644 --- a/launcher/types.ts +++ b/launcher/types.ts @@ -121,8 +121,6 @@ export interface LauncherJellyfinConfig { enabled?: boolean; serverUrl?: string; username?: string; - accessToken?: string; - userId?: string; defaultLibraryId?: string; pullPictures?: boolean; iconCacheDir?: string; diff --git a/package.json b/package.json index 04c2c09..15ffa1a 100644 --- a/package.json +++ b/package.json @@ -24,8 +24,8 @@ "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:smoke:src": "bun test launcher/smoke.e2e.test.ts", - "test:launcher:src": "bun test launcher/config.test.ts launcher/parse-args.test.ts launcher/main.test.ts launcher/commands/command-modules.test.ts launcher/smoke.e2e.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/core/utils/shortcut-config.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 launcher/commands/command-modules.test.ts", + "test:launcher:src": "bun test launcher/config.test.ts launcher/config-domain-parsers.test.ts launcher/parse-args.test.ts launcher/main.test.ts launcher/commands/command-modules.test.ts launcher/smoke.e2e.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/core/utils/shortcut-config.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/config-domain-parsers.test.ts launcher/parse-args.test.ts launcher/main.test.ts launcher/commands/command-modules.test.ts", "test:core:dist": "node --test dist/cli/args.test.js dist/cli/help.test.js dist/core/services/cli-command.test.js dist/core/services/ipc.test.js dist/core/services/anki-jimaku-ipc.test.js dist/core/services/field-grouping-overlay.test.js dist/core/services/numeric-shortcut-session.test.js dist/core/services/secondary-subtitle.test.js dist/core/services/mpv-render-metrics.test.js dist/core/services/overlay-content-measurement.test.js dist/core/services/mpv-control.test.js dist/core/services/mpv.test.js dist/core/services/runtime-options-ipc.test.js dist/core/services/runtime-config.test.js dist/core/services/config-hot-reload.test.js dist/core/services/tokenizer.test.js dist/core/services/tokenizer/annotation-stage.test.js dist/core/services/tokenizer/parser-selection-stage.test.js dist/core/services/tokenizer/parser-enrichment-stage.test.js dist/core/services/subsync.test.js dist/core/services/overlay-bridge.test.js dist/core/services/overlay-manager.test.js dist/core/services/overlay-shortcut-handler.test.js dist/core/services/mining.test.js dist/core/services/anki-jimaku.test.js dist/core/services/jellyfin.test.js dist/core/services/jellyfin-remote.test.js dist/core/services/immersion-tracker-service.test.js dist/core/services/app-ready.test.js dist/core/services/startup-bootstrap.test.js dist/core/services/subtitle-processing-controller.test.js dist/core/services/anilist/anilist-token-store.test.js dist/core/services/anilist/anilist-update-queue.test.js dist/renderer/error-recovery.test.js dist/subsync/utils.test.js dist/main/anilist-url-guard.test.js dist/window-trackers/x11-tracker.test.js", "test:core:smoke:dist": "node --test dist/cli/help.test.js dist/core/services/runtime-config.test.js dist/core/services/ipc.test.js dist/core/services/overlay-manager.test.js dist/core/services/anilist/anilist-token-store.test.js dist/core/services/startup-bootstrap.test.js dist/renderer/error-recovery.test.js dist/main/anilist-url-guard.test.js dist/window-trackers/x11-tracker.test.js", "test:smoke:dist": "bun run test:config:smoke:dist && bun run test:core:smoke:dist",