From 7d8d2ae7a77e6320dba08243307cacb8b7605e91 Mon Sep 17 00:00:00 2001 From: sudacode Date: Sun, 22 Mar 2026 18:38:54 -0700 Subject: [PATCH] refactor: unify cli and runtime wiring for startup and youtube flow --- launcher/aniskip-metadata.ts | 2 + launcher/commands/command-modules.test.ts | 52 ++-- launcher/commands/dictionary-command.ts | 4 +- launcher/commands/doctor-command.ts | 3 +- launcher/commands/jellyfin-command.ts | 4 + launcher/commands/playback-command.test.ts | 113 +++++++++ launcher/commands/playback-command.ts | 113 +++++---- launcher/config/args-normalizer.ts | 4 + launcher/config/cli-parser-builder.ts | 6 + launcher/log.test.ts | 22 +- launcher/log.ts | 41 +++- launcher/main.test.ts | 202 ++++++--------- launcher/mpv.test.ts | 46 +++- launcher/mpv.ts | 231 ++++++++++++------ launcher/parse-args.test.ts | 7 + launcher/smoke.e2e.test.ts | 2 +- launcher/types.ts | 29 ++- package.json | 2 +- plugin/subminer/options.lua | 1 + plugin/subminer/process.lua | 43 +++- src/cli/args.test.ts | 9 + src/cli/args.ts | 23 +- src/cli/help.ts | 2 + src/core/services/cli-command.test.ts | 24 ++ src/core/services/cli-command.ts | 20 ++ src/core/services/ipc.test.ts | 6 + src/core/services/ipc.ts | 14 ++ src/logger.test.ts | 4 +- src/logger.ts | 32 +-- src/main/cli-runtime.ts | 15 +- src/main/dependencies.ts | 20 +- src/main/overlay-runtime.test.ts | 106 ++++++++ src/main/overlay-runtime.ts | 101 +++++++- .../runtime/cli-command-context-deps.test.ts | 3 + src/main/runtime/cli-command-context-deps.ts | 2 + .../cli-command-context-factory.test.ts | 2 + .../cli-command-context-main-deps.test.ts | 4 + .../runtime/cli-command-context-main-deps.ts | 7 + src/main/runtime/cli-command-context.test.ts | 1 + src/main/runtime/cli-command-context.ts | 7 + .../composers/ipc-runtime-composer.test.ts | 1 + .../composers/mpv-runtime-composer.test.ts | 6 + .../runtime/mpv-main-event-main-deps.test.ts | 1 + src/main/runtime/mpv-main-event-main-deps.ts | 1 + src/main/runtime/windows-mpv-launch.ts | 10 +- src/main/state.ts | 3 + src/shared/ipc/contracts.ts | 5 + src/shared/ipc/validators.ts | 23 ++ 48 files changed, 1009 insertions(+), 370 deletions(-) create mode 100644 launcher/commands/playback-command.test.ts diff --git a/launcher/aniskip-metadata.ts b/launcher/aniskip-metadata.ts index 047b03e..5f8ed2d 100644 --- a/launcher/aniskip-metadata.ts +++ b/launcher/aniskip-metadata.ts @@ -553,10 +553,12 @@ export function buildSubminerScriptOpts( socketPath: string, aniSkipMetadata: AniSkipMetadata | null, logLevel: LogLevel = 'info', + extraParts: string[] = [], ): string { const parts = [ `subminer-binary_path=${sanitizeScriptOptValue(appPath)}`, `subminer-socket_path=${sanitizeScriptOptValue(socketPath)}`, + ...extraParts, ]; if (logLevel !== 'info') { parts.push(`subminer-log_level=${sanitizeScriptOptValue(logLevel)}`); diff --git a/launcher/commands/command-modules.test.ts b/launcher/commands/command-modules.test.ts index 5844d52..751f1ac 100644 --- a/launcher/commands/command-modules.test.ts +++ b/launcher/commands/command-modules.test.ts @@ -149,20 +149,16 @@ test('doctor command forwards refresh-known-words to app binary', () => { context.args.doctorRefreshKnownWords = true; const forwarded: string[][] = []; - assert.throws( - () => - runDoctorCommand(context, { - commandExists: () => false, - configExists: () => true, - resolveMainConfigPath: () => '/tmp/SubMiner/config.jsonc', - runAppCommandWithInherit: (_appPath, appArgs) => { - forwarded.push(appArgs); - throw new ExitSignal(0); - }, - }), - (error: unknown) => error instanceof ExitSignal && error.code === 0, - ); + const handled = runDoctorCommand(context, { + commandExists: () => false, + configExists: () => true, + resolveMainConfigPath: () => '/tmp/SubMiner/config.jsonc', + runAppCommandWithInherit: (_appPath, appArgs) => { + forwarded.push(appArgs); + }, + }); + assert.equal(handled, true); assert.deepEqual(forwarded, [['--refresh-known-words']]); }); @@ -187,31 +183,25 @@ test('dictionary command forwards --dictionary and target path to app binary', ( context.args.dictionaryTarget = '/tmp/anime'; const forwarded: string[][] = []; - assert.throws( - () => - runDictionaryCommand(context, { - runAppCommandWithInherit: (_appPath, appArgs) => { - forwarded.push(appArgs); - throw new ExitSignal(0); - }, - }), - (error: unknown) => error instanceof ExitSignal && error.code === 0, - ); + const handled = runDictionaryCommand(context, { + runAppCommandWithInherit: (_appPath, appArgs) => { + forwarded.push(appArgs); + }, + }); + assert.equal(handled, true); assert.deepEqual(forwarded, [['--dictionary', '--dictionary-target', '/tmp/anime']]); }); -test('dictionary command throws if app handoff unexpectedly returns', () => { +test('dictionary command returns after app handoff starts', () => { const context = createContext(); context.args.dictionary = true; - assert.throws( - () => - runDictionaryCommand(context, { - runAppCommandWithInherit: () => undefined as never, - }), - /unexpectedly returned/, - ); + const handled = runDictionaryCommand(context, { + runAppCommandWithInherit: () => undefined, + }); + + assert.equal(handled, true); }); test('stats command launches attached app command with response path', async () => { diff --git a/launcher/commands/dictionary-command.ts b/launcher/commands/dictionary-command.ts index da820bd..02b0785 100644 --- a/launcher/commands/dictionary-command.ts +++ b/launcher/commands/dictionary-command.ts @@ -2,7 +2,7 @@ import { runAppCommandWithInherit } from '../mpv.js'; import type { LauncherCommandContext } from './context.js'; interface DictionaryCommandDeps { - runAppCommandWithInherit: (appPath: string, appArgs: string[]) => never; + runAppCommandWithInherit: (appPath: string, appArgs: string[]) => void; } const defaultDeps: DictionaryCommandDeps = { @@ -27,5 +27,5 @@ export function runDictionaryCommand( } deps.runAppCommandWithInherit(appPath, forwarded); - throw new Error('Dictionary command app handoff unexpectedly returned.'); + return true; } diff --git a/launcher/commands/doctor-command.ts b/launcher/commands/doctor-command.ts index 6931bea..e170745 100644 --- a/launcher/commands/doctor-command.ts +++ b/launcher/commands/doctor-command.ts @@ -9,7 +9,7 @@ interface DoctorCommandDeps { commandExists(command: string): boolean; configExists(path: string): boolean; resolveMainConfigPath(): string; - runAppCommandWithInherit(appPath: string, appArgs: string[]): never; + runAppCommandWithInherit(appPath: string, appArgs: string[]): void; } const defaultDeps: DoctorCommandDeps = { @@ -85,6 +85,7 @@ export function runDoctorCommand( return true; } deps.runAppCommandWithInherit(appPath, ['--refresh-known-words']); + return true; } const hasHardFailure = checks.some((entry) => diff --git a/launcher/commands/jellyfin-command.ts b/launcher/commands/jellyfin-command.ts index e359fef..3f8fb27 100644 --- a/launcher/commands/jellyfin-command.ts +++ b/launcher/commands/jellyfin-command.ts @@ -21,6 +21,7 @@ export async function runJellyfinCommand(context: LauncherCommandContext): Promi if (args.logLevel !== 'info') forwarded.push('--log-level', args.logLevel); appendPasswordStore(forwarded); runAppCommandWithInherit(appPath, forwarded); + return true; } if (args.jellyfinLogin) { @@ -44,6 +45,7 @@ export async function runJellyfinCommand(context: LauncherCommandContext): Promi if (args.logLevel !== 'info') forwarded.push('--log-level', args.logLevel); appendPasswordStore(forwarded); runAppCommandWithInherit(appPath, forwarded); + return true; } if (args.jellyfinLogout) { @@ -51,6 +53,7 @@ export async function runJellyfinCommand(context: LauncherCommandContext): Promi if (args.logLevel !== 'info') forwarded.push('--log-level', args.logLevel); appendPasswordStore(forwarded); runAppCommandWithInherit(appPath, forwarded); + return true; } if (args.jellyfinPlay) { @@ -69,6 +72,7 @@ export async function runJellyfinCommand(context: LauncherCommandContext): Promi if (args.logLevel !== 'info') forwarded.push('--log-level', args.logLevel); appendPasswordStore(forwarded); runAppCommandWithInherit(appPath, forwarded); + return true; } return Boolean( diff --git a/launcher/commands/playback-command.test.ts b/launcher/commands/playback-command.test.ts new file mode 100644 index 0000000..b20b587 --- /dev/null +++ b/launcher/commands/playback-command.test.ts @@ -0,0 +1,113 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import type { LauncherCommandContext } from './context.js'; +import { runPlaybackCommandWithDeps } from './playback-command.js'; + +function createContext(): LauncherCommandContext { + return { + args: { + backend: 'auto', + directory: '.', + recursive: false, + profile: '', + startOverlay: false, + youtubeMode: 'download', + whisperBin: '', + whisperModel: '', + whisperVadModel: '', + whisperThreads: 0, + youtubeSubgenOutDir: '', + youtubeSubgenAudioFormat: '', + youtubeSubgenKeepTemp: false, + youtubeFixWithAi: false, + youtubePrimarySubLangs: [], + youtubeSecondarySubLangs: [], + youtubeAudioLangs: [], + youtubeWhisperSourceLanguage: '', + aiConfig: {}, + useTexthooker: false, + autoStartOverlay: false, + texthookerOnly: false, + useRofi: false, + logLevel: 'info', + passwordStore: '', + target: 'https://www.youtube.com/watch?v=65Ovd7t8sNw', + targetKind: 'url', + jimakuApiKey: '', + jimakuApiKeyCommand: '', + jimakuApiBaseUrl: '', + jimakuLanguagePreference: 'ja', + jimakuMaxEntryResults: 20, + jellyfin: false, + jellyfinLogin: false, + jellyfinLogout: false, + jellyfinPlay: false, + jellyfinDiscovery: false, + dictionary: false, + stats: false, + doctor: false, + doctorRefreshKnownWords: false, + configPath: false, + configShow: false, + mpvIdle: false, + mpvSocket: false, + mpvStatus: false, + mpvArgs: '', + appPassthrough: false, + appArgs: [], + jellyfinServer: '', + jellyfinUsername: '', + jellyfinPassword: '', + }, + scriptPath: '/tmp/subminer', + scriptName: 'subminer', + mpvSocketPath: '/tmp/subminer.sock', + pluginRuntimeConfig: { + socketPath: '/tmp/subminer.sock', + autoStart: true, + autoStartVisibleOverlay: true, + autoStartPauseUntilReady: true, + }, + appPath: '/tmp/SubMiner.AppImage', + launcherJellyfinConfig: {}, + processAdapter: { + platform: () => 'linux', + onSignal: () => {}, + writeStdout: () => {}, + exit: (_code: number): never => { + throw new Error('unexpected exit'); + }, + setExitCode: () => {}, + }, + }; +} + +test('youtube playback launches overlay with youtube-play args in the primary app start', async () => { + const calls: string[] = []; + const context = createContext(); + + await runPlaybackCommandWithDeps(context, { + ensurePlaybackSetupReady: async () => {}, + chooseTarget: async (_args, _scriptPath) => ({ target: context.args.target, kind: 'url' }), + checkDependencies: () => {}, + registerCleanup: () => {}, + startMpv: async () => { + calls.push('startMpv'); + }, + waitForUnixSocketReady: async () => true, + startOverlay: async (_appPath, _args, _socketPath, extraAppArgs = []) => { + calls.push(`startOverlay:${extraAppArgs.join(' ')}`); + }, + launchAppCommandDetached: (_appPath: string, appArgs: string[]) => { + calls.push(`launch:${appArgs.join(' ')}`); + }, + log: () => {}, + cleanupPlaybackSession: async () => {}, + getMpvProc: () => null, + }); + + assert.deepEqual(calls, [ + 'startMpv', + 'startOverlay:--youtube-play https://www.youtube.com/watch?v=65Ovd7t8sNw --youtube-mode download', + ]); +}); diff --git a/launcher/commands/playback-command.ts b/launcher/commands/playback-command.ts index e82af32..ae81895 100644 --- a/launcher/commands/playback-command.ts +++ b/launcher/commands/playback-command.ts @@ -6,13 +6,13 @@ import { commandExists, isYoutubeTarget, realpathMaybe, resolvePathMaybe } from import { collectVideos, showFzfMenu, showRofiMenu } from '../picker.js'; import { cleanupPlaybackSession, + launchAppCommandDetached, 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'; import { ensureLauncherSetupReady } from '../setup-gate.js'; @@ -126,30 +126,66 @@ async function ensurePlaybackSetupReady(context: LauncherCommandContext): Promis } export async function runPlaybackCommand(context: LauncherCommandContext): Promise { + return runPlaybackCommandWithDeps(context, { + ensurePlaybackSetupReady, + chooseTarget, + checkDependencies, + registerCleanup, + startMpv, + waitForUnixSocketReady, + startOverlay, + launchAppCommandDetached, + log, + cleanupPlaybackSession, + getMpvProc: () => state.mpvProc, + }); +} + +type PlaybackCommandDeps = { + ensurePlaybackSetupReady: (context: LauncherCommandContext) => Promise; + chooseTarget: ( + args: Args, + scriptPath: string, + ) => Promise<{ target: string; kind: 'file' | 'url' } | null>; + checkDependencies: (args: Args) => void; + registerCleanup: (context: LauncherCommandContext) => void; + startMpv: typeof startMpv; + waitForUnixSocketReady: typeof waitForUnixSocketReady; + startOverlay: typeof startOverlay; + launchAppCommandDetached: typeof launchAppCommandDetached; + log: typeof log; + cleanupPlaybackSession: typeof cleanupPlaybackSession; + getMpvProc: () => typeof state.mpvProc; +}; + +export async function runPlaybackCommandWithDeps( + context: LauncherCommandContext, + deps: PlaybackCommandDeps, +): Promise { const { args, appPath, scriptPath, mpvSocketPath, pluginRuntimeConfig, processAdapter } = context; if (!appPath) { fail('SubMiner AppImage not found. Install to ~/.local/bin/ or set SUBMINER_APPIMAGE_PATH.'); } - await ensurePlaybackSetupReady(context); + await deps.ensurePlaybackSetupReady(context); if (!args.target) { checkPickerDependencies(args); } - const targetChoice = await chooseTarget(args, scriptPath); + const targetChoice = await deps.chooseTarget(args, scriptPath); if (!targetChoice) { - log('info', args.logLevel, 'No video selected, exiting'); + deps.log('info', args.logLevel, 'No video selected, exiting'); processAdapter.exit(0); } - checkDependencies({ + deps.checkDependencies({ ...args, target: targetChoice ? targetChoice.target : args.target, targetKind: targetChoice ? targetChoice.kind : 'url', }); - registerCleanup(context); + deps.registerCleanup(context); const selectedTarget = targetChoice ? { @@ -159,30 +195,11 @@ export async function runPlaybackCommand(context: LauncherCommandContext): Promi : { target: args.target, kind: 'url' as const }; const isYoutubeUrl = selectedTarget.kind === 'url' && isYoutubeTarget(selectedTarget.target); - let preloadedSubtitles: { primaryPath?: string; secondaryPath?: string } | undefined; + const isAppOwnedYoutubeFlow = isYoutubeUrl; + const youtubeMode = args.youtubeMode ?? 'download'; if (isYoutubeUrl) { - log('info', args.logLevel, 'YouTube subtitle generation: preload before mpv'); - const generated = await generateYoutubeSubtitles(selectedTarget.target, args); - preloadedSubtitles = { - primaryPath: generated.primaryPath, - secondaryPath: generated.secondaryPath, - }; - const primaryStatus = generated.primaryPath - ? 'ready' - : generated.primaryNative - ? 'native' - : 'missing'; - const secondaryStatus = generated.secondaryPath - ? 'ready' - : generated.secondaryNative - ? 'native' - : 'missing'; - log( - 'info', - args.logLevel, - `YouTube subtitle result: primary=${primaryStatus}, secondary=${secondaryStatus}`, - ); + deps.log('info', args.logLevel, 'YouTube subtitle flow: app-owned picker after mpv bootstrap'); } const shouldPauseUntilOverlayReady = @@ -191,47 +208,57 @@ export async function runPlaybackCommand(context: LauncherCommandContext): Promi pluginRuntimeConfig.autoStartPauseUntilReady; if (shouldPauseUntilOverlayReady) { - log('info', args.logLevel, 'Configured to pause mpv until overlay and tokenization are ready'); + deps.log('info', args.logLevel, 'Configured to pause mpv until overlay and tokenization are ready'); } - await startMpv( + await deps.startMpv( selectedTarget.target, selectedTarget.kind, args, mpvSocketPath, appPath, - preloadedSubtitles, - { startPaused: shouldPauseUntilOverlayReady }, + undefined, + { + startPaused: shouldPauseUntilOverlayReady || isAppOwnedYoutubeFlow, + disableYoutubeSubtitleAutoLoad: isAppOwnedYoutubeFlow, + }, ); - const ready = await waitForUnixSocketReady(mpvSocketPath, 10000); + const ready = await deps.waitForUnixSocketReady(mpvSocketPath, 10000); const pluginAutoStartEnabled = pluginRuntimeConfig.autoStart; - const shouldStartOverlay = args.startOverlay || args.autoStartOverlay; + const shouldStartOverlay = args.startOverlay || args.autoStartOverlay || isAppOwnedYoutubeFlow; if (shouldStartOverlay) { if (ready) { - log('info', args.logLevel, 'MPV IPC socket ready, starting SubMiner overlay'); + deps.log('info', args.logLevel, 'MPV IPC socket ready, starting SubMiner overlay'); } else { - log( + deps.log( 'info', args.logLevel, 'MPV IPC socket not ready after timeout, starting SubMiner overlay anyway', ); } - await startOverlay(appPath, args, mpvSocketPath); + await deps.startOverlay( + appPath, + args, + mpvSocketPath, + isAppOwnedYoutubeFlow + ? ['--youtube-play', selectedTarget.target, '--youtube-mode', youtubeMode] + : [], + ); } else if (pluginAutoStartEnabled) { if (ready) { - log('info', args.logLevel, 'MPV IPC socket ready, relying on mpv plugin auto-start'); + deps.log('info', args.logLevel, 'MPV IPC socket ready, relying on mpv plugin auto-start'); } else { - log('info', args.logLevel, 'MPV IPC socket not ready yet, relying on mpv plugin auto-start'); + deps.log('info', args.logLevel, 'MPV IPC socket not ready yet, relying on mpv plugin auto-start'); } } else if (ready) { - log( + deps.log( 'info', args.logLevel, 'MPV IPC socket ready, overlay auto-start disabled (use y-s to start)', ); } else { - log( + deps.log( 'info', args.logLevel, 'MPV IPC socket not ready yet, overlay auto-start disabled (use y-s to start)', @@ -239,7 +266,7 @@ export async function runPlaybackCommand(context: LauncherCommandContext): Promi } await new Promise((resolve) => { - const mpvProc = state.mpvProc; + const mpvProc = deps.getMpvProc(); if (!mpvProc) { stopOverlay(args); resolve(); @@ -247,7 +274,7 @@ export async function runPlaybackCommand(context: LauncherCommandContext): Promi } const finalize = (code: number | null | undefined) => { - void cleanupPlaybackSession(args).finally(() => { + void deps.cleanupPlaybackSession(args).finally(() => { processAdapter.setExitCode(code ?? 0); resolve(); }); diff --git a/launcher/config/args-normalizer.ts b/launcher/config/args-normalizer.ts index 08e4e2e..03333e7 100644 --- a/launcher/config/args-normalizer.ts +++ b/launcher/config/args-normalizer.ts @@ -111,6 +111,7 @@ export function createDefaultArgs(launcherConfig: LauncherYoutubeSubgenConfig): youtubeSubgenAudioFormat: process.env.SUBMINER_YT_SUBGEN_AUDIO_FORMAT || 'm4a', youtubeSubgenKeepTemp: process.env.SUBMINER_YT_SUBGEN_KEEP_TEMP === '1', youtubeFixWithAi: launcherConfig.fixWithAi === true, + youtubeMode: undefined, 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, @@ -250,6 +251,9 @@ export function applyInvocationsToArgs(parsed: Args, invocations: CliInvocations } if (invocations.ytInvocation) { + if (invocations.ytInvocation.mode) { + parsed.youtubeMode = invocations.ytInvocation.mode; + } if (invocations.ytInvocation.logLevel) parsed.logLevel = parseLogLevel(invocations.ytInvocation.logLevel); if (invocations.ytInvocation.outDir) diff --git a/launcher/config/cli-parser-builder.ts b/launcher/config/cli-parser-builder.ts index 40ea761..2fd628f 100644 --- a/launcher/config/cli-parser-builder.ts +++ b/launcher/config/cli-parser-builder.ts @@ -16,6 +16,7 @@ export interface JellyfinInvocation { export interface YtInvocation { target?: string; + mode?: 'download' | 'generate'; outDir?: string; keepTemp?: boolean; whisperBin?: string; @@ -222,6 +223,7 @@ export function parseCliPrograms( .alias('youtube') .description('YouTube workflows') .argument('[target]', 'YouTube URL or ytsearch: query') + .option('--mode ', 'YouTube subtitle acquisition mode') .option('-o, --out-dir ', 'Subtitle output dir') .option('--keep-temp', 'Keep temp files') .option('--whisper-bin ', 'whisper.cpp CLI path') @@ -233,6 +235,10 @@ export function parseCliPrograms( .action((target: string | undefined, options: Record) => { ytInvocation = { target, + mode: + typeof options.mode === 'string' && (options.mode === 'download' || options.mode === 'generate') + ? options.mode + : undefined, outDir: typeof options.outDir === 'string' ? options.outDir : undefined, keepTemp: options.keepTemp === true, whisperBin: typeof options.whisperBin === 'string' ? options.whisperBin : undefined, diff --git a/launcher/log.test.ts b/launcher/log.test.ts index 3e97dbe..91e2776 100644 --- a/launcher/log.test.ts +++ b/launcher/log.test.ts @@ -1,7 +1,7 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import path from 'node:path'; -import { getDefaultMpvLogFile } from './types.js'; +import { getDefaultLauncherLogFile, getDefaultMpvLogFile } from './types.js'; test('getDefaultMpvLogFile uses APPDATA on windows', () => { const resolved = getDefaultMpvLogFile({ @@ -17,8 +17,26 @@ test('getDefaultMpvLogFile uses APPDATA on windows', () => { 'C:\\Users\\tester\\AppData\\Roaming', 'SubMiner', 'logs', - `SubMiner-${new Date().toISOString().slice(0, 10)}.log`, + `mpv-${new Date().toISOString().slice(0, 10)}.log`, ), ), ); }); + +test('getDefaultLauncherLogFile uses launcher prefix', () => { + const resolved = getDefaultLauncherLogFile({ + platform: 'linux', + homeDir: '/home/tester', + }); + + assert.equal( + resolved, + path.join( + '/home/tester', + '.config', + 'SubMiner', + 'logs', + `launcher-${new Date().toISOString().slice(0, 10)}.log`, + ), + ); +}); diff --git a/launcher/log.ts b/launcher/log.ts index 10aca67..c0cc3a2 100644 --- a/launcher/log.ts +++ b/launcher/log.ts @@ -1,7 +1,6 @@ -import fs from 'node:fs'; -import path from 'node:path'; import type { LogLevel } from './types.js'; -import { DEFAULT_MPV_LOG_FILE } from './types.js'; +import { DEFAULT_MPV_LOG_FILE, getDefaultLauncherLogFile } from './types.js'; +import { appendLogLine, resolveDefaultLogFilePath } from '../src/shared/log-files.js'; export const COLORS = { red: '\x1b[0;31m', @@ -28,14 +27,32 @@ export function getMpvLogPath(): string { return DEFAULT_MPV_LOG_FILE; } +export function getLauncherLogPath(): string { + const envPath = process.env.SUBMINER_LAUNCHER_LOG?.trim(); + if (envPath) return envPath; + return getDefaultLauncherLogFile(); +} + +export function getAppLogPath(): string { + const envPath = process.env.SUBMINER_APP_LOG?.trim(); + if (envPath) return envPath; + return resolveDefaultLogFilePath('app'); +} + +function appendTimestampedLog(logPath: string, message: string): void { + appendLogLine(logPath, `[${new Date().toISOString()}] ${message}`); +} + export function appendToMpvLog(message: string): void { - const logPath = getMpvLogPath(); - try { - fs.mkdirSync(path.dirname(logPath), { recursive: true }); - fs.appendFileSync(logPath, `[${new Date().toISOString()}] ${message}\n`, { encoding: 'utf8' }); - } catch { - // ignore logging failures - } + appendTimestampedLog(getMpvLogPath(), message); +} + +export function appendToLauncherLog(message: string): void { + appendTimestampedLog(getLauncherLogPath(), message); +} + +export function appendToAppLog(message: string): void { + appendTimestampedLog(getAppLogPath(), message); } export function log(level: LogLevel, configured: LogLevel, message: string): void { @@ -49,11 +66,11 @@ export function log(level: LogLevel, configured: LogLevel, message: string): voi ? COLORS.red : COLORS.cyan; process.stdout.write(`${color}[${level.toUpperCase()}]${COLORS.reset} ${message}\n`); - appendToMpvLog(`[${level.toUpperCase()}] ${message}`); + appendToLauncherLog(`[${level.toUpperCase()}] ${message}`); } export function fail(message: string): never { process.stderr.write(`${COLORS.red}[ERROR]${COLORS.reset} ${message}\n`); - appendToMpvLog(`[ERROR] ${message}`); + appendToLauncherLog(`[ERROR] ${message}`); process.exit(1); } diff --git a/launcher/main.test.ts b/launcher/main.test.ts index de06557..e03e1d8 100644 --- a/launcher/main.test.ts +++ b/launcher/main.test.ts @@ -205,136 +205,6 @@ test('doctor refresh-known-words forwards app refresh command without requiring }); }); -test('youtube command rejects removed --mode option', () => { - withTempDir((root) => { - const homeDir = path.join(root, 'home'); - const xdgConfigHome = path.join(root, 'xdg'); - const appPath = path.join(root, 'fake-subminer.sh'); - fs.writeFileSync(appPath, '#!/bin/sh\nexit 0\n'); - fs.chmodSync(appPath, 0o755); - - const env = { - ...makeTestEnv(homeDir, xdgConfigHome), - SUBMINER_APPIMAGE_PATH: appPath, - }; - const result = runLauncher( - ['youtube', 'https://www.youtube.com/watch?v=test123', '--mode', 'automatic'], - env, - ); - - assert.equal(result.status, 1); - assert.match(result.stderr, /unknown option '--mode'/i); - }); -}); - -test('youtube playback generates subtitles before mpv launch', { timeout: 15000 }, () => { - withTempDir((root) => { - const homeDir = path.join(root, 'home'); - const xdgConfigHome = path.join(root, 'xdg'); - const binDir = path.join(root, 'bin'); - const appPath = path.join(root, 'fake-subminer.sh'); - const ytdlpLogPath = path.join(root, 'yt-dlp.log'); - const mpvCapturePath = path.join(root, 'mpv-order.txt'); - const mpvArgsPath = path.join(root, 'mpv-args.txt'); - const socketPath = path.join(root, 'mpv.sock'); - const bunBinary = JSON.stringify(process.execPath.replace(/\\/g, '/')); - - fs.mkdirSync(binDir, { recursive: true }); - fs.mkdirSync(path.join(xdgConfigHome, 'SubMiner'), { recursive: true }); - fs.mkdirSync(path.join(xdgConfigHome, 'mpv', 'script-opts'), { recursive: true }); - fs.writeFileSync( - path.join(xdgConfigHome, 'SubMiner', 'setup-state.json'), - JSON.stringify({ - version: 1, - status: 'completed', - completedAt: '2026-03-08T00:00:00.000Z', - completionSource: 'user', - lastSeenYomitanDictionaryCount: 0, - pluginInstallStatus: 'installed', - pluginInstallPathSummary: null, - }), - ); - fs.writeFileSync( - path.join(xdgConfigHome, 'mpv', 'script-opts', 'subminer.conf'), - `socket_path=${socketPath}\nauto_start=no\nauto_start_visible_overlay=no\nauto_start_pause_until_ready=no\n`, - ); - fs.writeFileSync(appPath, '#!/bin/sh\nexit 0\n'); - fs.chmodSync(appPath, 0o755); - - fs.writeFileSync( - path.join(binDir, 'yt-dlp'), - `#!/bin/sh -set -eu -printf '%s\\n' "$*" >> "$SUBMINER_TEST_YTDLP_LOG" -if printf '%s\\n' "$*" | grep -q -- '--dump-single-json'; then - printf '{"id":"video123"}\\n' - exit 0 -fi -out_dir="" -prev="" -for arg in "$@"; do - if [ "$prev" = "-o" ]; then - out_dir=$(dirname "$arg") - break - fi - prev="$arg" -done -mkdir -p "$out_dir" -printf '1\\n00:00:00,000 --> 00:00:01,000\\nこんにちは\\n' > "$out_dir/video123.ja.srt" -printf '1\\n00:00:00,000 --> 00:00:01,000\\nhello\\n' > "$out_dir/video123.en.srt" -`, - 'utf8', - ); - fs.chmodSync(path.join(binDir, 'yt-dlp'), 0o755); - - fs.writeFileSync(path.join(binDir, 'ffmpeg'), '#!/bin/sh\nexit 0\n', 'utf8'); - fs.chmodSync(path.join(binDir, 'ffmpeg'), 0o755); - - fs.writeFileSync( - path.join(binDir, 'mpv'), - `#!/bin/sh -set -eu -if [ -s "$SUBMINER_TEST_YTDLP_LOG" ]; then - printf 'generated-before-mpv\\n' > "$SUBMINER_TEST_MPV_ORDER" -else - printf 'mpv-before-generation\\n' > "$SUBMINER_TEST_MPV_ORDER" -fi -printf '%s\\n' "$@" > "$SUBMINER_TEST_MPV_ARGS" -socket_path="" -for arg in "$@"; do - case "$arg" in - --input-ipc-server=*) - socket_path="\${arg#--input-ipc-server=}" - ;; - esac -done - ${bunBinary} -e "const net=require('node:net'); const fs=require('node:fs'); const path=require('node:path'); const socket=process.argv[1]||''; try{ if(socket) fs.mkdirSync(path.dirname(socket),{recursive:true}); }catch{} try{ if(socket) fs.rmSync(socket,{force:true}); }catch{} const server=net.createServer((c)=>c.end()); server.on('error',()=>process.exit(0)); if(!socket) process.exit(0); try{ server.listen(socket,()=>setTimeout(()=>server.close(()=>process.exit(0)),250)); } catch { process.exit(0); }" "$socket_path" - `, - 'utf8', - ); - fs.chmodSync(path.join(binDir, 'mpv'), 0o755); - - const env = { - ...makeTestEnv(homeDir, xdgConfigHome), - PATH: `${binDir}${path.delimiter}${process.env.Path || process.env.PATH || ''}`, - Path: `${binDir}${path.delimiter}${process.env.Path || process.env.PATH || ''}`, - SUBMINER_APPIMAGE_PATH: appPath, - SUBMINER_TEST_YTDLP_LOG: ytdlpLogPath, - SUBMINER_TEST_MPV_ORDER: mpvCapturePath, - SUBMINER_TEST_MPV_ARGS: mpvArgsPath, - }; - const result = runLauncher(['youtube', 'https://www.youtube.com/watch?v=test123'], env); - - assert.equal(result.status, 0, `stdout:\n${result.stdout}\nstderr:\n${result.stderr}`); - assert.equal(fs.readFileSync(mpvCapturePath, 'utf8').trim(), 'generated-before-mpv'); - assert.match( - fs.readFileSync(mpvArgsPath, 'utf8'), - /https:\/\/www\.youtube\.com\/watch\?v=test123/, - ); - assert.match(fs.readFileSync(ytdlpLogPath, 'utf8'), /--dump-single-json/); - }); -}); - test('launcher forwards --args to mpv as parsed tokens', { timeout: 15000 }, () => { withTempDir((root) => { const homeDir = path.join(root, 'home'); @@ -484,6 +354,78 @@ ${bunBinary} -e "const net=require('node:net'); const fs=require('node:fs'); con }); }); +test('launcher disables plugin startup pause gate for app-owned youtube flow', { timeout: 15000 }, () => { + withTempDir((root) => { + const homeDir = path.join(root, 'home'); + const xdgConfigHome = path.join(root, 'xdg'); + const binDir = path.join(root, 'bin'); + const appPath = path.join(root, 'fake-subminer.sh'); + const mpvArgsPath = path.join(root, 'mpv-args.txt'); + const socketPath = path.join(root, 'mpv.sock'); + const bunBinary = JSON.stringify(process.execPath.replace(/\\/g, '/')); + + fs.mkdirSync(binDir, { recursive: true }); + fs.mkdirSync(path.join(xdgConfigHome, 'SubMiner'), { recursive: true }); + fs.mkdirSync(path.join(xdgConfigHome, 'mpv', 'script-opts'), { recursive: true }); + fs.writeFileSync( + path.join(xdgConfigHome, 'SubMiner', 'setup-state.json'), + JSON.stringify({ + version: 1, + status: 'completed', + completedAt: '2026-03-08T00:00:00.000Z', + completionSource: 'user', + lastSeenYomitanDictionaryCount: 0, + pluginInstallStatus: 'installed', + pluginInstallPathSummary: null, + }), + ); + fs.writeFileSync( + path.join(xdgConfigHome, 'mpv', 'script-opts', 'subminer.conf'), + `socket_path=${socketPath}\nauto_start=yes\nauto_start_visible_overlay=yes\nauto_start_pause_until_ready=yes\n`, + ); + fs.writeFileSync( + appPath, + '#!/bin/sh\nif [ -n "$SUBMINER_TEST_CAPTURE" ]; then printf "%s\\n" "$@" > "$SUBMINER_TEST_CAPTURE"; fi\nexit 0\n', + ); + fs.chmodSync(appPath, 0o755); + + fs.writeFileSync( + path.join(binDir, 'mpv'), + `#!/bin/sh +set -eu +printf '%s\\n' "$@" > "$SUBMINER_TEST_MPV_ARGS" +socket_path="" +for arg in "$@"; do + case "$arg" in + --input-ipc-server=*) + socket_path="\${arg#--input-ipc-server=}" + ;; + esac +done +${bunBinary} -e "const net=require('node:net'); const fs=require('node:fs'); const path=require('node:path'); const socket=process.argv[1]||''; try{ if (socket) fs.mkdirSync(path.dirname(socket),{recursive:true}); }catch{} try{ if (socket) fs.rmSync(socket,{force:true}); }catch{} if(!socket) process.exit(0); const server=net.createServer((c)=>c.end()); server.on('error',()=>process.exit(0)); try{ server.listen(socket,()=>setTimeout(()=>server.close(()=>process.exit(0)),250)); } catch { process.exit(0); }" "$socket_path" +`, + 'utf8', + ); + fs.chmodSync(path.join(binDir, 'mpv'), 0o755); + + const env = { + ...makeTestEnv(homeDir, xdgConfigHome), + PATH: `${binDir}${path.delimiter}${process.env.Path || process.env.PATH || ''}`, + Path: `${binDir}${path.delimiter}${process.env.Path || process.env.PATH || ''}`, + SUBMINER_APPIMAGE_PATH: appPath, + SUBMINER_TEST_MPV_ARGS: mpvArgsPath, + SUBMINER_TEST_CAPTURE: path.join(root, 'captured-args.txt'), + }; + const result = runLauncher(['yt', 'https://www.youtube.com/watch?v=abc123'], env); + + assert.equal(result.status, 0, `stdout:\n${result.stdout}\nstderr:\n${result.stderr}`); + assert.match( + fs.readFileSync(mpvArgsPath, 'utf8'), + /--script-opts=.*subminer-auto_start_pause_until_ready=no/, + ); + }); +}); + test('dictionary command forwards --dictionary and --dictionary-target to app command path', () => { withTempDir((root) => { const homeDir = path.join(root, 'home'); diff --git a/launcher/mpv.test.ts b/launcher/mpv.test.ts index a60dc66..be848a3 100644 --- a/launcher/mpv.test.ts +++ b/launcher/mpv.test.ts @@ -302,7 +302,47 @@ test('startOverlay resolves without fixed 2s sleep when readiness signals arrive } }); -test('cleanupPlaybackSession preserves background app while stopping mpv-owned children', async () => { +test('startOverlay captures app stdout and stderr into app log', async () => { + const { dir, socketPath } = createTempSocketPath(); + const appPath = path.join(dir, 'fake-subminer.sh'); + const appLogPath = path.join(dir, 'app.log'); + const originalAppLog = process.env.SUBMINER_APP_LOG; + fs.writeFileSync( + appPath, + '#!/bin/sh\nprintf "hello from stdout\\n"\nprintf "hello from stderr\\n" >&2\nexit 0\n', + ); + fs.chmodSync(appPath, 0o755); + fs.writeFileSync(socketPath, ''); + const originalCreateConnection = net.createConnection; + try { + process.env.SUBMINER_APP_LOG = appLogPath; + net.createConnection = (() => { + const socket = new EventEmitter() as net.Socket; + socket.destroy = (() => socket) as net.Socket['destroy']; + socket.setTimeout = (() => socket) as net.Socket['setTimeout']; + setTimeout(() => socket.emit('connect'), 10); + return socket; + }) as typeof net.createConnection; + + await startOverlay(appPath, makeArgs(), socketPath); + + const logText = fs.readFileSync(appLogPath, 'utf8'); + assert.match(logText, /\[STDOUT\] hello from stdout/); + assert.match(logText, /\[STDERR\] hello from stderr/); + } finally { + net.createConnection = originalCreateConnection; + state.overlayProc = null; + state.overlayManagedByLauncher = false; + if (originalAppLog === undefined) { + delete process.env.SUBMINER_APP_LOG; + } else { + process.env.SUBMINER_APP_LOG = originalAppLog; + } + fs.rmSync(dir, { recursive: true, force: true }); + } +}); + +test('cleanupPlaybackSession stops launcher-managed overlay app and mpv-owned children', async () => { const { dir } = createTempSocketPath(); const appPath = path.join(dir, 'fake-subminer.sh'); const appInvocationsPath = path.join(dir, 'app-invocations.log'); @@ -345,8 +385,8 @@ test('cleanupPlaybackSession preserves background app while stopping mpv-owned c try { await cleanupPlaybackSession(makeArgs()); - assert.deepEqual(calls, ['mpv-kill', 'helper-kill']); - assert.equal(fs.existsSync(appInvocationsPath), false); + assert.deepEqual(calls, ['overlay-kill', 'mpv-kill', 'helper-kill']); + assert.match(fs.readFileSync(appInvocationsPath, 'utf8'), /--stop/); } finally { state.overlayProc = null; state.mpvProc = null; diff --git a/launcher/mpv.ts b/launcher/mpv.ts index fa945c4..7177c3c 100644 --- a/launcher/mpv.ts +++ b/launcher/mpv.ts @@ -5,7 +5,7 @@ import net from 'node:net'; import { spawn, spawnSync } from 'node:child_process'; import type { LogLevel, Backend, Args, MpvTrack } from './types.js'; import { DEFAULT_MPV_SUBMINER_ARGS, DEFAULT_YOUTUBE_YTDL_FORMAT } from './types.js'; -import { log, fail, getMpvLogPath } from './log.js'; +import { appendToAppLog, getAppLogPath, log, fail, getMpvLogPath } from './log.js'; import { buildSubminerScriptOpts, resolveAniSkipMetadataForFile } from './aniskip-metadata.js'; import { commandExists, @@ -542,7 +542,7 @@ export async function startMpv( socketPath: string, appPath: string, preloadedSubtitles?: { primaryPath?: string; secondaryPath?: string }, - options?: { startPaused?: boolean }, + options?: { startPaused?: boolean; disableYoutubeSubtitleAutoLoad?: boolean }, ): Promise { if (targetKind === 'file' && (!fs.existsSync(target) || !fs.statSync(target).isFile())) { fail(`Video file not found: ${target}`); @@ -575,13 +575,17 @@ export async function startMpv( log('debug', args.logLevel, `YouTube subtitle langs: ${subtitleLangs}`); log('debug', args.logLevel, `YouTube audio langs: ${audioLangs}`); mpvArgs.push(`--ytdl-format=${DEFAULT_YOUTUBE_YTDL_FORMAT}`, `--alang=${audioLangs}`); - mpvArgs.push( - '--sub-auto=fuzzy', - `--slang=${subtitleLangs}`, - '--ytdl-raw-options-append=write-subs=', - '--ytdl-raw-options-append=sub-format=vtt/best', - `--ytdl-raw-options-append=sub-langs=${subtitleLangs}`, - ); + if (options?.disableYoutubeSubtitleAutoLoad !== true) { + mpvArgs.push( + '--sub-auto=fuzzy', + `--slang=${subtitleLangs}`, + '--ytdl-raw-options-append=write-subs=', + '--ytdl-raw-options-append=sub-format=vtt/best', + `--ytdl-raw-options-append=sub-langs=${subtitleLangs}`, + ); + } else { + mpvArgs.push('--sub-auto=no'); + } } } @@ -597,7 +601,17 @@ export async function startMpv( const aniSkipMetadata = shouldResolveAniSkipMetadata(target, targetKind, preloadedSubtitles) ? await resolveAniSkipMetadataForFile(target) : null; - const scriptOpts = buildSubminerScriptOpts(appPath, socketPath, aniSkipMetadata, args.logLevel); + const extraScriptOpts = + targetKind === 'url' && isYoutubeTarget(target) && options?.disableYoutubeSubtitleAutoLoad === true + ? ['subminer-auto_start_pause_until_ready=no'] + : []; + const scriptOpts = buildSubminerScriptOpts( + appPath, + socketPath, + aniSkipMetadata, + args.logLevel, + extraScriptOpts, + ); if (aniSkipMetadata) { log( 'debug', @@ -661,19 +675,25 @@ async function waitForOverlayStartCommandSettled( }); } -export async function startOverlay(appPath: string, args: Args, socketPath: string): Promise { +export async function startOverlay( + appPath: string, + args: Args, + socketPath: string, + extraAppArgs: string[] = [], +): Promise { const backend = detectBackend(args.backend); log('info', args.logLevel, `Starting SubMiner overlay (backend: ${backend})...`); - const overlayArgs = ['--start', '--backend', backend, '--socket', socketPath]; + const overlayArgs = ['--start', '--backend', backend, '--socket', socketPath, ...extraAppArgs]; if (args.logLevel !== 'info') overlayArgs.push('--log-level', args.logLevel); if (args.useTexthooker) overlayArgs.push('--texthooker'); const target = resolveAppSpawnTarget(appPath, overlayArgs); state.overlayProc = spawn(target.command, target.args, { - stdio: 'inherit', + stdio: ['ignore', 'pipe', 'pipe'], env: buildAppEnv(), }); + attachAppProcessLogging(state.overlayProc); state.overlayManagedByLauncher = true; const [socketReady] = await Promise.all([ @@ -699,10 +719,7 @@ export function launchTexthookerOnly(appPath: string, args: Args): never { if (args.logLevel !== 'info') overlayArgs.push('--log-level', args.logLevel); log('info', args.logLevel, 'Launching texthooker mode...'); - const result = spawnSync(appPath, overlayArgs, { - stdio: 'inherit', - env: buildAppEnv(), - }); + const result = runSyncAppCommand(appPath, overlayArgs, true); if (result.error) { fail(`Failed to launch texthooker mode: ${result.error.message}`); } @@ -713,30 +730,7 @@ export function stopOverlay(args: Args): void { if (state.stopRequested) return; state.stopRequested = true; - if (state.overlayManagedByLauncher && state.appPath) { - log('info', args.logLevel, 'Stopping SubMiner overlay...'); - - const stopArgs = ['--stop']; - if (args.logLevel !== 'info') stopArgs.push('--log-level', args.logLevel); - - const result = spawnSync(state.appPath, stopArgs, { - stdio: 'ignore', - env: buildAppEnv(), - }); - if (result.error) { - log('warn', args.logLevel, `Failed to stop SubMiner overlay: ${result.error.message}`); - } else if (typeof result.status === 'number' && result.status !== 0) { - log('warn', args.logLevel, `SubMiner overlay stop command exited with status ${result.status}`); - } - - if (state.overlayProc && !state.overlayProc.killed) { - try { - state.overlayProc.kill('SIGTERM'); - } catch { - // ignore - } - } - } + stopManagedOverlayApp(args); if (state.mpvProc && !state.mpvProc.killed) { try { @@ -761,6 +755,8 @@ export function stopOverlay(args: Args): void { } export async function cleanupPlaybackSession(args: Args): Promise { + stopManagedOverlayApp(args); + if (state.mpvProc && !state.mpvProc.killed) { try { state.mpvProc.kill('SIGTERM'); @@ -783,9 +779,39 @@ export async function cleanupPlaybackSession(args: Args): Promise { await terminateTrackedDetachedMpv(args.logLevel); } +function stopManagedOverlayApp(args: Args): void { + if (!(state.overlayManagedByLauncher && state.appPath)) { + return; + } + + log('info', args.logLevel, 'Stopping SubMiner overlay...'); + + const stopArgs = ['--stop']; + if (args.logLevel !== 'info') stopArgs.push('--log-level', args.logLevel); + + const result = spawnSync(state.appPath, stopArgs, { + stdio: 'ignore', + env: buildAppEnv(), + }); + if (result.error) { + log('warn', args.logLevel, `Failed to stop SubMiner overlay: ${result.error.message}`); + } else if (typeof result.status === 'number' && result.status !== 0) { + log('warn', args.logLevel, `SubMiner overlay stop command exited with status ${result.status}`); + } + + if (state.overlayProc && !state.overlayProc.killed) { + try { + state.overlayProc.kill('SIGTERM'); + } catch { + // ignore + } + } +} + function buildAppEnv(): NodeJS.ProcessEnv { const env: Record = { ...process.env, + SUBMINER_APP_LOG: getAppLogPath(), SUBMINER_MPV_LOG: getMpvLogPath(), }; delete env.ELECTRON_RUN_AS_NODE; @@ -804,6 +830,64 @@ function buildAppEnv(): NodeJS.ProcessEnv { return env; } +function appendCapturedAppOutput(kind: 'STDOUT' | 'STDERR', chunk: string): void { + const normalized = chunk.replace(/\r\n/g, '\n'); + for (const line of normalized.split('\n')) { + if (!line) continue; + appendToAppLog(`[${kind}] ${line}`); + } +} + +function attachAppProcessLogging( + proc: ReturnType, + options?: { + mirrorStdout?: boolean; + mirrorStderr?: boolean; + }, +): void { + proc.stdout?.setEncoding('utf8'); + proc.stderr?.setEncoding('utf8'); + proc.stdout?.on('data', (chunk: string) => { + appendCapturedAppOutput('STDOUT', chunk); + if (options?.mirrorStdout) process.stdout.write(chunk); + }); + proc.stderr?.on('data', (chunk: string) => { + appendCapturedAppOutput('STDERR', chunk); + if (options?.mirrorStderr) process.stderr.write(chunk); + }); +} + +function runSyncAppCommand( + appPath: string, + appArgs: string[], + mirrorOutput: boolean, +): { + status: number; + stdout: string; + stderr: string; + error?: Error; +} { + const target = resolveAppSpawnTarget(appPath, appArgs); + const result = spawnSync(target.command, target.args, { + env: buildAppEnv(), + encoding: 'utf8', + }); + if (result.stdout) { + appendCapturedAppOutput('STDOUT', result.stdout); + if (mirrorOutput) process.stdout.write(result.stdout); + } + if (result.stderr) { + appendCapturedAppOutput('STDERR', result.stderr); + if (mirrorOutput) process.stderr.write(result.stderr); + } + return { + status: result.status ?? 1, + stdout: result.stdout ?? '', + stderr: result.stderr ?? '', + error: result.error ?? undefined, + }; +} + function maybeCaptureAppArgs(appArgs: string[]): boolean { const capturePath = process.env.SUBMINER_TEST_CAPTURE?.trim(); if (!capturePath) { @@ -821,20 +905,23 @@ function resolveAppSpawnTarget(appPath: string, appArgs: string[]): SpawnTarget return resolveCommandInvocation(appPath, appArgs); } -export function runAppCommandWithInherit(appPath: string, appArgs: string[]): never { +export function runAppCommandWithInherit(appPath: string, appArgs: string[]): void { if (maybeCaptureAppArgs(appArgs)) { process.exit(0); } const target = resolveAppSpawnTarget(appPath, appArgs); - const result = spawnSync(target.command, target.args, { - stdio: 'inherit', + const proc = spawn(target.command, target.args, { + stdio: ['ignore', 'pipe', 'pipe'], env: buildAppEnv(), }); - if (result.error) { - fail(`Failed to run app command: ${result.error.message}`); - } - process.exit(result.status ?? 0); + attachAppProcessLogging(proc, { mirrorStdout: true, mirrorStderr: true }); + proc.once('error', (error) => { + fail(`Failed to run app command: ${error.message}`); + }); + proc.once('exit', (code) => { + process.exit(code ?? 0); + }); } export function runAppCommandCaptureOutput( @@ -854,18 +941,7 @@ export function runAppCommandCaptureOutput( }; } - const target = resolveAppSpawnTarget(appPath, appArgs); - const result = spawnSync(target.command, target.args, { - env: buildAppEnv(), - encoding: 'utf8', - }); - - return { - status: result.status ?? 1, - stdout: result.stdout ?? '', - stderr: result.stderr ?? '', - error: result.error ?? undefined, - }; + return runSyncAppCommand(appPath, appArgs, false); } export function runAppCommandAttached( @@ -887,9 +963,10 @@ export function runAppCommandAttached( return new Promise((resolve, reject) => { const proc = spawn(target.command, target.args, { - stdio: 'inherit', + stdio: ['ignore', 'pipe', 'pipe'], env: buildAppEnv(), }); + attachAppProcessLogging(proc, { mirrorStdout: true, mirrorStderr: true }); proc.once('error', (error) => { reject(error); }); @@ -921,10 +998,7 @@ export function runAppCommandWithInheritLogged( logLevel, `${label}: launching app with args: ${[target.command, ...target.args].join(' ')}`, ); - const result = spawnSync(target.command, target.args, { - stdio: 'inherit', - env: buildAppEnv(), - }); + const result = runSyncAppCommand(appPath, appArgs, true); if (result.error) { fail(`Failed to run app command: ${result.error.message}`); } @@ -953,15 +1027,24 @@ export function launchAppCommandDetached( logLevel, `${label}: launching detached app with args: ${[target.command, ...target.args].join(' ')}`, ); - const proc = spawn(target.command, target.args, { - stdio: 'ignore', - detached: true, - env: buildAppEnv(), - }); - proc.once('error', (error) => { - log('warn', logLevel, `${label}: failed to launch detached app: ${error.message}`); - }); - proc.unref(); + const appLogPath = getAppLogPath(); + fs.mkdirSync(path.dirname(appLogPath), { recursive: true }); + const stdoutFd = fs.openSync(appLogPath, 'a'); + const stderrFd = fs.openSync(appLogPath, 'a'); + try { + const proc = spawn(target.command, target.args, { + stdio: ['ignore', stdoutFd, stderrFd], + detached: true, + env: buildAppEnv(), + }); + proc.once('error', (error) => { + log('warn', logLevel, `${label}: failed to launch detached app: ${error.message}`); + }); + proc.unref(); + } finally { + fs.closeSync(stdoutFd); + fs.closeSync(stderrFd); + } } export function launchMpvIdleDetached( diff --git a/launcher/parse-args.test.ts b/launcher/parse-args.test.ts index 907c7d0..8b7296d 100644 --- a/launcher/parse-args.test.ts +++ b/launcher/parse-args.test.ts @@ -85,6 +85,13 @@ test('parseArgs maps mpv idle action', () => { assert.equal(parsed.mpvStatus, false); }); +test('parseArgs captures youtube mode forwarding', () => { + const parsed = parseArgs(['youtube', 'https://example.com', '--mode', 'generate'], 'subminer', {}); + + assert.equal(parsed.target, 'https://example.com'); + assert.equal(parsed.youtubeMode, 'generate'); +}); + test('parseArgs maps dictionary command and log-level override', () => { const parsed = parseArgs(['dictionary', '.', '--log-level', 'debug'], 'subminer', {}); diff --git a/launcher/smoke.e2e.test.ts b/launcher/smoke.e2e.test.ts index 14e840f..e95ed55 100644 --- a/launcher/smoke.e2e.test.ts +++ b/launcher/smoke.e2e.test.ts @@ -324,7 +324,7 @@ test( assert.match(result.stdout, /Starting SubMiner overlay/i); assert.equal(appStartEntries.length, 1); - assert.equal(appStopEntries.length, 0); + assert.equal(appStopEntries.length, 1); assert.equal(mpvEntries.length >= 1, true); const appStartArgs = appStartEntries[0]?.argv; diff --git a/launcher/types.ts b/launcher/types.ts index 375494f..598cc44 100644 --- a/launcher/types.ts +++ b/launcher/types.ts @@ -1,5 +1,6 @@ import path from 'node:path'; import os from 'node:os'; +import { resolveDefaultLogFilePath } from '../src/shared/log-files.js'; export { VIDEO_EXTENSIONS } from '../src/shared/video-extensions.js'; export const ROFI_THEME_FILE = 'subminer.rasi'; @@ -29,21 +30,28 @@ export const DEFAULT_YOUTUBE_SUBGEN_OUT_DIR = path.join( 'subminer', 'youtube-subs', ); +export function getDefaultLauncherLogFile(options?: { + platform?: NodeJS.Platform; + homeDir?: string; + appDataDir?: string; +}): string { + return resolveDefaultLogFilePath('launcher', { + platform: options?.platform ?? process.platform, + homeDir: options?.homeDir ?? os.homedir(), + appDataDir: options?.appDataDir, + }); +} + export function getDefaultMpvLogFile(options?: { platform?: NodeJS.Platform; homeDir?: string; appDataDir?: string; }): string { - const platform = options?.platform ?? process.platform; - const homeDir = options?.homeDir ?? os.homedir(); - const baseDir = - platform === 'win32' - ? path.join( - options?.appDataDir?.trim() || path.join(homeDir, 'AppData', 'Roaming'), - 'SubMiner', - ) - : path.join(homeDir, '.config', 'SubMiner'); - return path.join(baseDir, 'logs', `SubMiner-${new Date().toISOString().slice(0, 10)}.log`); + return resolveDefaultLogFilePath('mpv', { + platform: options?.platform ?? process.platform, + homeDir: options?.homeDir ?? os.homedir(), + appDataDir: options?.appDataDir, + }); } export const DEFAULT_MPV_LOG_FILE = getDefaultMpvLogFile(); @@ -79,6 +87,7 @@ export interface Args { recursive: boolean; profile: string; startOverlay: boolean; + youtubeMode?: 'download' | 'generate'; whisperBin: string; whisperModel: string; whisperVadModel: string; diff --git a/package.json b/package.json index 43a8830..33a72a0 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "subminer", - "version": "0.8.0", + "version": "0.9.0", "description": "All-in-one sentence mining overlay with AnkiConnect and dictionary integration", "packageManager": "bun@1.3.5", "main": "dist/main-entry.js", diff --git a/plugin/subminer/options.lua b/plugin/subminer/options.lua index d30a11d..f084314 100644 --- a/plugin/subminer/options.lua +++ b/plugin/subminer/options.lua @@ -33,6 +33,7 @@ function M.load(options_lib, default_socket_path) auto_start = true, auto_start_visible_overlay = true, auto_start_pause_until_ready = true, + auto_start_pause_until_ready_timeout_seconds = 15, osd_messages = true, log_level = "info", aniskip_enabled = true, diff --git a/plugin/subminer/process.lua b/plugin/subminer/process.lua index 3c35055..54b09b1 100644 --- a/plugin/subminer/process.lua +++ b/plugin/subminer/process.lua @@ -2,7 +2,6 @@ local M = {} local OVERLAY_START_RETRY_DELAY_SECONDS = 0.2 local OVERLAY_START_MAX_ATTEMPTS = 6 -local AUTO_PLAY_READY_TIMEOUT_SECONDS = 15 local AUTO_PLAY_READY_LOADING_OSD = "Loading subtitle tokenization..." local AUTO_PLAY_READY_READY_OSD = "Subtitle tokenization ready" @@ -34,6 +33,23 @@ function M.create(ctx) return options_helper.coerce_bool(raw_pause_until_ready, false) end + local function resolve_pause_until_ready_timeout_seconds() + local raw_timeout_seconds = opts.auto_start_pause_until_ready_timeout_seconds + if raw_timeout_seconds == nil then + raw_timeout_seconds = opts["auto-start-pause-until-ready-timeout-seconds"] + end + if type(raw_timeout_seconds) == "number" then + return raw_timeout_seconds + end + if type(raw_timeout_seconds) == "string" then + local parsed = tonumber(raw_timeout_seconds) + if parsed ~= nil then + return parsed + end + end + return 15 + end + local function normalize_socket_path(path) if type(path) ~= "string" then return nil @@ -118,17 +134,20 @@ function M.create(ctx) end) end subminer_log("info", "process", "Pausing playback until SubMiner overlay/tokenization readiness signal") - state.auto_play_ready_timeout = mp.add_timeout(AUTO_PLAY_READY_TIMEOUT_SECONDS, function() - if not state.auto_play_ready_gate_armed then - return - end - subminer_log( - "warn", - "process", - "Startup readiness signal timed out; resuming playback to avoid stalled pause" - ) - release_auto_play_ready_gate("timeout") - end) + local timeout_seconds = resolve_pause_until_ready_timeout_seconds() + if timeout_seconds and timeout_seconds > 0 then + state.auto_play_ready_timeout = mp.add_timeout(timeout_seconds, function() + if not state.auto_play_ready_gate_armed then + return + end + subminer_log( + "warn", + "process", + "Startup readiness signal timed out; resuming playback to avoid stalled pause" + ) + release_auto_play_ready_gate("timeout") + end) + end end local function notify_auto_play_ready() diff --git a/src/cli/args.test.ts b/src/cli/args.test.ts index cbc9d70..f8d0ded 100644 --- a/src/cli/args.test.ts +++ b/src/cli/args.test.ts @@ -56,6 +56,15 @@ test('parseArgs captures launch-mpv targets and keeps it out of app startup', () assert.equal(shouldStartApp(args), false); }); +test('parseArgs captures youtube playback commands and mode', () => { + const args = parseArgs(['--youtube-play', 'https://youtube.com/watch?v=abc', '--youtube-mode', 'generate']); + + assert.equal(args.youtubePlay, 'https://youtube.com/watch?v=abc'); + assert.equal(args.youtubeMode, 'generate'); + assert.equal(hasExplicitCommand(args), true); + assert.equal(shouldStartApp(args), true); +}); + test('parseArgs handles jellyfin item listing controls', () => { const args = parseArgs([ '--jellyfin-items', diff --git a/src/cli/args.ts b/src/cli/args.ts index ad05bc5..08b8efc 100644 --- a/src/cli/args.ts +++ b/src/cli/args.ts @@ -3,6 +3,8 @@ export interface CliArgs { start: boolean; launchMpv: boolean; launchMpvTargets: string[]; + youtubePlay?: string; + youtubeMode?: 'download' | 'generate'; stop: boolean; toggle: boolean; toggleVisibleOverlay: boolean; @@ -79,6 +81,8 @@ export function parseArgs(argv: string[]): CliArgs { start: false, launchMpv: false, launchMpvTargets: [], + youtubePlay: undefined, + youtubeMode: undefined, stop: false, toggle: false, toggleVisibleOverlay: false, @@ -140,7 +144,19 @@ export function parseArgs(argv: string[]): CliArgs { if (arg === '--background') args.background = true; else if (arg === '--start') args.start = true; - else if (arg === '--launch-mpv') { + else if (arg.startsWith('--youtube-play=')) { + const value = arg.split('=', 2)[1]; + if (value) args.youtubePlay = value; + } else if (arg === '--youtube-play') { + const value = readValue(argv[i + 1]); + if (value) args.youtubePlay = value; + } else if (arg.startsWith('--youtube-mode=')) { + const value = arg.split('=', 2)[1]; + if (value === 'download' || value === 'generate') args.youtubeMode = value; + } else if (arg === '--youtube-mode') { + const value = readValue(argv[i + 1]); + if (value === 'download' || value === 'generate') args.youtubeMode = value; + } else if (arg === '--launch-mpv') { args.launchMpv = true; args.launchMpvTargets = argv.slice(i + 1).filter((value) => value && !value.startsWith('--')); break; @@ -334,6 +350,7 @@ export function hasExplicitCommand(args: CliArgs): boolean { return ( args.background || args.start || + Boolean(args.youtubePlay) || args.launchMpv || args.stop || args.toggle || @@ -385,6 +402,7 @@ export function shouldStartApp(args: CliArgs): boolean { if ( args.background || args.start || + Boolean(args.youtubePlay) || args.launchMpv || args.toggle || args.toggleVisibleOverlay || @@ -405,6 +423,7 @@ export function shouldStartApp(args: CliArgs): boolean { args.stats || args.jellyfin || args.jellyfinPlay || + Boolean(args.youtubePlay) || args.texthooker ) { if (args.launchMpv) { @@ -452,6 +471,7 @@ export function shouldRunSettingsOnlyStartup(args: CliArgs): boolean { !args.jellyfinItems && !args.jellyfinSubtitles && !args.jellyfinPlay && + !args.youtubePlay && !args.jellyfinRemoteAnnounce && !args.jellyfinPreviewAuth && !args.texthooker && @@ -481,5 +501,6 @@ export function commandNeedsOverlayRuntime(args: CliArgs): boolean { args.triggerSubsync || args.markAudioCard || args.openRuntimeOptions + || Boolean(args.youtubePlay) ); } diff --git a/src/cli/help.ts b/src/cli/help.ts index 3cb9731..5ea8b92 100644 --- a/src/cli/help.ts +++ b/src/cli/help.ts @@ -13,6 +13,8 @@ ${B}Session${R} --background Start in tray/background mode --start Connect to mpv and launch overlay --launch-mpv ${D}[targets...]${R} Launch mpv with the SubMiner mpv profile and exit + --youtube-play ${D}URL${R} Open YouTube subtitle picker flow for a URL + --youtube-mode ${D}download|generate${R} Subtitle acquisition mode for YouTube flow --stop Stop the running instance --stats Open the stats dashboard in your browser --texthooker Start texthooker server only ${D}(no overlay)${R} diff --git a/src/core/services/cli-command.test.ts b/src/core/services/cli-command.test.ts index a2539ab..21f4f30 100644 --- a/src/core/services/cli-command.test.ts +++ b/src/core/services/cli-command.test.ts @@ -9,6 +9,8 @@ function makeArgs(overrides: Partial = {}): CliArgs { start: false, launchMpv: false, launchMpvTargets: [], + youtubePlay: undefined, + youtubeMode: undefined, stop: false, toggle: false, toggleVisibleOverlay: false, @@ -184,6 +186,9 @@ function createDeps(overrides: Partial = {}) { runJellyfinCommand: async () => { calls.push('runJellyfinCommand'); }, + runYoutubePlaybackFlow: async ({ url, mode }) => { + calls.push(`runYoutubePlaybackFlow:${url}:${mode}`); + }, printHelp: () => { calls.push('printHelp'); }, @@ -226,6 +231,25 @@ test('handleCliCommand reconnects MPV for second-instance --start when overlay r ); }); +test('handleCliCommand starts youtube playback flow on initial launch', () => { + const { deps, calls } = createDeps({ + runYoutubePlaybackFlow: async (request) => { + calls.push(`youtube:${request.url}:${request.mode}`); + }, + }); + + handleCliCommand( + makeArgs({ youtubePlay: 'https://youtube.com/watch?v=abc', youtubeMode: 'generate' }), + 'initial', + deps, + ); + + assert.deepEqual(calls, [ + 'initializeOverlayRuntime', + 'youtube:https://youtube.com/watch?v=abc:generate', + ]); +}); + test('handleCliCommand processes --start for second-instance when overlay runtime is not initialized', () => { const { deps, calls } = createDeps(); const args = makeArgs({ start: true }); diff --git a/src/core/services/cli-command.ts b/src/core/services/cli-command.ts index 53fd819..95e32bb 100644 --- a/src/core/services/cli-command.ts +++ b/src/core/services/cli-command.ts @@ -63,6 +63,11 @@ export interface CliCommandServiceDeps { }>; runStatsCommand: (args: CliArgs, source: CliCommandSource) => Promise; runJellyfinCommand: (args: CliArgs) => Promise; + runYoutubePlaybackFlow: (request: { + url: string; + mode: NonNullable; + source: CliCommandSource; + }) => Promise; printHelp: () => void; hasMainWindow: () => boolean; getMultiCopyTimeoutMs: () => number; @@ -135,6 +140,7 @@ interface AnilistCliRuntime { interface AppCliRuntime { stop: () => void; hasMainWindow: () => boolean; + runYoutubePlaybackFlow: CliCommandServiceDeps['runYoutubePlaybackFlow']; } export interface CliCommandDepsRuntimeOptions { @@ -226,6 +232,7 @@ export function createCliCommandDepsRuntime( generateCharacterDictionary: options.dictionary.generate, runStatsCommand: options.jellyfin.runStatsCommand, runJellyfinCommand: options.jellyfin.runCommand, + runYoutubePlaybackFlow: options.app.runYoutubePlaybackFlow, printHelp: options.ui.printHelp, hasMainWindow: options.app.hasMainWindow, getMultiCopyTimeoutMs: options.getMultiCopyTimeoutMs, @@ -396,6 +403,19 @@ export function handleCliCommand( } else if (args.jellyfin) { deps.openJellyfinSetup(); deps.log('Opened Jellyfin setup flow.'); + } else if (args.youtubePlay) { + const youtubeUrl = args.youtubePlay; + runAsyncWithOsd( + () => + deps.runYoutubePlaybackFlow({ + url: youtubeUrl, + mode: args.youtubeMode ?? 'download', + source, + }), + deps, + 'runYoutubePlaybackFlow', + 'YouTube playback failed', + ); } else if (args.dictionary) { const shouldStopAfterRun = source === 'initial' && !deps.hasMainWindow(); deps.log('Generating character dictionary for current anime...'); diff --git a/src/core/services/ipc.test.ts b/src/core/services/ipc.test.ts index de8595f..5cb4a7c 100644 --- a/src/core/services/ipc.test.ts +++ b/src/core/services/ipc.test.ts @@ -144,6 +144,7 @@ function createRegisterIpcDeps(overrides: Partial = {}): IpcServ getAnilistQueueStatus: () => ({}), retryAnilistQueueNow: async () => ({ ok: true, message: 'ok' }), appendClipboardVideoToQueue: () => ({ ok: true, message: 'ok' }), + onYoutubePickerResolve: async () => ({ ok: true, message: 'ok' }), immersionTracker: null, ...overrides, }; @@ -236,6 +237,7 @@ test('createIpcDepsRuntime wires AniList handlers', async () => { return { ok: true, message: 'done' }; }, appendClipboardVideoToQueue: () => ({ ok: true, message: 'queued' }), + onYoutubePickerResolve: async () => ({ ok: true, message: 'ok' }), }); assert.deepEqual(deps.getAnilistStatus(), { tokenStatus: 'resolved' }); @@ -305,6 +307,7 @@ test('registerIpcHandlers rejects malformed runtime-option payloads', async () = getAnilistQueueStatus: () => ({}), retryAnilistQueueNow: async () => ({ ok: true, message: 'ok' }), appendClipboardVideoToQueue: () => ({ ok: true, message: 'ok' }), + onYoutubePickerResolve: async () => ({ ok: true, message: 'ok' }), }, registrar, ); @@ -611,6 +614,7 @@ test('registerIpcHandlers ignores malformed fire-and-forget payloads', () => { getAnilistQueueStatus: () => ({}), retryAnilistQueueNow: async () => ({ ok: true, message: 'ok' }), appendClipboardVideoToQueue: () => ({ ok: true, message: 'ok' }), + onYoutubePickerResolve: async () => ({ ok: true, message: 'ok' }), }, registrar, ); @@ -677,6 +681,7 @@ test('registerIpcHandlers awaits saveControllerPreference through request-respon getAnilistQueueStatus: () => ({}), retryAnilistQueueNow: async () => ({ ok: true, message: 'ok' }), appendClipboardVideoToQueue: () => ({ ok: true, message: 'ok' }), + onYoutubePickerResolve: async () => ({ ok: true, message: 'ok' }), }, registrar, ); @@ -746,6 +751,7 @@ test('registerIpcHandlers rejects malformed controller preference payloads', asy getAnilistQueueStatus: () => ({}), retryAnilistQueueNow: async () => ({ ok: true, message: 'ok' }), appendClipboardVideoToQueue: () => ({ ok: true, message: 'ok' }), + onYoutubePickerResolve: async () => ({ ok: true, message: 'ok' }), }, registrar, ); diff --git a/src/core/services/ipc.ts b/src/core/services/ipc.ts index a8a4612..331451a 100644 --- a/src/core/services/ipc.ts +++ b/src/core/services/ipc.ts @@ -10,6 +10,8 @@ import type { SubtitlePosition, SubsyncManualRunRequest, SubsyncResult, + YoutubePickerResolveRequest, + YoutubePickerResolveResult, } from '../../types'; import { IPC_CHANNELS, type OverlayHostedModal } from '../../shared/ipc/contracts'; import { @@ -23,6 +25,7 @@ import { parseRuntimeOptionValue, parseSubtitlePosition, parseSubsyncManualRunRequest, + parseYoutubePickerResolveRequest, } from '../../shared/ipc/validators'; const { BrowserWindow, ipcMain } = electron; @@ -61,6 +64,7 @@ export interface IpcServiceDeps { getCurrentSecondarySub: () => string; focusMainWindow: () => void; runSubsyncManual: (request: SubsyncManualRunRequest) => Promise; + onYoutubePickerResolve: (request: YoutubePickerResolveRequest) => Promise; getAnkiConnectStatus: () => boolean; getRuntimeOptions: () => unknown; setRuntimeOption: (id: RuntimeOptionId, value: RuntimeOptionValue) => unknown; @@ -163,6 +167,7 @@ export interface IpcDepsRuntimeOptions { getMpvClient: () => MpvClientLike | null; focusMainWindow: () => void; runSubsyncManual: (request: SubsyncManualRunRequest) => Promise; + onYoutubePickerResolve: (request: YoutubePickerResolveRequest) => Promise; getAnkiConnectStatus: () => boolean; getRuntimeOptions: () => unknown; setRuntimeOption: (id: RuntimeOptionId, value: RuntimeOptionValue) => unknown; @@ -225,6 +230,7 @@ export function createIpcDepsRuntime(options: IpcDepsRuntimeOptions): IpcService mainWindow.focus(); }, runSubsyncManual: options.runSubsyncManual, + onYoutubePickerResolve: options.onYoutubePickerResolve, getAnkiConnectStatus: options.getAnkiConnectStatus, getRuntimeOptions: options.getRuntimeOptions, setRuntimeOption: options.setRuntimeOption, @@ -285,6 +291,14 @@ export function registerIpcHandlers(deps: IpcServiceDeps, ipc: IpcMainRegistrar deps.onOverlayModalOpened(parsedModal); }); + ipc.handle(IPC_CHANNELS.request.youtubePickerResolve, async (_event: unknown, request: unknown) => { + const parsedRequest = parseYoutubePickerResolveRequest(request); + if (!parsedRequest) { + return { ok: false, message: 'Invalid YouTube picker resolve payload' }; + } + return await deps.onYoutubePickerResolve(parsedRequest); + }); + ipc.on(IPC_CHANNELS.command.openYomitanSettings, () => { deps.openYomitanSettings(); }); diff --git a/src/logger.test.ts b/src/logger.test.ts index 58eacf5..57eee62 100644 --- a/src/logger.test.ts +++ b/src/logger.test.ts @@ -17,7 +17,7 @@ test('resolveDefaultLogFilePath uses APPDATA on windows', () => { 'C:\\Users\\tester\\AppData\\Roaming', 'SubMiner', 'logs', - `SubMiner-${new Date().toISOString().slice(0, 10)}.log`, + `app-${new Date().toISOString().slice(0, 10)}.log`, ), ), ); @@ -36,7 +36,7 @@ test('resolveDefaultLogFilePath uses .config on linux', () => { '.config', 'SubMiner', 'logs', - `SubMiner-${new Date().toISOString().slice(0, 10)}.log`, + `app-${new Date().toISOString().slice(0, 10)}.log`, ), ); }); diff --git a/src/logger.ts b/src/logger.ts index 7e5a98f..64e69d3 100644 --- a/src/logger.ts +++ b/src/logger.ts @@ -1,6 +1,4 @@ -import fs from 'node:fs'; -import os from 'node:os'; -import path from 'node:path'; +import { appendLogLine, resolveDefaultLogFilePath as resolveSharedDefaultLogFilePath } from './shared/log-files'; export type LogLevel = 'debug' | 'info' | 'warn' | 'error'; export type LogLevelSource = 'cli' | 'config'; @@ -112,15 +110,11 @@ function safeStringify(value: unknown): string { } function resolveLogFilePath(): string { - const envPath = process.env.SUBMINER_MPV_LOG?.trim(); + const envPath = process.env.SUBMINER_APP_LOG?.trim(); if (envPath) { return envPath; } - return resolveDefaultLogFilePath({ - platform: process.platform, - homeDir: os.homedir(), - appDataDir: process.env.APPDATA, - }); + return resolveDefaultLogFilePath(); } export function resolveDefaultLogFilePath(options?: { @@ -128,27 +122,11 @@ export function resolveDefaultLogFilePath(options?: { homeDir?: string; appDataDir?: string; }): string { - const date = new Date().toISOString().slice(0, 10); - const platform = options?.platform ?? process.platform; - const homeDir = options?.homeDir ?? os.homedir(); - const baseDir = - platform === 'win32' - ? path.join( - options?.appDataDir?.trim() || path.join(homeDir, 'AppData', 'Roaming'), - 'SubMiner', - ) - : path.join(homeDir, '.config', 'SubMiner'); - return path.join(baseDir, 'logs', `SubMiner-${date}.log`); + return resolveSharedDefaultLogFilePath('app', options); } function appendToLogFile(line: string): void { - try { - const logPath = resolveLogFilePath(); - fs.mkdirSync(path.dirname(logPath), { recursive: true }); - fs.appendFileSync(logPath, `${line}\n`, { encoding: 'utf8' }); - } catch { - // never break runtime due to logging sink failures - } + appendLogLine(resolveLogFilePath(), line); } function emit(level: LogLevel, scope: string, message: string, meta: unknown[]): void { diff --git a/src/main/cli-runtime.ts b/src/main/cli-runtime.ts index 7d5c7af..4283893 100644 --- a/src/main/cli-runtime.ts +++ b/src/main/cli-runtime.ts @@ -1,5 +1,6 @@ import { handleCliCommand, createCliCommandDepsRuntime } from '../core/services'; import type { CliArgs, CliCommandSource } from '../cli/args'; +import type { YoutubeFlowMode } from '../types'; import { createCliCommandRuntimeServiceDeps, CliCommandRuntimeServiceDepsParams, @@ -38,6 +39,11 @@ export interface CliCommandRuntimeServiceContext { openJellyfinSetup: CliCommandRuntimeServiceDepsParams['jellyfin']['openSetup']; runStatsCommand: CliCommandRuntimeServiceDepsParams['jellyfin']['runStatsCommand']; runJellyfinCommand: CliCommandRuntimeServiceDepsParams['jellyfin']['runCommand']; + runYoutubePlaybackFlow: (request: { + url: string; + mode: YoutubeFlowMode; + source: CliCommandSource; + }) => Promise; openYomitanSettings: () => void; cycleSecondarySubMode: () => void; openRuntimeOptionsPalette: () => void; @@ -105,6 +111,11 @@ function createCliCommandDepsFromContext( runStatsCommand: context.runStatsCommand, runCommand: context.runJellyfinCommand, }, + app: { + stop: context.stopApp, + hasMainWindow: context.hasMainWindow, + runYoutubePlaybackFlow: context.runYoutubePlaybackFlow, + }, ui: { openFirstRunSetup: context.openFirstRunSetup, openYomitanSettings: context.openYomitanSettings, @@ -112,10 +123,6 @@ function createCliCommandDepsFromContext( openRuntimeOptionsPalette: context.openRuntimeOptionsPalette, printHelp: context.printHelp, }, - app: { - stop: context.stopApp, - hasMainWindow: context.hasMainWindow, - }, getMultiCopyTimeoutMs: context.getMultiCopyTimeoutMs, schedule: context.schedule, log: context.log, diff --git a/src/main/dependencies.ts b/src/main/dependencies.ts index debb8b9..4c74e0f 100644 --- a/src/main/dependencies.ts +++ b/src/main/dependencies.ts @@ -57,6 +57,7 @@ export interface MainIpcRuntimeServiceDepsParams { getVisibleOverlayVisibility: IpcDepsRuntimeOptions['getVisibleOverlayVisibility']; onOverlayModalClosed: IpcDepsRuntimeOptions['onOverlayModalClosed']; onOverlayModalOpened?: IpcDepsRuntimeOptions['onOverlayModalOpened']; + onYoutubePickerResolve: IpcDepsRuntimeOptions['onYoutubePickerResolve']; openYomitanSettings: IpcDepsRuntimeOptions['openYomitanSettings']; quitApp: IpcDepsRuntimeOptions['quitApp']; toggleVisibleOverlay: IpcDepsRuntimeOptions['toggleVisibleOverlay']; @@ -166,6 +167,11 @@ export interface CliCommandRuntimeServiceDepsParams { runStatsCommand: CliCommandDepsRuntimeOptions['jellyfin']['runStatsCommand']; runCommand: CliCommandDepsRuntimeOptions['jellyfin']['runCommand']; }; + app: { + stop: CliCommandDepsRuntimeOptions['app']['stop']; + hasMainWindow: CliCommandDepsRuntimeOptions['app']['hasMainWindow']; + runYoutubePlaybackFlow: CliCommandDepsRuntimeOptions['app']['runYoutubePlaybackFlow']; + }; ui: { openFirstRunSetup: CliCommandDepsRuntimeOptions['ui']['openFirstRunSetup']; openYomitanSettings: CliCommandDepsRuntimeOptions['ui']['openYomitanSettings']; @@ -173,10 +179,6 @@ export interface CliCommandRuntimeServiceDepsParams { openRuntimeOptionsPalette: CliCommandDepsRuntimeOptions['ui']['openRuntimeOptionsPalette']; printHelp: CliCommandDepsRuntimeOptions['ui']['printHelp']; }; - app: { - stop: CliCommandDepsRuntimeOptions['app']['stop']; - hasMainWindow: CliCommandDepsRuntimeOptions['app']['hasMainWindow']; - }; getMultiCopyTimeoutMs: CliCommandDepsRuntimeOptions['getMultiCopyTimeoutMs']; schedule: CliCommandDepsRuntimeOptions['schedule']; log: CliCommandDepsRuntimeOptions['log']; @@ -207,6 +209,7 @@ export function createMainIpcRuntimeServiceDeps( getVisibleOverlayVisibility: params.getVisibleOverlayVisibility, onOverlayModalClosed: params.onOverlayModalClosed, onOverlayModalOpened: params.onOverlayModalOpened, + onYoutubePickerResolve: params.onYoutubePickerResolve, openYomitanSettings: params.openYomitanSettings, quitApp: params.quitApp, toggleVisibleOverlay: params.toggleVisibleOverlay, @@ -324,6 +327,11 @@ export function createCliCommandRuntimeServiceDeps( runStatsCommand: params.jellyfin.runStatsCommand, runCommand: params.jellyfin.runCommand, }, + app: { + stop: params.app.stop, + hasMainWindow: params.app.hasMainWindow, + runYoutubePlaybackFlow: params.app.runYoutubePlaybackFlow, + }, ui: { openFirstRunSetup: params.ui.openFirstRunSetup, openYomitanSettings: params.ui.openYomitanSettings, @@ -331,10 +339,6 @@ export function createCliCommandRuntimeServiceDeps( openRuntimeOptionsPalette: params.ui.openRuntimeOptionsPalette, printHelp: params.ui.printHelp, }, - app: { - stop: params.app.stop, - hasMainWindow: params.app.hasMainWindow, - }, getMultiCopyTimeoutMs: params.getMultiCopyTimeoutMs, schedule: params.schedule, log: params.log, diff --git a/src/main/overlay-runtime.test.ts b/src/main/overlay-runtime.test.ts index b3cf8f2..a5d67a8 100644 --- a/src/main/overlay-runtime.test.ts +++ b/src/main/overlay-runtime.test.ts @@ -275,6 +275,82 @@ test('sendToActiveOverlayWindow prefers visible main overlay window for modal op assert.deepEqual(mainWindow.sent, [['runtime-options:open']]); }); +test('sendToActiveOverlayWindow can prefer modal window even when main overlay is visible', () => { + const mainWindow = createMockWindow(); + mainWindow.visible = true; + const modalWindow = createMockWindow(); + const runtime = createOverlayModalRuntimeService({ + getMainWindow: () => mainWindow as never, + getModalWindow: () => modalWindow as never, + createModalWindow: () => modalWindow as never, + getModalGeometry: () => ({ x: 0, y: 0, width: 400, height: 300 }), + setModalWindowBounds: () => {}, + }); + + const sent = runtime.sendToActiveOverlayWindow('youtube:picker-open', { sessionId: 'yt-1' }, { + restoreOnModalClose: 'youtube-track-picker', + preferModalWindow: true, + }); + + assert.equal(sent, true); + assert.deepEqual(mainWindow.sent, []); + assert.deepEqual(modalWindow.sent, [['youtube:picker-open', { sessionId: 'yt-1' }]]); +}); + +test('modal window path makes visible main overlay click-through until modal closes', () => { + const mainWindow = createMockWindow(); + mainWindow.visible = true; + const modalWindow = createMockWindow(); + const runtime = createOverlayModalRuntimeService({ + getMainWindow: () => mainWindow as never, + getModalWindow: () => modalWindow as never, + createModalWindow: () => modalWindow as never, + getModalGeometry: () => ({ x: 0, y: 0, width: 400, height: 300 }), + setModalWindowBounds: () => {}, + }); + + const sent = runtime.sendToActiveOverlayWindow('youtube:picker-open', { sessionId: 'yt-1' }, { + restoreOnModalClose: 'youtube-track-picker', + preferModalWindow: true, + }); + runtime.notifyOverlayModalOpened('youtube-track-picker'); + + assert.equal(sent, true); + assert.equal(mainWindow.ignoreMouseEvents, true); + assert.equal(modalWindow.ignoreMouseEvents, false); + + runtime.handleOverlayModalClosed('youtube-track-picker'); + + assert.equal(mainWindow.ignoreMouseEvents, false); +}); + +test('modal window path hides visible main overlay until modal closes', () => { + const mainWindow = createMockWindow(); + mainWindow.visible = true; + const modalWindow = createMockWindow(); + const runtime = createOverlayModalRuntimeService({ + getMainWindow: () => mainWindow as never, + getModalWindow: () => modalWindow as never, + createModalWindow: () => modalWindow as never, + getModalGeometry: () => ({ x: 0, y: 0, width: 400, height: 300 }), + setModalWindowBounds: () => {}, + }); + + runtime.sendToActiveOverlayWindow('youtube:picker-open', { sessionId: 'yt-1' }, { + restoreOnModalClose: 'youtube-track-picker', + preferModalWindow: true, + }); + runtime.notifyOverlayModalOpened('youtube-track-picker'); + + assert.equal(mainWindow.getHideCount(), 1); + assert.equal(mainWindow.isVisible(), false); + + runtime.handleOverlayModalClosed('youtube-track-picker'); + + assert.equal(mainWindow.getShowCount(), 1); + assert.equal(mainWindow.isVisible(), true); +}); + test('modal runtime notifies callers when modal input state becomes active/inactive', () => { const window = createMockWindow(); const state: boolean[] = []; @@ -430,3 +506,33 @@ test('modal fallback reveal keeps mouse events ignored until modal confirms open runtime.notifyOverlayModalOpened('jimaku'); assert.equal(window.ignoreMouseEvents, false); }); + +test('waitForModalOpen resolves true after modal acknowledgement', async () => { + const runtime = createOverlayModalRuntimeService({ + getMainWindow: () => null, + getModalWindow: () => null, + createModalWindow: () => null, + getModalGeometry: () => ({ x: 0, y: 0, width: 400, height: 300 }), + setModalWindowBounds: () => {}, + }); + + runtime.sendToActiveOverlayWindow('youtube:picker-open', { sessionId: 'yt-1' }, { + restoreOnModalClose: 'youtube-track-picker', + }); + const pending = runtime.waitForModalOpen('youtube-track-picker', 1000); + runtime.notifyOverlayModalOpened('youtube-track-picker'); + + assert.equal(await pending, true); +}); + +test('waitForModalOpen resolves false on timeout', async () => { + const runtime = createOverlayModalRuntimeService({ + getMainWindow: () => null, + getModalWindow: () => null, + createModalWindow: () => null, + getModalGeometry: () => ({ x: 0, y: 0, width: 400, height: 300 }), + setModalWindowBounds: () => {}, + }); + + assert.equal(await runtime.waitForModalOpen('youtube-track-picker', 5), false); +}); diff --git a/src/main/overlay-runtime.ts b/src/main/overlay-runtime.ts index 366862b..f6334b7 100644 --- a/src/main/overlay-runtime.ts +++ b/src/main/overlay-runtime.ts @@ -16,11 +16,15 @@ export interface OverlayModalRuntime { sendToActiveOverlayWindow: ( channel: string, payload?: unknown, - runtimeOptions?: { restoreOnModalClose?: OverlayHostedModal }, + runtimeOptions?: { + restoreOnModalClose?: OverlayHostedModal; + preferModalWindow?: boolean; + }, ) => boolean; openRuntimeOptionsPalette: () => void; handleOverlayModalClosed: (modal: OverlayHostedModal) => void; notifyOverlayModalOpened: (modal: OverlayHostedModal) => void; + waitForModalOpen: (modal: OverlayHostedModal, timeoutMs: number) => Promise; getRestoreVisibleOverlayOnModalClose: () => Set; } @@ -33,7 +37,10 @@ export function createOverlayModalRuntimeService( options: OverlayModalRuntimeOptions = {}, ): OverlayModalRuntime { const restoreVisibleOverlayOnModalClose = new Set(); + const modalOpenWaiters = new Map void>>(); let modalActive = false; + let mainWindowMousePassthroughForcedByModal = false; + let mainWindowHiddenByModal = false; let pendingModalWindowReveal: BrowserWindow | null = null; let pendingModalWindowRevealTimeout: ReturnType | null = null; @@ -163,6 +170,54 @@ export function createOverlayModalRuntimeService( pendingModalWindowReveal = null; }; + const setMainWindowMousePassthroughForModal = (enabled: boolean): void => { + const mainWindow = deps.getMainWindow(); + if (!mainWindow || mainWindow.isDestroyed()) { + mainWindowMousePassthroughForcedByModal = false; + return; + } + + if (enabled) { + if (!mainWindow.isVisible()) { + mainWindowMousePassthroughForcedByModal = false; + return; + } + mainWindow.setIgnoreMouseEvents(true, { forward: true }); + mainWindowMousePassthroughForcedByModal = true; + return; + } + + if (!mainWindowMousePassthroughForcedByModal) { + return; + } + mainWindow.setIgnoreMouseEvents(false); + mainWindowMousePassthroughForcedByModal = false; + }; + + const setMainWindowVisibilityForModal = (hidden: boolean): void => { + const mainWindow = deps.getMainWindow(); + if (!mainWindow || mainWindow.isDestroyed()) { + mainWindowHiddenByModal = false; + return; + } + + if (hidden) { + if (!mainWindow.isVisible()) { + mainWindowHiddenByModal = false; + return; + } + mainWindow.hide(); + mainWindowHiddenByModal = true; + return; + } + + if (!mainWindowHiddenByModal) { + return; + } + mainWindow.show(); + mainWindowHiddenByModal = false; + }; + const scheduleModalWindowReveal = (window: BrowserWindow): void => { pendingModalWindowReveal = window; if (pendingModalWindowRevealTimeout !== null) { @@ -182,9 +237,13 @@ export function createOverlayModalRuntimeService( const sendToActiveOverlayWindow = ( channel: string, payload?: unknown, - runtimeOptions?: { restoreOnModalClose?: OverlayHostedModal }, + runtimeOptions?: { + restoreOnModalClose?: OverlayHostedModal; + preferModalWindow?: boolean; + }, ): boolean => { const restoreOnModalClose = runtimeOptions?.restoreOnModalClose; + const preferModalWindow = runtimeOptions?.preferModalWindow === true; const sendNow = (window: BrowserWindow): void => { ensureModalWindowInteractive(window); @@ -198,7 +257,7 @@ export function createOverlayModalRuntimeService( if (restoreOnModalClose) { restoreVisibleOverlayOnModalClose.add(restoreOnModalClose); const mainWindow = getTargetOverlayWindow(); - if (mainWindow && !mainWindow.isDestroyed() && mainWindow.isVisible()) { + if (!preferModalWindow && mainWindow && !mainWindow.isDestroyed() && mainWindow.isVisible()) { sendOrQueueForWindow(mainWindow, (window) => { if (payload === undefined) { window.webContents.send(channel); @@ -255,6 +314,8 @@ export function createOverlayModalRuntimeService( if (restoreVisibleOverlayOnModalClose.size === 0) { clearPendingModalWindowReveal(); notifyModalStateChange(false); + setMainWindowMousePassthroughForModal(false); + setMainWindowVisibilityForModal(false); if (modalWindow && !modalWindow.isDestroyed()) { modalWindow.hide(); } @@ -263,6 +324,11 @@ export function createOverlayModalRuntimeService( const notifyOverlayModalOpened = (modal: OverlayHostedModal): void => { if (!restoreVisibleOverlayOnModalClose.has(modal)) return; + const waiters = modalOpenWaiters.get(modal) ?? []; + modalOpenWaiters.delete(modal); + for (const resolve of waiters) { + resolve(true); + } notifyModalStateChange(true); const targetWindow = getActiveOverlayWindowForModalInput(); clearPendingModalWindowReveal(); @@ -270,6 +336,12 @@ export function createOverlayModalRuntimeService( return; } + const modalWindow = deps.getModalWindow(); + if (modalWindow && !modalWindow.isDestroyed() && targetWindow === modalWindow) { + setMainWindowMousePassthroughForModal(true); + setMainWindowVisibilityForModal(true); + } + if (targetWindow.isVisible()) { targetWindow.setIgnoreMouseEvents(false); elevateModalWindow(targetWindow); @@ -285,11 +357,34 @@ export function createOverlayModalRuntimeService( showModalWindow(targetWindow); }; + const waitForModalOpen = async ( + modal: OverlayHostedModal, + timeoutMs: number, + ): Promise => + await new Promise((resolve) => { + const waiters = modalOpenWaiters.get(modal) ?? []; + const finish = (opened: boolean): void => { + clearTimeout(timeout); + resolve(opened); + }; + waiters.push(finish); + modalOpenWaiters.set(modal, waiters); + const timeout = setTimeout(() => { + const current = modalOpenWaiters.get(modal) ?? []; + modalOpenWaiters.set( + modal, + current.filter((candidate) => candidate !== finish), + ); + resolve(false); + }, timeoutMs); + }); + return { sendToActiveOverlayWindow, openRuntimeOptionsPalette, handleOverlayModalClosed, notifyOverlayModalOpened, + waitForModalOpen, getRestoreVisibleOverlayOnModalClose: () => restoreVisibleOverlayOnModalClose, }; } diff --git a/src/main/runtime/cli-command-context-deps.test.ts b/src/main/runtime/cli-command-context-deps.test.ts index 73e3809..4ee2d92 100644 --- a/src/main/runtime/cli-command-context-deps.test.ts +++ b/src/main/runtime/cli-command-context-deps.test.ts @@ -60,6 +60,9 @@ test('build cli command context deps maps handlers and values', () => { runJellyfinCommand: async () => { calls.push('run-jellyfin'); }, + runYoutubePlaybackFlow: async () => { + calls.push('run-youtube'); + }, openYomitanSettings: () => calls.push('yomitan'), cycleSecondarySubMode: () => calls.push('cycle-secondary'), openRuntimeOptionsPalette: () => calls.push('runtime-options'), diff --git a/src/main/runtime/cli-command-context-deps.ts b/src/main/runtime/cli-command-context-deps.ts index c8b10cd..1a8b2a9 100644 --- a/src/main/runtime/cli-command-context-deps.ts +++ b/src/main/runtime/cli-command-context-deps.ts @@ -36,6 +36,7 @@ export function createBuildCliCommandContextDepsHandler(deps: { generateCharacterDictionary: CliCommandContextFactoryDeps['generateCharacterDictionary']; runStatsCommand: CliCommandContextFactoryDeps['runStatsCommand']; runJellyfinCommand: (args: CliArgs) => Promise; + runYoutubePlaybackFlow: CliCommandContextFactoryDeps['runYoutubePlaybackFlow']; openYomitanSettings: () => void; cycleSecondarySubMode: () => void; openRuntimeOptionsPalette: () => void; @@ -83,6 +84,7 @@ export function createBuildCliCommandContextDepsHandler(deps: { generateCharacterDictionary: deps.generateCharacterDictionary, runStatsCommand: deps.runStatsCommand, runJellyfinCommand: deps.runJellyfinCommand, + runYoutubePlaybackFlow: deps.runYoutubePlaybackFlow, openYomitanSettings: deps.openYomitanSettings, cycleSecondarySubMode: deps.cycleSecondarySubMode, openRuntimeOptionsPalette: deps.openRuntimeOptionsPalette, diff --git a/src/main/runtime/cli-command-context-factory.test.ts b/src/main/runtime/cli-command-context-factory.test.ts index 3d329de..14e82c6 100644 --- a/src/main/runtime/cli-command-context-factory.test.ts +++ b/src/main/runtime/cli-command-context-factory.test.ts @@ -9,6 +9,7 @@ test('cli command context factory composes main deps and context handlers', () = mpvClient: null, texthookerPort: 5174, overlayRuntimeInitialized: false, + youtubePlaybackFlowPending: false, }; const createContext = createCliCommandContextFactory({ @@ -63,6 +64,7 @@ test('cli command context factory composes main deps and context handlers', () = }), runStatsCommand: async () => {}, runJellyfinCommand: async () => {}, + runYoutubePlaybackFlow: async () => {}, openYomitanSettings: () => {}, cycleSecondarySubMode: () => {}, openRuntimeOptionsPalette: () => {}, diff --git a/src/main/runtime/cli-command-context-main-deps.test.ts b/src/main/runtime/cli-command-context-main-deps.test.ts index 3c48ef2..19411a1 100644 --- a/src/main/runtime/cli-command-context-main-deps.test.ts +++ b/src/main/runtime/cli-command-context-main-deps.test.ts @@ -9,6 +9,7 @@ test('cli command context main deps builder maps state and callbacks', async () mpvClient: null, texthookerPort: 5174, overlayRuntimeInitialized: false, + youtubePlaybackFlowPending: false, }; const build = createBuildCliCommandContextMainDepsHandler({ @@ -84,6 +85,9 @@ test('cli command context main deps builder maps state and callbacks', async () runJellyfinCommand: async () => { calls.push('run-jellyfin'); }, + runYoutubePlaybackFlow: async () => { + calls.push('run-youtube'); + }, openYomitanSettings: () => calls.push('open-yomitan'), cycleSecondarySubMode: () => calls.push('cycle-secondary'), diff --git a/src/main/runtime/cli-command-context-main-deps.ts b/src/main/runtime/cli-command-context-main-deps.ts index 9e6dfe7..40e26f8 100644 --- a/src/main/runtime/cli-command-context-main-deps.ts +++ b/src/main/runtime/cli-command-context-main-deps.ts @@ -1,4 +1,5 @@ import type { CliArgs } from '../../cli/args'; +import type { YoutubeFlowMode } from '../../types'; import type { CliCommandContextFactoryDeps } from './cli-command-context'; type CliCommandContextMainState = { @@ -41,6 +42,11 @@ export function createBuildCliCommandContextMainDepsHandler(deps: { generateCharacterDictionary: CliCommandContextFactoryDeps['generateCharacterDictionary']; runStatsCommand: CliCommandContextFactoryDeps['runStatsCommand']; runJellyfinCommand: (args: CliArgs) => Promise; + runYoutubePlaybackFlow: (request: { + url: string; + mode: YoutubeFlowMode; + source: 'initial' | 'second-instance'; + }) => Promise; openYomitanSettings: () => void; cycleSecondarySubMode: () => void; @@ -95,6 +101,7 @@ export function createBuildCliCommandContextMainDepsHandler(deps: { deps.generateCharacterDictionary(targetPath), runStatsCommand: (args: CliArgs, source) => deps.runStatsCommand(args, source), runJellyfinCommand: (args: CliArgs) => deps.runJellyfinCommand(args), + runYoutubePlaybackFlow: (request) => deps.runYoutubePlaybackFlow(request), openYomitanSettings: () => deps.openYomitanSettings(), cycleSecondarySubMode: () => deps.cycleSecondarySubMode(), openRuntimeOptionsPalette: () => deps.openRuntimeOptionsPalette(), diff --git a/src/main/runtime/cli-command-context.test.ts b/src/main/runtime/cli-command-context.test.ts index 1eeb660..2953687 100644 --- a/src/main/runtime/cli-command-context.test.ts +++ b/src/main/runtime/cli-command-context.test.ts @@ -50,6 +50,7 @@ function createDeps() { }), runStatsCommand: async () => {}, runJellyfinCommand: async () => {}, + runYoutubePlaybackFlow: async () => {}, openYomitanSettings: () => {}, cycleSecondarySubMode: () => {}, openRuntimeOptionsPalette: () => {}, diff --git a/src/main/runtime/cli-command-context.ts b/src/main/runtime/cli-command-context.ts index de9d630..f9f0359 100644 --- a/src/main/runtime/cli-command-context.ts +++ b/src/main/runtime/cli-command-context.ts @@ -1,4 +1,5 @@ import type { CliArgs } from '../../cli/args'; +import type { YoutubeFlowMode } from '../../types'; import type { CliCommandRuntimeServiceContext, CliCommandRuntimeServiceContextHandlers, @@ -41,6 +42,11 @@ export type CliCommandContextFactoryDeps = { generateCharacterDictionary: CliCommandRuntimeServiceContext['generateCharacterDictionary']; runStatsCommand: CliCommandRuntimeServiceContext['runStatsCommand']; runJellyfinCommand: (args: CliArgs) => Promise; + runYoutubePlaybackFlow: (request: { + url: string; + mode: YoutubeFlowMode; + source: 'initial' | 'second-instance'; + }) => Promise; openYomitanSettings: () => void; cycleSecondarySubMode: () => void; openRuntimeOptionsPalette: () => void; @@ -95,6 +101,7 @@ export function createCliCommandContext( generateCharacterDictionary: deps.generateCharacterDictionary, runStatsCommand: deps.runStatsCommand, runJellyfinCommand: deps.runJellyfinCommand, + runYoutubePlaybackFlow: deps.runYoutubePlaybackFlow, openYomitanSettings: deps.openYomitanSettings, cycleSecondarySubMode: deps.cycleSecondarySubMode, openRuntimeOptionsPalette: deps.openRuntimeOptionsPalette, diff --git a/src/main/runtime/composers/ipc-runtime-composer.test.ts b/src/main/runtime/composers/ipc-runtime-composer.test.ts index 878a738..d46e3cb 100644 --- a/src/main/runtime/composers/ipc-runtime-composer.test.ts +++ b/src/main/runtime/composers/ipc-runtime-composer.test.ts @@ -67,6 +67,7 @@ test('composeIpcRuntimeHandlers returns callable IPC handlers and registration b getAnilistQueueStatus: () => ({}) as never, retryAnilistQueueNow: async () => ({ ok: true, message: 'ok' }), appendClipboardVideoToQueue: () => ({ ok: true, message: 'ok' }), + onYoutubePickerResolve: async () => ({ ok: true, message: 'ok' }), }, ankiJimakuDeps: { patchAnkiConnectEnabled: () => {}, diff --git a/src/main/runtime/composers/mpv-runtime-composer.test.ts b/src/main/runtime/composers/mpv-runtime-composer.test.ts index 72a780e..acf7093 100644 --- a/src/main/runtime/composers/mpv-runtime-composer.test.ts +++ b/src/main/runtime/composers/mpv-runtime-composer.test.ts @@ -72,6 +72,7 @@ test('composeMpvRuntimeHandlers returns callable handlers and forwards to inject currentSubAssText: '', playbackPaused: null, previousSecondarySubVisibility: null, + youtubePlaybackFlowPending: false, }, getQuitOnDisconnectArmed: () => false, scheduleQuitCheck: () => {}, @@ -280,6 +281,7 @@ test('composeMpvRuntimeHandlers skips MeCab warmup when all POS-dependent annota currentSubAssText: '', playbackPaused: null, previousSecondarySubVisibility: null, + youtubePlaybackFlowPending: false, }, getQuitOnDisconnectArmed: () => false, scheduleQuitCheck: () => {}, @@ -411,6 +413,7 @@ test('composeMpvRuntimeHandlers runs tokenization warmup once across sequential currentSubAssText: '', playbackPaused: null, previousSecondarySubVisibility: null, + youtubePlaybackFlowPending: false, }, getQuitOnDisconnectArmed: () => false, scheduleQuitCheck: () => {}, @@ -550,6 +553,7 @@ test('composeMpvRuntimeHandlers does not block first tokenization on dictionary currentSubAssText: '', playbackPaused: null, previousSecondarySubVisibility: null, + youtubePlaybackFlowPending: false, }, getQuitOnDisconnectArmed: () => false, scheduleQuitCheck: () => {}, @@ -683,6 +687,7 @@ test('composeMpvRuntimeHandlers shows annotation loading OSD after tokenization- currentSubAssText: '', playbackPaused: null, previousSecondarySubVisibility: null, + youtubePlaybackFlowPending: false, }, getQuitOnDisconnectArmed: () => false, scheduleQuitCheck: () => {}, @@ -830,6 +835,7 @@ test('composeMpvRuntimeHandlers reuses completed background tokenization warmups currentSubAssText: '', playbackPaused: null, previousSecondarySubVisibility: null, + youtubePlaybackFlowPending: false, }, getQuitOnDisconnectArmed: () => false, scheduleQuitCheck: () => {}, diff --git a/src/main/runtime/mpv-main-event-main-deps.test.ts b/src/main/runtime/mpv-main-event-main-deps.test.ts index 705d398..eb9b1df 100644 --- a/src/main/runtime/mpv-main-event-main-deps.test.ts +++ b/src/main/runtime/mpv-main-event-main-deps.test.ts @@ -25,6 +25,7 @@ test('mpv main event main deps map app state updates and delegate callbacks', as currentSubAssText: '', playbackPaused: null, previousSecondarySubVisibility: false, + youtubePlaybackFlowPending: false, }; const deps = createBuildBindMpvMainEventHandlersMainDepsHandler({ diff --git a/src/main/runtime/mpv-main-event-main-deps.ts b/src/main/runtime/mpv-main-event-main-deps.ts index 2523861..b3eb028 100644 --- a/src/main/runtime/mpv-main-event-main-deps.ts +++ b/src/main/runtime/mpv-main-event-main-deps.ts @@ -34,6 +34,7 @@ export function createBuildBindMpvMainEventHandlersMainDepsHandler(deps: { currentSubtitleData?: SubtitleData | null; playbackPaused: boolean | null; previousSecondarySubVisibility: boolean | null; + youtubePlaybackFlowPending: boolean; }; getQuitOnDisconnectArmed: () => boolean; scheduleQuitCheck: (callback: () => void) => void; diff --git a/src/main/runtime/windows-mpv-launch.ts b/src/main/runtime/windows-mpv-launch.ts index 8e3555a..7501565 100644 --- a/src/main/runtime/windows-mpv-launch.ts +++ b/src/main/runtime/windows-mpv-launch.ts @@ -33,13 +33,17 @@ export function resolveWindowsMpvPath(deps: WindowsMpvLaunchDeps): string { return ''; } -export function buildWindowsMpvLaunchArgs(targets: string[]): string[] { - return ['--player-operation-mode=pseudo-gui', '--profile=subminer', ...targets]; +export function buildWindowsMpvLaunchArgs( + targets: string[], + extraArgs: string[] = [], +): string[] { + return ['--player-operation-mode=pseudo-gui', '--profile=subminer', ...extraArgs, ...targets]; } export function launchWindowsMpv( targets: string[], deps: WindowsMpvLaunchDeps, + extraArgs: string[] = [], ): { ok: boolean; mpvPath: string } { const mpvPath = resolveWindowsMpvPath(deps); if (!mpvPath) { @@ -51,7 +55,7 @@ export function launchWindowsMpv( } try { - deps.spawnDetached(mpvPath, buildWindowsMpvLaunchArgs(targets)); + deps.spawnDetached(mpvPath, buildWindowsMpvLaunchArgs(targets, extraArgs)); return { ok: true, mpvPath }; } catch (error) { const message = error instanceof Error ? error.message : String(error); diff --git a/src/main/state.ts b/src/main/state.ts index 16372dd..6572288 100644 --- a/src/main/state.ts +++ b/src/main/state.ts @@ -188,6 +188,7 @@ export interface AppState { overlayDebugVisualizationEnabled: boolean; statsOverlayVisible: boolean; subsyncInProgress: boolean; + youtubePlaybackFlowPending: boolean; initialArgs: CliArgs | null; mpvSocketPath: string; texthookerPort: number; @@ -272,6 +273,7 @@ export function createAppState(values: AppStateInitialValues): AppState { fieldGroupingResolver: null, fieldGroupingResolverSequence: 0, subsyncInProgress: false, + youtubePlaybackFlowPending: false, initialArgs: null, mpvSocketPath: values.mpvSocketPath, texthookerPort: values.texthookerPort, @@ -291,6 +293,7 @@ export function createAppState(values: AppStateInitialValues): AppState { export function applyStartupState(appState: AppState, startupState: StartupState): void { appState.initialArgs = startupState.initialArgs; + appState.youtubePlaybackFlowPending = Boolean(startupState.initialArgs.youtubePlay); appState.mpvSocketPath = startupState.mpvSocketPath; appState.texthookerPort = startupState.texthookerPort; appState.backendOverride = startupState.backendOverride; diff --git a/src/shared/ipc/contracts.ts b/src/shared/ipc/contracts.ts index 2f39786..426ce00 100644 --- a/src/shared/ipc/contracts.ts +++ b/src/shared/ipc/contracts.ts @@ -4,6 +4,7 @@ export const OVERLAY_HOSTED_MODALS = [ 'runtime-options', 'subsync', 'jimaku', + 'youtube-track-picker', 'kiku', 'controller-select', 'controller-debug', @@ -18,6 +19,7 @@ export const IPC_CHANNELS = { openYomitanSettings: 'open-yomitan-settings', recordYomitanLookup: 'record-yomitan-lookup', quitApp: 'quit-app', + youtubePickerResolve: 'youtube:picker-resolve', toggleDevTools: 'toggle-dev-tools', toggleOverlay: 'toggle-overlay', saveSubtitlePosition: 'save-subtitle-position', @@ -51,6 +53,7 @@ export const IPC_CHANNELS = { getControllerConfig: 'get-controller-config', getSecondarySubMode: 'get-secondary-sub-mode', getCurrentSecondarySub: 'get-current-secondary-sub', + youtubePickerResolve: 'youtube:picker-resolve', focusMainWindow: 'focus-main-window', runSubsyncManual: 'subsync:run-manual', getAnkiConnectStatus: 'get-anki-connect-status', @@ -94,6 +97,8 @@ export const IPC_CHANNELS = { runtimeOptionsChanged: 'runtime-options:changed', runtimeOptionsOpen: 'runtime-options:open', jimakuOpen: 'jimaku:open', + youtubePickerOpen: 'youtube:picker-open', + youtubePickerCancel: 'youtube:picker-cancel', keyboardModeToggleRequested: 'keyboard-mode-toggle:requested', lookupWindowToggleRequested: 'lookup-window-toggle:requested', configHotReload: 'config:hot-reload', diff --git a/src/shared/ipc/validators.ts b/src/shared/ipc/validators.ts index e5dc48c..b0160e5 100644 --- a/src/shared/ipc/validators.ts +++ b/src/shared/ipc/validators.ts @@ -10,6 +10,7 @@ import type { RuntimeOptionValue, SubtitlePosition, SubsyncManualRunRequest, + YoutubePickerResolveRequest, } from '../../types'; import { OVERLAY_HOSTED_MODALS, type OverlayHostedModal } from './contracts'; @@ -253,3 +254,25 @@ export function parseJimakuDownloadQuery(value: unknown): JimakuDownloadQuery | name: value.name, }; } + +export function parseYoutubePickerResolveRequest(value: unknown): YoutubePickerResolveRequest | null { + if (!isObject(value)) return null; + if (typeof value.sessionId !== 'string' || !value.sessionId.trim()) return null; + if (value.action !== 'use-selected' && value.action !== 'continue-without-subtitles') return null; + if (value.primaryTrackId !== null && value.primaryTrackId !== undefined && typeof value.primaryTrackId !== 'string') { + return null; + } + if ( + value.secondaryTrackId !== null && + value.secondaryTrackId !== undefined && + typeof value.secondaryTrackId !== 'string' + ) { + return null; + } + return { + sessionId: value.sessionId, + action: value.action, + primaryTrackId: value.primaryTrackId ?? null, + secondaryTrackId: value.secondaryTrackId ?? null, + }; +}