From c749430c778fd8013cb69cdc177fd41c16b4dad7 Mon Sep 17 00:00:00 2001 From: sudacode Date: Sat, 21 Feb 2026 17:14:58 -0800 Subject: [PATCH] refactor(launcher): split CLI flow into command modules Isolate process-side effects behind adapter seams and keep wrapper behavior stable while improving command-level testability. --- ...to-command-modules-and-process-adapters.md | 50 ++- ...-launcher-modules-20260222T005725Z-8oh8.md | 54 +++ launcher/commands/app-command.ts | 20 + launcher/commands/command-modules.test.ts | 90 ++++ launcher/commands/config-command.ts | 43 ++ launcher/commands/context.ts | 12 + launcher/commands/doctor-command.ts | 85 ++++ launcher/commands/jellyfin-command.ts | 71 +++ launcher/commands/mpv-command.ts | 62 +++ launcher/commands/playback-command.ts | 208 +++++++++ launcher/config-path.ts | 11 + launcher/main.ts | 414 +++--------------- launcher/process-adapter.ts | 21 + package.json | 4 +- 14 files changed, 773 insertions(+), 372 deletions(-) create mode 100644 docs/subagents/agents/opencode-task81-launcher-modules-20260222T005725Z-8oh8.md create mode 100644 launcher/commands/app-command.ts create mode 100644 launcher/commands/command-modules.test.ts create mode 100644 launcher/commands/config-command.ts create mode 100644 launcher/commands/context.ts create mode 100644 launcher/commands/doctor-command.ts create mode 100644 launcher/commands/jellyfin-command.ts create mode 100644 launcher/commands/mpv-command.ts create mode 100644 launcher/commands/playback-command.ts create mode 100644 launcher/config-path.ts create mode 100644 launcher/process-adapter.ts diff --git a/backlog/tasks/task-81 - Refactor-launcher-into-command-modules-and-process-adapters.md b/backlog/tasks/task-81 - Refactor-launcher-into-command-modules-and-process-adapters.md index 17a1d11..db722ba 100644 --- a/backlog/tasks/task-81 - Refactor-launcher-into-command-modules-and-process-adapters.md +++ b/backlog/tasks/task-81 - Refactor-launcher-into-command-modules-and-process-adapters.md @@ -1,10 +1,11 @@ --- id: TASK-81 title: Refactor launcher into command modules and process adapters -status: To Do -assignee: [] +status: Done +assignee: + - opencode-task81-launcher-modules created_date: '2026-02-18 11:43' -updated_date: '2026-02-18 11:43' +updated_date: '2026-02-22 01:09' labels: - launcher - cli @@ -43,15 +44,44 @@ Launcher code is still large and process-control heavy (`launcher/main.ts`, `lau ## Acceptance Criteria -- [ ] #1 Launcher commands are implemented as focused modules -- [ ] #2 Process side effects are isolated behind adapter interfaces -- [ ] #3 Existing CLI behavior and exit codes remain compatible -- [ ] #4 Launcher testability improves via mocked adapter tests +- [x] #1 Launcher commands are implemented as focused modules +- [x] #2 Process side effects are isolated behind adapter interfaces +- [x] #3 Existing CLI behavior and exit codes remain compatible +- [x] #4 Launcher testability improves via mocked adapter tests +## Implementation Plan + + +1) Add `launcher/process-adapter.ts` and `launcher/commands/context.ts` so command handlers receive a shared context and explicit process/stdout/signal/exit seam instead of hardcoding `process.*` calls. +2) Extract early-return command branches from `launcher/main.ts` into focused modules under `launcher/commands/` (`config`, `doctor`, `mpv`, `app`, `texthooker`) while preserving existing outputs and exit-code behavior. +3) Extract `jellyfin` and default playback orchestration into dedicated command modules so `launcher/main.ts` becomes thin command dispatch + lifecycle wiring. +4) Add adapter-mocked command tests (`launcher/commands/command-modules.test.ts`) and keep integration regressions in `launcher/main.test.ts` to prove behavior parity. +5) Wire new command test file into launcher/core source test scripts, run required gates (`bun run test:launcher`, `bun run test:core:src`), then finalize TASK-81 notes/AC/DoD without commit. + + +## Implementation Notes + + +Planning complete. Detailed execution plan saved at docs/plans/2026-02-22-task-81-launcher-command-modules-process-adapters.md. Proceeding directly per user request to execute via writing-plans + executing-plans flow without commit. + +Implemented launcher refactor by extracting command handlers into `launcher/commands/*` (`config`, `doctor`, `mpv`, `app`, `jellyfin`, `playback`) and converting `launcher/main.ts` into thin context+dispatch orchestration. + +Added explicit process side-effect seam in `launcher/process-adapter.ts` and routed command output/exit/signal behavior through adapter-aware command modules. + +Added mocked-adapter regression tests in `launcher/commands/command-modules.test.ts` and wired that file into `test:launcher:src` + `test:core:src` scripts. + +Verification: `bun test launcher/commands/command-modules.test.ts launcher/main.test.ts launcher/parse-args.test.ts` and `bun run test:launcher && bun run test:core:src` all passing. + + +## Final Summary + + +Refactored launcher command branching into focused command modules and introduced a dedicated process adapter interface to isolate output/exit/signal side effects from orchestration code. Preserved existing CLI behavior by keeping helper flows intact, added adapter-mocked command tests, and validated compatibility with full launcher and core source test gates. + + ## Definition of Done -- [ ] #1 Launcher tests and core test gate pass -- [ ] #2 No regression in wrapper command behavior +- [x] #1 Launcher tests and core test gate pass +- [x] #2 No regression in wrapper command behavior - diff --git a/docs/subagents/agents/opencode-task81-launcher-modules-20260222T005725Z-8oh8.md b/docs/subagents/agents/opencode-task81-launcher-modules-20260222T005725Z-8oh8.md new file mode 100644 index 0000000..2472e30 --- /dev/null +++ b/docs/subagents/agents/opencode-task81-launcher-modules-20260222T005725Z-8oh8.md @@ -0,0 +1,54 @@ +# Agent Session: opencode-task81-launcher-modules-20260222T005725Z-8oh8 + +- alias: `opencode-task81-launcher-modules` +- mission: `Execute TASK-81 launcher command-module/process-adapter refactor via writing-plans + executing-plans (no commit).` +- status: `done` +- started_utc: `2026-02-22T00:57:25Z` +- last_update_utc: `2026-02-22T01:09:30Z` + +## Intent + +- Load TASK-81 from Backlog MCP and capture full implementation plan before code edits. +- Refactor launcher into focused command modules and process adapters without CLI behavior drift. +- Improve launcher test seams with adapter-mocked unit/integration tests. + +## Planned Files + +- `launcher/main.ts` +- `launcher/*.ts` +- `launcher/commands/*.ts` +- `launcher/**/*.test.ts` +- `package.json` +- `docs/development.md` + +## Files Touched + +- `docs/subagents/agents/opencode-task81-launcher-modules-20260222T005725Z-8oh8.md` +- `docs/subagents/INDEX.md` +- `docs/subagents/collaboration.md` +- `docs/plans/2026-02-22-task-81-launcher-command-modules-process-adapters.md` +- `launcher/main.ts` +- `launcher/process-adapter.ts` +- `launcher/config-path.ts` +- `launcher/commands/context.ts` +- `launcher/commands/config-command.ts` +- `launcher/commands/doctor-command.ts` +- `launcher/commands/mpv-command.ts` +- `launcher/commands/app-command.ts` +- `launcher/commands/jellyfin-command.ts` +- `launcher/commands/playback-command.ts` +- `launcher/commands/command-modules.test.ts` +- `package.json` + +## Assumptions + +- TASK-81 scope is launcher architecture and tests only; keep command UX/exit semantics stable. +- Existing TASK-73/74 changes provide stable baseline tests to preserve behavior. +- Parallel subagents can split command extraction and adapter seams where file overlap is controlled. + +## Phase Log + +- `2026-02-22T00:57:25Z` Session started; read subagent protocol docs + backlog workflow + TASK-81 details. +- `2026-02-22T01:01:30Z` Wrote TASK-81 implementation plan at `docs/plans/2026-02-22-task-81-launcher-command-modules-process-adapters.md` and recorded plan/status in Backlog MCP. +- `2026-02-22T01:07:40Z` Implemented command-module extraction + process adapter seam and rewired `launcher/main.ts` into thin dispatcher. +- `2026-02-22T01:09:30Z` Added adapter-mocked command tests and updated test scripts; verified `bun run test:launcher && bun run test:core:src` passing. diff --git a/launcher/commands/app-command.ts b/launcher/commands/app-command.ts new file mode 100644 index 0000000..62a49ac --- /dev/null +++ b/launcher/commands/app-command.ts @@ -0,0 +1,20 @@ +import { launchTexthookerOnly, runAppCommandWithInherit } from '../mpv.js'; +import type { LauncherCommandContext } from './context.js'; + +export function runAppPassthroughCommand(context: LauncherCommandContext): boolean { + const { args, appPath } = context; + if (!args.appPassthrough || !appPath) { + return false; + } + runAppCommandWithInherit(appPath, args.appArgs); + return true; +} + +export function runTexthookerCommand(context: LauncherCommandContext): boolean { + const { args, appPath } = context; + if (!args.texthookerOnly || !appPath) { + return false; + } + launchTexthookerOnly(appPath, args); + return true; +} diff --git a/launcher/commands/command-modules.test.ts b/launcher/commands/command-modules.test.ts new file mode 100644 index 0000000..f629d2d --- /dev/null +++ b/launcher/commands/command-modules.test.ts @@ -0,0 +1,90 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { parseArgs } from '../config.js'; +import type { ProcessAdapter } from '../process-adapter.js'; +import type { LauncherCommandContext } from './context.js'; +import { runConfigCommand } from './config-command.js'; +import { runDoctorCommand } from './doctor-command.js'; +import { runMpvPreAppCommand } from './mpv-command.js'; + +class ExitSignal extends Error { + code: number; + + constructor(code: number) { + super(`exit:${code}`); + this.code = code; + } +} + +function createContext(overrides: Partial = {}): LauncherCommandContext { + const args = parseArgs([], 'subminer', {}); + const adapter: ProcessAdapter = { + platform: () => 'linux', + onSignal: () => {}, + writeStdout: () => {}, + exit: (code) => { + throw new ExitSignal(code); + }, + setExitCode: () => {}, + }; + + return { + args, + scriptPath: '/tmp/subminer', + scriptName: 'subminer', + mpvSocketPath: '/tmp/subminer.sock', + appPath: '/tmp/subminer.app', + launcherJellyfinConfig: {}, + processAdapter: adapter, + ...overrides, + }; +} + +test('config command writes newline-terminated path via process adapter', () => { + const writes: string[] = []; + const context = createContext(); + context.args.configPath = true; + context.processAdapter = { + ...context.processAdapter, + writeStdout: (text) => writes.push(text), + }; + + const handled = runConfigCommand(context, { + existsSync: () => true, + readFileSync: () => '', + resolveMainConfigPath: () => '/tmp/SubMiner/config.jsonc', + }); + + assert.equal(handled, true); + assert.deepEqual(writes, ['/tmp/SubMiner/config.jsonc\n']); +}); + +test('doctor command exits non-zero for missing hard dependencies', () => { + const context = createContext({ appPath: null }); + context.args.doctor = true; + + assert.throws( + () => + runDoctorCommand(context, { + commandExists: () => false, + configExists: () => true, + resolveMainConfigPath: () => '/tmp/SubMiner/config.jsonc', + }), + (error: unknown) => error instanceof ExitSignal && error.code === 1, + ); +}); + +test('mpv pre-app command exits non-zero when socket is not ready', async () => { + const context = createContext(); + context.args.mpvStatus = true; + + await assert.rejects( + async () => { + await runMpvPreAppCommand(context, { + waitForUnixSocketReady: async () => false, + launchMpvIdleDetached: async () => {}, + }); + }, + (error: unknown) => error instanceof ExitSignal && error.code === 1, + ); +}); diff --git a/launcher/commands/config-command.ts b/launcher/commands/config-command.ts new file mode 100644 index 0000000..b4faabe --- /dev/null +++ b/launcher/commands/config-command.ts @@ -0,0 +1,43 @@ +import fs from 'node:fs'; +import { fail } from '../log.js'; +import { resolveMainConfigPath } from '../config-path.js'; +import type { LauncherCommandContext } from './context.js'; + +interface ConfigCommandDeps { + existsSync(path: string): boolean; + readFileSync(path: string, encoding: BufferEncoding): string; + resolveMainConfigPath(): string; +} + +const defaultDeps: ConfigCommandDeps = { + existsSync: fs.existsSync, + readFileSync: fs.readFileSync, + resolveMainConfigPath, +}; + +export function runConfigCommand( + context: LauncherCommandContext, + deps: ConfigCommandDeps = defaultDeps, +): boolean { + const { args, processAdapter } = context; + if (args.configPath) { + processAdapter.writeStdout(`${deps.resolveMainConfigPath()}\n`); + return true; + } + + if (!args.configShow) { + return false; + } + + const configPath = deps.resolveMainConfigPath(); + if (!deps.existsSync(configPath)) { + fail(`Config file not found: ${configPath}`); + } + + const contents = deps.readFileSync(configPath, 'utf8'); + processAdapter.writeStdout(contents); + if (!contents.endsWith('\n')) { + processAdapter.writeStdout('\n'); + } + return true; +} diff --git a/launcher/commands/context.ts b/launcher/commands/context.ts new file mode 100644 index 0000000..44db33d --- /dev/null +++ b/launcher/commands/context.ts @@ -0,0 +1,12 @@ +import type { Args, LauncherJellyfinConfig } from '../types.js'; +import type { ProcessAdapter } from '../process-adapter.js'; + +export interface LauncherCommandContext { + args: Args; + scriptPath: string; + scriptName: string; + mpvSocketPath: string; + appPath: string | null; + launcherJellyfinConfig: LauncherJellyfinConfig; + processAdapter: ProcessAdapter; +} diff --git a/launcher/commands/doctor-command.ts b/launcher/commands/doctor-command.ts new file mode 100644 index 0000000..b070ab9 --- /dev/null +++ b/launcher/commands/doctor-command.ts @@ -0,0 +1,85 @@ +import fs from 'node:fs'; +import { log } from '../log.js'; +import { commandExists } from '../util.js'; +import { resolveMainConfigPath } from '../config-path.js'; +import type { LauncherCommandContext } from './context.js'; + +interface DoctorCommandDeps { + commandExists(command: string): boolean; + configExists(path: string): boolean; + resolveMainConfigPath(): string; +} + +const defaultDeps: DoctorCommandDeps = { + commandExists, + configExists: fs.existsSync, + resolveMainConfigPath, +}; + +export function runDoctorCommand( + context: LauncherCommandContext, + deps: DoctorCommandDeps = defaultDeps, +): boolean { + const { args, appPath, mpvSocketPath, processAdapter } = context; + if (!args.doctor) { + return false; + } + + const configPath = deps.resolveMainConfigPath(); + const mpvFound = deps.commandExists('mpv'); + const checks: Array<{ label: string; ok: boolean; detail: string }> = [ + { + label: 'app binary', + ok: Boolean(appPath), + detail: appPath || 'not found (set SUBMINER_APPIMAGE_PATH)', + }, + { + label: 'mpv', + ok: mpvFound, + detail: mpvFound ? 'found' : 'missing', + }, + { + label: 'yt-dlp', + ok: deps.commandExists('yt-dlp'), + detail: deps.commandExists('yt-dlp') ? 'found' : 'missing (optional unless YouTube URLs)', + }, + { + label: 'ffmpeg', + ok: deps.commandExists('ffmpeg'), + detail: deps.commandExists('ffmpeg') + ? 'found' + : 'missing (optional unless subtitle generation)', + }, + { + label: 'fzf', + ok: deps.commandExists('fzf'), + detail: deps.commandExists('fzf') ? 'found' : 'missing (optional if using rofi)', + }, + { + label: 'rofi', + ok: deps.commandExists('rofi'), + detail: deps.commandExists('rofi') ? 'found' : 'missing (optional if using fzf)', + }, + { + label: 'config', + ok: deps.configExists(configPath), + detail: configPath, + }, + { + label: 'mpv socket path', + ok: true, + detail: mpvSocketPath, + }, + ]; + + const hasHardFailure = checks.some((entry) => + entry.label === 'app binary' || entry.label === 'mpv' ? !entry.ok : false, + ); + + for (const check of checks) { + log(check.ok ? 'info' : 'warn', args.logLevel, `[doctor] ${check.label}: ${check.detail}`); + } + + processAdapter.exit(hasHardFailure ? 1 : 0); + return true; +} diff --git a/launcher/commands/jellyfin-command.ts b/launcher/commands/jellyfin-command.ts new file mode 100644 index 0000000..d05d22a --- /dev/null +++ b/launcher/commands/jellyfin-command.ts @@ -0,0 +1,71 @@ +import { fail } from '../log.js'; +import { runAppCommandWithInherit } from '../mpv.js'; +import { commandExists } from '../util.js'; +import { runJellyfinPlayMenu } from '../jellyfin.js'; +import type { LauncherCommandContext } from './context.js'; + +export async function runJellyfinCommand(context: LauncherCommandContext): Promise { + const { args, appPath, scriptPath, mpvSocketPath, launcherJellyfinConfig } = context; + if (!appPath) { + return false; + } + + if (args.jellyfin) { + const forwarded = ['--jellyfin']; + if (args.logLevel !== 'info') forwarded.push('--log-level', args.logLevel); + runAppCommandWithInherit(appPath, forwarded); + } + + if (args.jellyfinLogin) { + const serverUrl = args.jellyfinServer || launcherJellyfinConfig.serverUrl || ''; + const username = args.jellyfinUsername || launcherJellyfinConfig.username || ''; + const password = args.jellyfinPassword || ''; + if (!serverUrl || !username || !password) { + fail( + '--jellyfin-login requires server, username, and password. Pass flags or run `subminer --jellyfin`.', + ); + } + const forwarded = [ + '--jellyfin-login', + '--jellyfin-server', + serverUrl, + '--jellyfin-username', + username, + '--jellyfin-password', + password, + ]; + if (args.logLevel !== 'info') forwarded.push('--log-level', args.logLevel); + runAppCommandWithInherit(appPath, forwarded); + } + + if (args.jellyfinLogout) { + const forwarded = ['--jellyfin-logout']; + if (args.logLevel !== 'info') forwarded.push('--log-level', args.logLevel); + runAppCommandWithInherit(appPath, forwarded); + } + + if (args.jellyfinPlay) { + if (!args.useRofi && !commandExists('fzf')) { + fail('fzf not found. Install fzf or use -R for rofi.'); + } + if (args.useRofi && !commandExists('rofi')) { + fail('rofi not found. Install rofi or omit -R for fzf.'); + } + await runJellyfinPlayMenu(appPath, args, scriptPath, mpvSocketPath); + return true; + } + + if (args.jellyfinDiscovery) { + const forwarded = ['--start']; + if (args.logLevel !== 'info') forwarded.push('--log-level', args.logLevel); + runAppCommandWithInherit(appPath, forwarded); + } + + return Boolean( + args.jellyfin || + args.jellyfinLogin || + args.jellyfinLogout || + args.jellyfinPlay || + args.jellyfinDiscovery, + ); +} diff --git a/launcher/commands/mpv-command.ts b/launcher/commands/mpv-command.ts new file mode 100644 index 0000000..96445a9 --- /dev/null +++ b/launcher/commands/mpv-command.ts @@ -0,0 +1,62 @@ +import { fail, log } from '../log.js'; +import { waitForUnixSocketReady, launchMpvIdleDetached } from '../mpv.js'; +import type { LauncherCommandContext } from './context.js'; + +interface MpvCommandDeps { + waitForUnixSocketReady(socketPath: string, timeoutMs: number): Promise; + launchMpvIdleDetached( + socketPath: string, + appPath: string, + args: LauncherCommandContext['args'], + ): Promise; +} + +const defaultDeps: MpvCommandDeps = { + waitForUnixSocketReady, + launchMpvIdleDetached, +}; + +export async function runMpvPreAppCommand( + context: LauncherCommandContext, + deps: MpvCommandDeps = defaultDeps, +): Promise { + const { args, mpvSocketPath, processAdapter } = context; + if (args.mpvSocket) { + processAdapter.writeStdout(`${mpvSocketPath}\n`); + return true; + } + + if (!args.mpvStatus) { + return false; + } + + const ready = await deps.waitForUnixSocketReady(mpvSocketPath, 500); + log( + ready ? 'info' : 'warn', + args.logLevel, + `[mpv] socket ${ready ? 'ready' : 'not ready'}: ${mpvSocketPath}`, + ); + processAdapter.exit(ready ? 0 : 1); + return true; +} + +export async function runMpvPostAppCommand( + context: LauncherCommandContext, + deps: MpvCommandDeps = defaultDeps, +): Promise { + const { args, appPath, mpvSocketPath } = context; + if (!args.mpvIdle) { + return false; + } + if (!appPath) { + fail('SubMiner app binary not found. Install to ~/.local/bin/ or set SUBMINER_APPIMAGE_PATH.'); + } + + await deps.launchMpvIdleDetached(mpvSocketPath, appPath, args); + const ready = await deps.waitForUnixSocketReady(mpvSocketPath, 8000); + if (!ready) { + fail(`MPV IPC socket not ready after idle launch: ${mpvSocketPath}`); + } + log('info', args.logLevel, `[mpv] idle instance ready on ${mpvSocketPath}`); + return true; +} diff --git a/launcher/commands/playback-command.ts b/launcher/commands/playback-command.ts new file mode 100644 index 0000000..e1c28eb --- /dev/null +++ b/launcher/commands/playback-command.ts @@ -0,0 +1,208 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { fail, log } from '../log.js'; +import { commandExists, isYoutubeTarget, realpathMaybe, resolvePathMaybe } from '../util.js'; +import { collectVideos, showFzfMenu, showRofiMenu } from '../picker.js'; +import { + loadSubtitleIntoMpv, + startMpv, + startOverlay, + state, + stopOverlay, + waitForUnixSocketReady, +} from '../mpv.js'; +import { generateYoutubeSubtitles } from '../youtube.js'; +import type { Args } from '../types.js'; +import type { LauncherCommandContext } from './context.js'; + +function checkDependencies(args: Args): void { + const missing: string[] = []; + + if (!commandExists('mpv')) missing.push('mpv'); + + if (args.targetKind === 'url' && isYoutubeTarget(args.target) && !commandExists('yt-dlp')) { + missing.push('yt-dlp'); + } + + if ( + args.targetKind === 'url' && + isYoutubeTarget(args.target) && + args.youtubeSubgenMode !== 'off' && + !commandExists('ffmpeg') + ) { + missing.push('ffmpeg'); + } + + if (missing.length > 0) fail(`Missing dependencies: ${missing.join(' ')}`); +} + +function checkPickerDependencies(args: Args): void { + if (args.useRofi) { + if (!commandExists('rofi')) fail('Missing dependency: rofi'); + return; + } + + if (!commandExists('fzf')) fail('Missing dependency: fzf'); +} + +async function chooseTarget( + args: Args, + scriptPath: string, +): Promise<{ target: string; kind: 'file' | 'url' } | null> { + if (args.target) { + return { target: args.target, kind: args.targetKind as 'file' | 'url' }; + } + + const searchDir = realpathMaybe(resolvePathMaybe(args.directory)); + if (!fs.existsSync(searchDir) || !fs.statSync(searchDir).isDirectory()) { + fail(`Directory not found: ${searchDir}`); + } + + const videos = collectVideos(searchDir, args.recursive); + if (videos.length === 0) { + fail(`No video files found in: ${searchDir}`); + } + + log('info', args.logLevel, `Browsing: ${searchDir} (${videos.length} videos found)`); + + const selected = args.useRofi + ? showRofiMenu(videos, searchDir, args.recursive, scriptPath, args.logLevel) + : showFzfMenu(videos); + + if (!selected) return null; + return { target: selected, kind: 'file' }; +} + +function registerCleanup(context: LauncherCommandContext): void { + const { args, processAdapter } = context; + processAdapter.onSignal('SIGINT', () => { + stopOverlay(args); + processAdapter.exit(130); + }); + processAdapter.onSignal('SIGTERM', () => { + stopOverlay(args); + processAdapter.exit(143); + }); +} + +export async function runPlaybackCommand(context: LauncherCommandContext): Promise { + const { args, appPath, scriptPath, mpvSocketPath, processAdapter } = context; + if (!appPath) { + fail('SubMiner AppImage not found. Install to ~/.local/bin/ or set SUBMINER_APPIMAGE_PATH.'); + } + + if (!args.target) { + checkPickerDependencies(args); + } + + const targetChoice = await chooseTarget(args, scriptPath); + if (!targetChoice) { + log('info', args.logLevel, 'No video selected, exiting'); + processAdapter.exit(0); + } + + checkDependencies({ + ...args, + target: targetChoice ? targetChoice.target : args.target, + targetKind: targetChoice ? targetChoice.kind : 'url', + }); + + registerCleanup(context); + + const selectedTarget = targetChoice + ? { + target: targetChoice.target, + kind: targetChoice.kind as 'file' | 'url', + } + : { target: args.target, kind: 'url' as const }; + + const isYoutubeUrl = selectedTarget.kind === 'url' && isYoutubeTarget(selectedTarget.target); + let preloadedSubtitles: { primaryPath?: string; secondaryPath?: string } | undefined; + + if (isYoutubeUrl && args.youtubeSubgenMode === 'preprocess') { + log('info', args.logLevel, 'YouTube subtitle mode: preprocess'); + const generated = await generateYoutubeSubtitles(selectedTarget.target, args); + preloadedSubtitles = { + primaryPath: generated.primaryPath, + secondaryPath: generated.secondaryPath, + }; + log( + 'info', + args.logLevel, + `YouTube preprocess result: primary=${generated.primaryPath ? 'ready' : 'missing'}, secondary=${generated.secondaryPath ? 'ready' : 'missing'}`, + ); + } else if (isYoutubeUrl && args.youtubeSubgenMode === 'automatic') { + log('info', args.logLevel, 'YouTube subtitle mode: automatic (background)'); + } else if (isYoutubeUrl) { + log('info', args.logLevel, 'YouTube subtitle mode: off'); + } + + startMpv( + selectedTarget.target, + selectedTarget.kind, + args, + mpvSocketPath, + appPath, + preloadedSubtitles, + ); + + if (isYoutubeUrl && args.youtubeSubgenMode === 'automatic') { + void generateYoutubeSubtitles(selectedTarget.target, args, async (lang, subtitlePath) => { + try { + await loadSubtitleIntoMpv(mpvSocketPath, subtitlePath, lang === 'primary', args.logLevel); + } catch (error) { + log( + 'warn', + args.logLevel, + `Generated subtitle ready but failed to load in mpv: ${(error as Error).message}`, + ); + } + }).catch((error) => { + log( + 'warn', + args.logLevel, + `Background subtitle generation failed: ${(error as Error).message}`, + ); + }); + } + + const ready = await waitForUnixSocketReady(mpvSocketPath, 10000); + const shouldStartOverlay = args.startOverlay || args.autoStartOverlay; + if (shouldStartOverlay) { + if (ready) { + log('info', args.logLevel, 'MPV IPC socket ready, starting SubMiner overlay'); + } else { + log( + 'info', + args.logLevel, + 'MPV IPC socket not ready after timeout, starting SubMiner overlay anyway', + ); + } + await startOverlay(appPath, args, mpvSocketPath); + } else if (ready) { + log( + 'info', + args.logLevel, + 'MPV IPC socket ready, overlay auto-start disabled (use y-s to start)', + ); + } else { + log( + 'info', + args.logLevel, + 'MPV IPC socket not ready yet, overlay auto-start disabled (use y-s to start)', + ); + } + + await new Promise((resolve) => { + if (!state.mpvProc) { + stopOverlay(args); + resolve(); + return; + } + state.mpvProc.on('exit', (code) => { + stopOverlay(args); + processAdapter.setExitCode(code ?? 0); + resolve(); + }); + }); +} diff --git a/launcher/config-path.ts b/launcher/config-path.ts new file mode 100644 index 0000000..33786af --- /dev/null +++ b/launcher/config-path.ts @@ -0,0 +1,11 @@ +import fs from 'node:fs'; +import os from 'node:os'; +import { resolveConfigFilePath } from '../src/config/path-resolution.js'; + +export function resolveMainConfigPath(): string { + return resolveConfigFilePath({ + xdgConfigHome: process.env.XDG_CONFIG_HOME, + homeDir: os.homedir(), + existsSync: fs.existsSync, + }); +} diff --git a/launcher/main.ts b/launcher/main.ts index 765b637..197c32f 100644 --- a/launcher/main.ts +++ b/launcher/main.ts @@ -1,404 +1,98 @@ -import fs from 'node:fs'; import path from 'node:path'; -import os from 'node:os'; -import { resolveConfigFilePath } from '../src/config/path-resolution.js'; -import type { Args } from './types.js'; -import { log, fail } from './log.js'; -import { commandExists, isYoutubeTarget, resolvePathMaybe, realpathMaybe } from './util.js'; import { - parseArgs, - loadLauncherYoutubeSubgenConfig, loadLauncherJellyfinConfig, + loadLauncherYoutubeSubgenConfig, + parseArgs, readPluginRuntimeConfig, } from './config.js'; -import { showRofiMenu, showFzfMenu, collectVideos } from './picker.js'; -import { - state, - startMpv, - startOverlay, - stopOverlay, - launchTexthookerOnly, - findAppBinary, - loadSubtitleIntoMpv, - runAppCommandWithInherit, - launchMpvIdleDetached, - waitForUnixSocketReady, -} from './mpv.js'; -import { generateYoutubeSubtitles } from './youtube.js'; -import { runJellyfinPlayMenu } from './jellyfin.js'; +import { fail, log } from './log.js'; +import { findAppBinary, state } from './mpv.js'; +import { nodeProcessAdapter } from './process-adapter.js'; +import type { LauncherCommandContext } from './commands/context.js'; +import { runDoctorCommand } from './commands/doctor-command.js'; +import { runConfigCommand } from './commands/config-command.js'; +import { runMpvPostAppCommand, runMpvPreAppCommand } from './commands/mpv-command.js'; +import { runAppPassthroughCommand, runTexthookerCommand } from './commands/app-command.js'; +import { runJellyfinCommand } from './commands/jellyfin-command.js'; +import { runPlaybackCommand } from './commands/playback-command.js'; -function checkDependencies(args: Args): void { - const missing: string[] = []; - - if (!commandExists('mpv')) missing.push('mpv'); - - if (args.targetKind === 'url' && isYoutubeTarget(args.target) && !commandExists('yt-dlp')) { - missing.push('yt-dlp'); - } - - if ( - args.targetKind === 'url' && - isYoutubeTarget(args.target) && - args.youtubeSubgenMode !== 'off' && - !commandExists('ffmpeg') - ) { - missing.push('ffmpeg'); - } - - if (missing.length > 0) fail(`Missing dependencies: ${missing.join(' ')}`); -} - -function checkPickerDependencies(args: Args): void { - if (args.useRofi) { - if (!commandExists('rofi')) fail('Missing dependency: rofi'); - return; - } - - if (!commandExists('fzf')) fail('Missing dependency: fzf'); -} - -async function chooseTarget( - args: Args, +function createCommandContext( + args: ReturnType, scriptPath: string, -): Promise<{ target: string; kind: 'file' | 'url' } | null> { - if (args.target) { - return { target: args.target, kind: args.targetKind as 'file' | 'url' }; - } - - const searchDir = realpathMaybe(resolvePathMaybe(args.directory)); - if (!fs.existsSync(searchDir) || !fs.statSync(searchDir).isDirectory()) { - fail(`Directory not found: ${searchDir}`); - } - - const videos = collectVideos(searchDir, args.recursive); - if (videos.length === 0) { - fail(`No video files found in: ${searchDir}`); - } - - log('info', args.logLevel, `Browsing: ${searchDir} (${videos.length} videos found)`); - - const selected = args.useRofi - ? showRofiMenu(videos, searchDir, args.recursive, scriptPath, args.logLevel) - : showFzfMenu(videos); - - if (!selected) return null; - return { target: selected, kind: 'file' }; + mpvSocketPath: string, + appPath: string | null, +): LauncherCommandContext { + return { + args, + scriptPath, + scriptName: path.basename(scriptPath), + mpvSocketPath, + appPath, + launcherJellyfinConfig: loadLauncherJellyfinConfig(), + processAdapter: nodeProcessAdapter, + }; } -function registerCleanup(args: Args): void { - process.on('SIGINT', () => { - stopOverlay(args); - process.exit(130); - }); - process.on('SIGTERM', () => { - stopOverlay(args); - process.exit(143); - }); -} - -function resolveMainConfigPath(): string { - return resolveConfigFilePath({ - xdgConfigHome: process.env.XDG_CONFIG_HOME, - homeDir: os.homedir(), - existsSync: fs.existsSync, - }); -} - -function runDoctor(args: Args, appPath: string | null, mpvSocketPath: string): never { - const checks: Array<{ label: string; ok: boolean; detail: string }> = [ - { - label: 'app binary', - ok: Boolean(appPath), - detail: appPath || 'not found (set SUBMINER_APPIMAGE_PATH)', - }, - { - label: 'mpv', - ok: commandExists('mpv'), - detail: commandExists('mpv') ? 'found' : 'missing', - }, - { - label: 'yt-dlp', - ok: commandExists('yt-dlp'), - detail: commandExists('yt-dlp') ? 'found' : 'missing (optional unless YouTube URLs)', - }, - { - label: 'ffmpeg', - ok: commandExists('ffmpeg'), - detail: commandExists('ffmpeg') ? 'found' : 'missing (optional unless subtitle generation)', - }, - { - label: 'fzf', - ok: commandExists('fzf'), - detail: commandExists('fzf') ? 'found' : 'missing (optional if using rofi)', - }, - { - label: 'rofi', - ok: commandExists('rofi'), - detail: commandExists('rofi') ? 'found' : 'missing (optional if using fzf)', - }, - { - label: 'config', - ok: fs.existsSync(resolveMainConfigPath()), - detail: resolveMainConfigPath(), - }, - { - label: 'mpv socket path', - ok: true, - detail: mpvSocketPath, - }, - ]; - - const hasHardFailure = checks.some((entry) => - entry.label === 'app binary' || entry.label === 'mpv' ? !entry.ok : false, - ); - - for (const check of checks) { - log(check.ok ? 'info' : 'warn', args.logLevel, `[doctor] ${check.label}: ${check.detail}`); +function ensureAppPath(context: LauncherCommandContext): string { + if (context.appPath) { + return context.appPath; } - process.exit(hasHardFailure ? 1 : 0); + if (context.processAdapter.platform() === 'darwin') { + fail( + 'SubMiner app binary not found. Install SubMiner.app to /Applications or ~/Applications, or set SUBMINER_APPIMAGE_PATH.', + ); + } + fail('SubMiner AppImage not found. Install to ~/.local/bin/ or set SUBMINER_APPIMAGE_PATH.'); } async function main(): Promise { const scriptPath = process.argv[1] || 'subminer'; const scriptName = path.basename(scriptPath); const launcherConfig = loadLauncherYoutubeSubgenConfig(); - const launcherJellyfinConfig = loadLauncherJellyfinConfig(); const args = parseArgs(process.argv.slice(2), scriptName, launcherConfig); const pluginRuntimeConfig = readPluginRuntimeConfig(args.logLevel); - const mpvSocketPath = pluginRuntimeConfig.socketPath; + const appPath = findAppBinary(scriptPath); log('debug', args.logLevel, `Wrapper log level set to: ${args.logLevel}`); - const appPath = findAppBinary(process.argv[1] || 'subminer'); - if (args.doctor) { - runDoctor(args, appPath, mpvSocketPath); - } + const context = createCommandContext(args, scriptPath, pluginRuntimeConfig.socketPath, appPath); - if (args.configPath) { - process.stdout.write(`${resolveMainConfigPath()}\n`); + if (runDoctorCommand(context)) { return; } - if (args.configShow) { - const configPath = resolveMainConfigPath(); - if (!fs.existsSync(configPath)) { - fail(`Config file not found: ${configPath}`); - } - const contents = fs.readFileSync(configPath, 'utf8'); - process.stdout.write(contents); - if (!contents.endsWith('\n')) { - process.stdout.write('\n'); - } + if (runConfigCommand(context)) { return; } - if (args.mpvSocket) { - process.stdout.write(`${mpvSocketPath}\n`); + if (await runMpvPreAppCommand(context)) { return; } - if (args.mpvStatus) { - const ready = await waitForUnixSocketReady(mpvSocketPath, 500); - log( - ready ? 'info' : 'warn', - args.logLevel, - `[mpv] socket ${ready ? 'ready' : 'not ready'}: ${mpvSocketPath}`, - ); - process.exit(ready ? 0 : 1); - } + const resolvedAppPath = ensureAppPath(context); + state.appPath = resolvedAppPath; + const appContext: LauncherCommandContext = { + ...context, + appPath: resolvedAppPath, + }; - if (!appPath) { - if (process.platform === 'darwin') { - fail( - 'SubMiner app binary not found. Install SubMiner.app to /Applications or ~/Applications, or set SUBMINER_APPIMAGE_PATH.', - ); - } - fail('SubMiner AppImage not found. Install to ~/.local/bin/ or set SUBMINER_APPIMAGE_PATH.'); - } - state.appPath = appPath; - - if (args.appPassthrough) { - runAppCommandWithInherit(appPath, args.appArgs); - } - - if (args.mpvIdle) { - await launchMpvIdleDetached(mpvSocketPath, appPath, args); - const ready = await waitForUnixSocketReady(mpvSocketPath, 8000); - if (!ready) { - fail(`MPV IPC socket not ready after idle launch: ${mpvSocketPath}`); - } - log('info', args.logLevel, `[mpv] idle instance ready on ${mpvSocketPath}`); + if (runAppPassthroughCommand(appContext)) { return; } - if (args.texthookerOnly) { - launchTexthookerOnly(appPath, args); + if (await runMpvPostAppCommand(appContext)) { + return; } - if (args.jellyfin) { - const forwarded = ['--jellyfin']; - if (args.logLevel !== 'info') forwarded.push('--log-level', args.logLevel); - runAppCommandWithInherit(appPath, forwarded); + if (runTexthookerCommand(appContext)) { + return; } - if (args.jellyfinLogin) { - const serverUrl = args.jellyfinServer || launcherJellyfinConfig.serverUrl || ''; - const username = args.jellyfinUsername || launcherJellyfinConfig.username || ''; - const password = args.jellyfinPassword || ''; - if (!serverUrl || !username || !password) { - fail( - '--jellyfin-login requires server, username, and password. Pass flags or run `subminer --jellyfin`.', - ); - } - const forwarded = [ - '--jellyfin-login', - '--jellyfin-server', - serverUrl, - '--jellyfin-username', - username, - '--jellyfin-password', - password, - ]; - if (args.logLevel !== 'info') forwarded.push('--log-level', args.logLevel); - runAppCommandWithInherit(appPath, forwarded); + if (await runJellyfinCommand(appContext)) { + return; } - if (args.jellyfinLogout) { - const forwarded = ['--jellyfin-logout']; - if (args.logLevel !== 'info') forwarded.push('--log-level', args.logLevel); - runAppCommandWithInherit(appPath, forwarded); - } - - if (args.jellyfinPlay) { - if (!args.useRofi && !commandExists('fzf')) { - fail('fzf not found. Install fzf or use -R for rofi.'); - } - if (args.useRofi && !commandExists('rofi')) { - fail('rofi not found. Install rofi or omit -R for fzf.'); - } - await runJellyfinPlayMenu(appPath, args, scriptPath, mpvSocketPath); - } - - if (args.jellyfinDiscovery) { - const forwarded = ['--start']; - if (args.logLevel !== 'info') forwarded.push('--log-level', args.logLevel); - runAppCommandWithInherit(appPath, forwarded); - } - - if (!args.target) { - checkPickerDependencies(args); - } - - const targetChoice = await chooseTarget(args, process.argv[1] || 'subminer'); - if (!targetChoice) { - log('info', args.logLevel, 'No video selected, exiting'); - process.exit(0); - } - - checkDependencies({ - ...args, - target: targetChoice ? targetChoice.target : args.target, - targetKind: targetChoice ? targetChoice.kind : 'url', - }); - - registerCleanup(args); - - let selectedTarget = targetChoice - ? { - target: targetChoice.target, - kind: targetChoice.kind as 'file' | 'url', - } - : { target: args.target, kind: 'url' as const }; - - const isYoutubeUrl = selectedTarget.kind === 'url' && isYoutubeTarget(selectedTarget.target); - let preloadedSubtitles: { primaryPath?: string; secondaryPath?: string } | undefined; - - if (isYoutubeUrl && args.youtubeSubgenMode === 'preprocess') { - log('info', args.logLevel, 'YouTube subtitle mode: preprocess'); - const generated = await generateYoutubeSubtitles(selectedTarget.target, args); - preloadedSubtitles = { - primaryPath: generated.primaryPath, - secondaryPath: generated.secondaryPath, - }; - log( - 'info', - args.logLevel, - `YouTube preprocess result: primary=${generated.primaryPath ? 'ready' : 'missing'}, secondary=${generated.secondaryPath ? 'ready' : 'missing'}`, - ); - } else if (isYoutubeUrl && args.youtubeSubgenMode === 'automatic') { - log('info', args.logLevel, 'YouTube subtitle mode: automatic (background)'); - } else if (isYoutubeUrl) { - log('info', args.logLevel, 'YouTube subtitle mode: off'); - } - - startMpv( - selectedTarget.target, - selectedTarget.kind, - args, - mpvSocketPath, - appPath, - preloadedSubtitles, - ); - - if (isYoutubeUrl && args.youtubeSubgenMode === 'automatic') { - void generateYoutubeSubtitles(selectedTarget.target, args, async (lang, subtitlePath) => { - try { - await loadSubtitleIntoMpv(mpvSocketPath, subtitlePath, lang === 'primary', args.logLevel); - } catch (error) { - log( - 'warn', - args.logLevel, - `Generated subtitle ready but failed to load in mpv: ${(error as Error).message}`, - ); - } - }).catch((error) => { - log( - 'warn', - args.logLevel, - `Background subtitle generation failed: ${(error as Error).message}`, - ); - }); - } - - const ready = await waitForUnixSocketReady(mpvSocketPath, 10000); - const shouldStartOverlay = args.startOverlay || args.autoStartOverlay; - if (shouldStartOverlay) { - if (ready) { - log('info', args.logLevel, 'MPV IPC socket ready, starting SubMiner overlay'); - } else { - log( - 'info', - args.logLevel, - 'MPV IPC socket not ready after timeout, starting SubMiner overlay anyway', - ); - } - await startOverlay(appPath, args, mpvSocketPath); - } else if (ready) { - log( - 'info', - args.logLevel, - 'MPV IPC socket ready, overlay auto-start disabled (use y-s to start)', - ); - } else { - log( - 'info', - args.logLevel, - 'MPV IPC socket not ready yet, overlay auto-start disabled (use y-s to start)', - ); - } - - await new Promise((resolve) => { - if (!state.mpvProc) { - stopOverlay(args); - resolve(); - return; - } - state.mpvProc.on('exit', (code) => { - stopOverlay(args); - process.exitCode = code ?? 0; - resolve(); - }); - }); + await runPlaybackCommand(appContext); } main().catch((error: unknown) => { diff --git a/launcher/process-adapter.ts b/launcher/process-adapter.ts new file mode 100644 index 0000000..58b3ff6 --- /dev/null +++ b/launcher/process-adapter.ts @@ -0,0 +1,21 @@ +export interface ProcessAdapter { + platform(): NodeJS.Platform; + onSignal(signal: NodeJS.Signals, handler: () => void): void; + writeStdout(text: string): void; + exit(code: number): never; + setExitCode(code: number): void; +} + +export const nodeProcessAdapter: ProcessAdapter = { + platform: () => process.platform, + onSignal: (signal, handler) => { + process.on(signal, handler); + }, + writeStdout: (text) => { + process.stdout.write(text); + }, + exit: (code) => process.exit(code), + setExitCode: (code) => { + process.exitCode = code; + }, +}; diff --git a/package.json b/package.json index 0df29ba..455fa4b 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/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/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", + "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/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: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",