refactor: unify cli and runtime wiring for startup and youtube flow

This commit is contained in:
2026-03-22 18:38:54 -07:00
parent 3fb33af116
commit 7d8d2ae7a7
48 changed files with 1009 additions and 370 deletions

View File

@@ -553,10 +553,12 @@ export function buildSubminerScriptOpts(
socketPath: string, socketPath: string,
aniSkipMetadata: AniSkipMetadata | null, aniSkipMetadata: AniSkipMetadata | null,
logLevel: LogLevel = 'info', logLevel: LogLevel = 'info',
extraParts: string[] = [],
): string { ): string {
const parts = [ const parts = [
`subminer-binary_path=${sanitizeScriptOptValue(appPath)}`, `subminer-binary_path=${sanitizeScriptOptValue(appPath)}`,
`subminer-socket_path=${sanitizeScriptOptValue(socketPath)}`, `subminer-socket_path=${sanitizeScriptOptValue(socketPath)}`,
...extraParts,
]; ];
if (logLevel !== 'info') { if (logLevel !== 'info') {
parts.push(`subminer-log_level=${sanitizeScriptOptValue(logLevel)}`); parts.push(`subminer-log_level=${sanitizeScriptOptValue(logLevel)}`);

View File

@@ -149,20 +149,16 @@ test('doctor command forwards refresh-known-words to app binary', () => {
context.args.doctorRefreshKnownWords = true; context.args.doctorRefreshKnownWords = true;
const forwarded: string[][] = []; const forwarded: string[][] = [];
assert.throws( const handled = runDoctorCommand(context, {
() =>
runDoctorCommand(context, {
commandExists: () => false, commandExists: () => false,
configExists: () => true, configExists: () => true,
resolveMainConfigPath: () => '/tmp/SubMiner/config.jsonc', resolveMainConfigPath: () => '/tmp/SubMiner/config.jsonc',
runAppCommandWithInherit: (_appPath, appArgs) => { runAppCommandWithInherit: (_appPath, appArgs) => {
forwarded.push(appArgs); forwarded.push(appArgs);
throw new ExitSignal(0);
}, },
}), });
(error: unknown) => error instanceof ExitSignal && error.code === 0,
);
assert.equal(handled, true);
assert.deepEqual(forwarded, [['--refresh-known-words']]); 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'; context.args.dictionaryTarget = '/tmp/anime';
const forwarded: string[][] = []; const forwarded: string[][] = [];
assert.throws( const handled = runDictionaryCommand(context, {
() =>
runDictionaryCommand(context, {
runAppCommandWithInherit: (_appPath, appArgs) => { runAppCommandWithInherit: (_appPath, appArgs) => {
forwarded.push(appArgs); forwarded.push(appArgs);
throw new ExitSignal(0);
}, },
}), });
(error: unknown) => error instanceof ExitSignal && error.code === 0,
);
assert.equal(handled, true);
assert.deepEqual(forwarded, [['--dictionary', '--dictionary-target', '/tmp/anime']]); 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(); const context = createContext();
context.args.dictionary = true; context.args.dictionary = true;
assert.throws( const handled = runDictionaryCommand(context, {
() => runAppCommandWithInherit: () => undefined,
runDictionaryCommand(context, { });
runAppCommandWithInherit: () => undefined as never,
}), assert.equal(handled, true);
/unexpectedly returned/,
);
}); });
test('stats command launches attached app command with response path', async () => { test('stats command launches attached app command with response path', async () => {

View File

@@ -2,7 +2,7 @@ import { runAppCommandWithInherit } from '../mpv.js';
import type { LauncherCommandContext } from './context.js'; import type { LauncherCommandContext } from './context.js';
interface DictionaryCommandDeps { interface DictionaryCommandDeps {
runAppCommandWithInherit: (appPath: string, appArgs: string[]) => never; runAppCommandWithInherit: (appPath: string, appArgs: string[]) => void;
} }
const defaultDeps: DictionaryCommandDeps = { const defaultDeps: DictionaryCommandDeps = {
@@ -27,5 +27,5 @@ export function runDictionaryCommand(
} }
deps.runAppCommandWithInherit(appPath, forwarded); deps.runAppCommandWithInherit(appPath, forwarded);
throw new Error('Dictionary command app handoff unexpectedly returned.'); return true;
} }

View File

@@ -9,7 +9,7 @@ interface DoctorCommandDeps {
commandExists(command: string): boolean; commandExists(command: string): boolean;
configExists(path: string): boolean; configExists(path: string): boolean;
resolveMainConfigPath(): string; resolveMainConfigPath(): string;
runAppCommandWithInherit(appPath: string, appArgs: string[]): never; runAppCommandWithInherit(appPath: string, appArgs: string[]): void;
} }
const defaultDeps: DoctorCommandDeps = { const defaultDeps: DoctorCommandDeps = {
@@ -85,6 +85,7 @@ export function runDoctorCommand(
return true; return true;
} }
deps.runAppCommandWithInherit(appPath, ['--refresh-known-words']); deps.runAppCommandWithInherit(appPath, ['--refresh-known-words']);
return true;
} }
const hasHardFailure = checks.some((entry) => const hasHardFailure = checks.some((entry) =>

View File

@@ -21,6 +21,7 @@ export async function runJellyfinCommand(context: LauncherCommandContext): Promi
if (args.logLevel !== 'info') forwarded.push('--log-level', args.logLevel); if (args.logLevel !== 'info') forwarded.push('--log-level', args.logLevel);
appendPasswordStore(forwarded); appendPasswordStore(forwarded);
runAppCommandWithInherit(appPath, forwarded); runAppCommandWithInherit(appPath, forwarded);
return true;
} }
if (args.jellyfinLogin) { if (args.jellyfinLogin) {
@@ -44,6 +45,7 @@ export async function runJellyfinCommand(context: LauncherCommandContext): Promi
if (args.logLevel !== 'info') forwarded.push('--log-level', args.logLevel); if (args.logLevel !== 'info') forwarded.push('--log-level', args.logLevel);
appendPasswordStore(forwarded); appendPasswordStore(forwarded);
runAppCommandWithInherit(appPath, forwarded); runAppCommandWithInherit(appPath, forwarded);
return true;
} }
if (args.jellyfinLogout) { if (args.jellyfinLogout) {
@@ -51,6 +53,7 @@ export async function runJellyfinCommand(context: LauncherCommandContext): Promi
if (args.logLevel !== 'info') forwarded.push('--log-level', args.logLevel); if (args.logLevel !== 'info') forwarded.push('--log-level', args.logLevel);
appendPasswordStore(forwarded); appendPasswordStore(forwarded);
runAppCommandWithInherit(appPath, forwarded); runAppCommandWithInherit(appPath, forwarded);
return true;
} }
if (args.jellyfinPlay) { if (args.jellyfinPlay) {
@@ -69,6 +72,7 @@ export async function runJellyfinCommand(context: LauncherCommandContext): Promi
if (args.logLevel !== 'info') forwarded.push('--log-level', args.logLevel); if (args.logLevel !== 'info') forwarded.push('--log-level', args.logLevel);
appendPasswordStore(forwarded); appendPasswordStore(forwarded);
runAppCommandWithInherit(appPath, forwarded); runAppCommandWithInherit(appPath, forwarded);
return true;
} }
return Boolean( return Boolean(

View File

@@ -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',
]);
});

View File

@@ -6,13 +6,13 @@ import { commandExists, isYoutubeTarget, realpathMaybe, resolvePathMaybe } from
import { collectVideos, showFzfMenu, showRofiMenu } from '../picker.js'; import { collectVideos, showFzfMenu, showRofiMenu } from '../picker.js';
import { import {
cleanupPlaybackSession, cleanupPlaybackSession,
launchAppCommandDetached,
startMpv, startMpv,
startOverlay, startOverlay,
state, state,
stopOverlay, stopOverlay,
waitForUnixSocketReady, waitForUnixSocketReady,
} from '../mpv.js'; } from '../mpv.js';
import { generateYoutubeSubtitles } from '../youtube.js';
import type { Args } from '../types.js'; import type { Args } from '../types.js';
import type { LauncherCommandContext } from './context.js'; import type { LauncherCommandContext } from './context.js';
import { ensureLauncherSetupReady } from '../setup-gate.js'; import { ensureLauncherSetupReady } from '../setup-gate.js';
@@ -126,30 +126,66 @@ async function ensurePlaybackSetupReady(context: LauncherCommandContext): Promis
} }
export async function runPlaybackCommand(context: LauncherCommandContext): Promise<void> { export async function runPlaybackCommand(context: LauncherCommandContext): Promise<void> {
return runPlaybackCommandWithDeps(context, {
ensurePlaybackSetupReady,
chooseTarget,
checkDependencies,
registerCleanup,
startMpv,
waitForUnixSocketReady,
startOverlay,
launchAppCommandDetached,
log,
cleanupPlaybackSession,
getMpvProc: () => state.mpvProc,
});
}
type PlaybackCommandDeps = {
ensurePlaybackSetupReady: (context: LauncherCommandContext) => Promise<void>;
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<void> {
const { args, appPath, scriptPath, mpvSocketPath, pluginRuntimeConfig, processAdapter } = context; const { args, appPath, scriptPath, mpvSocketPath, pluginRuntimeConfig, processAdapter } = context;
if (!appPath) { if (!appPath) {
fail('SubMiner AppImage not found. Install to ~/.local/bin/ or set SUBMINER_APPIMAGE_PATH.'); fail('SubMiner AppImage not found. Install to ~/.local/bin/ or set SUBMINER_APPIMAGE_PATH.');
} }
await ensurePlaybackSetupReady(context); await deps.ensurePlaybackSetupReady(context);
if (!args.target) { if (!args.target) {
checkPickerDependencies(args); checkPickerDependencies(args);
} }
const targetChoice = await chooseTarget(args, scriptPath); const targetChoice = await deps.chooseTarget(args, scriptPath);
if (!targetChoice) { if (!targetChoice) {
log('info', args.logLevel, 'No video selected, exiting'); deps.log('info', args.logLevel, 'No video selected, exiting');
processAdapter.exit(0); processAdapter.exit(0);
} }
checkDependencies({ deps.checkDependencies({
...args, ...args,
target: targetChoice ? targetChoice.target : args.target, target: targetChoice ? targetChoice.target : args.target,
targetKind: targetChoice ? targetChoice.kind : 'url', targetKind: targetChoice ? targetChoice.kind : 'url',
}); });
registerCleanup(context); deps.registerCleanup(context);
const selectedTarget = targetChoice const selectedTarget = targetChoice
? { ? {
@@ -159,30 +195,11 @@ export async function runPlaybackCommand(context: LauncherCommandContext): Promi
: { target: args.target, kind: 'url' as const }; : { target: args.target, kind: 'url' as const };
const isYoutubeUrl = selectedTarget.kind === 'url' && isYoutubeTarget(selectedTarget.target); 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) { if (isYoutubeUrl) {
log('info', args.logLevel, 'YouTube subtitle generation: preload before mpv'); deps.log('info', args.logLevel, 'YouTube subtitle flow: app-owned picker after mpv bootstrap');
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}`,
);
} }
const shouldPauseUntilOverlayReady = const shouldPauseUntilOverlayReady =
@@ -191,47 +208,57 @@ export async function runPlaybackCommand(context: LauncherCommandContext): Promi
pluginRuntimeConfig.autoStartPauseUntilReady; pluginRuntimeConfig.autoStartPauseUntilReady;
if (shouldPauseUntilOverlayReady) { 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.target,
selectedTarget.kind, selectedTarget.kind,
args, args,
mpvSocketPath, mpvSocketPath,
appPath, appPath,
preloadedSubtitles, undefined,
{ startPaused: shouldPauseUntilOverlayReady }, {
startPaused: shouldPauseUntilOverlayReady || isAppOwnedYoutubeFlow,
disableYoutubeSubtitleAutoLoad: isAppOwnedYoutubeFlow,
},
); );
const ready = await waitForUnixSocketReady(mpvSocketPath, 10000); const ready = await deps.waitForUnixSocketReady(mpvSocketPath, 10000);
const pluginAutoStartEnabled = pluginRuntimeConfig.autoStart; const pluginAutoStartEnabled = pluginRuntimeConfig.autoStart;
const shouldStartOverlay = args.startOverlay || args.autoStartOverlay; const shouldStartOverlay = args.startOverlay || args.autoStartOverlay || isAppOwnedYoutubeFlow;
if (shouldStartOverlay) { if (shouldStartOverlay) {
if (ready) { 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 { } else {
log( deps.log(
'info', 'info',
args.logLevel, args.logLevel,
'MPV IPC socket not ready after timeout, starting SubMiner overlay anyway', '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) { } else if (pluginAutoStartEnabled) {
if (ready) { 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 { } 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) { } else if (ready) {
log( deps.log(
'info', 'info',
args.logLevel, args.logLevel,
'MPV IPC socket ready, overlay auto-start disabled (use y-s to start)', 'MPV IPC socket ready, overlay auto-start disabled (use y-s to start)',
); );
} else { } else {
log( deps.log(
'info', 'info',
args.logLevel, args.logLevel,
'MPV IPC socket not ready yet, overlay auto-start disabled (use y-s to start)', '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<void>((resolve) => { await new Promise<void>((resolve) => {
const mpvProc = state.mpvProc; const mpvProc = deps.getMpvProc();
if (!mpvProc) { if (!mpvProc) {
stopOverlay(args); stopOverlay(args);
resolve(); resolve();
@@ -247,7 +274,7 @@ export async function runPlaybackCommand(context: LauncherCommandContext): Promi
} }
const finalize = (code: number | null | undefined) => { const finalize = (code: number | null | undefined) => {
void cleanupPlaybackSession(args).finally(() => { void deps.cleanupPlaybackSession(args).finally(() => {
processAdapter.setExitCode(code ?? 0); processAdapter.setExitCode(code ?? 0);
resolve(); resolve();
}); });

View File

@@ -111,6 +111,7 @@ export function createDefaultArgs(launcherConfig: LauncherYoutubeSubgenConfig):
youtubeSubgenAudioFormat: process.env.SUBMINER_YT_SUBGEN_AUDIO_FORMAT || 'm4a', youtubeSubgenAudioFormat: process.env.SUBMINER_YT_SUBGEN_AUDIO_FORMAT || 'm4a',
youtubeSubgenKeepTemp: process.env.SUBMINER_YT_SUBGEN_KEEP_TEMP === '1', youtubeSubgenKeepTemp: process.env.SUBMINER_YT_SUBGEN_KEEP_TEMP === '1',
youtubeFixWithAi: launcherConfig.fixWithAi === true, youtubeFixWithAi: launcherConfig.fixWithAi === true,
youtubeMode: undefined,
jimakuApiKey: process.env.SUBMINER_JIMAKU_API_KEY || '', jimakuApiKey: process.env.SUBMINER_JIMAKU_API_KEY || '',
jimakuApiKeyCommand: process.env.SUBMINER_JIMAKU_API_KEY_COMMAND || '', jimakuApiKeyCommand: process.env.SUBMINER_JIMAKU_API_KEY_COMMAND || '',
jimakuApiBaseUrl: process.env.SUBMINER_JIMAKU_API_BASE_URL || DEFAULT_JIMAKU_API_BASE_URL, 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) {
if (invocations.ytInvocation.mode) {
parsed.youtubeMode = invocations.ytInvocation.mode;
}
if (invocations.ytInvocation.logLevel) if (invocations.ytInvocation.logLevel)
parsed.logLevel = parseLogLevel(invocations.ytInvocation.logLevel); parsed.logLevel = parseLogLevel(invocations.ytInvocation.logLevel);
if (invocations.ytInvocation.outDir) if (invocations.ytInvocation.outDir)

View File

@@ -16,6 +16,7 @@ export interface JellyfinInvocation {
export interface YtInvocation { export interface YtInvocation {
target?: string; target?: string;
mode?: 'download' | 'generate';
outDir?: string; outDir?: string;
keepTemp?: boolean; keepTemp?: boolean;
whisperBin?: string; whisperBin?: string;
@@ -222,6 +223,7 @@ export function parseCliPrograms(
.alias('youtube') .alias('youtube')
.description('YouTube workflows') .description('YouTube workflows')
.argument('[target]', 'YouTube URL or ytsearch: query') .argument('[target]', 'YouTube URL or ytsearch: query')
.option('--mode <mode>', 'YouTube subtitle acquisition mode')
.option('-o, --out-dir <dir>', 'Subtitle output dir') .option('-o, --out-dir <dir>', 'Subtitle output dir')
.option('--keep-temp', 'Keep temp files') .option('--keep-temp', 'Keep temp files')
.option('--whisper-bin <path>', 'whisper.cpp CLI path') .option('--whisper-bin <path>', 'whisper.cpp CLI path')
@@ -233,6 +235,10 @@ export function parseCliPrograms(
.action((target: string | undefined, options: Record<string, unknown>) => { .action((target: string | undefined, options: Record<string, unknown>) => {
ytInvocation = { ytInvocation = {
target, target,
mode:
typeof options.mode === 'string' && (options.mode === 'download' || options.mode === 'generate')
? options.mode
: undefined,
outDir: typeof options.outDir === 'string' ? options.outDir : undefined, outDir: typeof options.outDir === 'string' ? options.outDir : undefined,
keepTemp: options.keepTemp === true, keepTemp: options.keepTemp === true,
whisperBin: typeof options.whisperBin === 'string' ? options.whisperBin : undefined, whisperBin: typeof options.whisperBin === 'string' ? options.whisperBin : undefined,

View File

@@ -1,7 +1,7 @@
import test from 'node:test'; import test from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import path from 'node:path'; import path from 'node:path';
import { getDefaultMpvLogFile } from './types.js'; import { getDefaultLauncherLogFile, getDefaultMpvLogFile } from './types.js';
test('getDefaultMpvLogFile uses APPDATA on windows', () => { test('getDefaultMpvLogFile uses APPDATA on windows', () => {
const resolved = getDefaultMpvLogFile({ const resolved = getDefaultMpvLogFile({
@@ -17,8 +17,26 @@ test('getDefaultMpvLogFile uses APPDATA on windows', () => {
'C:\\Users\\tester\\AppData\\Roaming', 'C:\\Users\\tester\\AppData\\Roaming',
'SubMiner', 'SubMiner',
'logs', '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`,
),
);
});

View File

@@ -1,7 +1,6 @@
import fs from 'node:fs';
import path from 'node:path';
import type { LogLevel } from './types.js'; 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 = { export const COLORS = {
red: '\x1b[0;31m', red: '\x1b[0;31m',
@@ -28,14 +27,32 @@ export function getMpvLogPath(): string {
return DEFAULT_MPV_LOG_FILE; 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 { export function appendToMpvLog(message: string): void {
const logPath = getMpvLogPath(); appendTimestampedLog(getMpvLogPath(), message);
try { }
fs.mkdirSync(path.dirname(logPath), { recursive: true });
fs.appendFileSync(logPath, `[${new Date().toISOString()}] ${message}\n`, { encoding: 'utf8' }); export function appendToLauncherLog(message: string): void {
} catch { appendTimestampedLog(getLauncherLogPath(), message);
// ignore logging failures }
}
export function appendToAppLog(message: string): void {
appendTimestampedLog(getAppLogPath(), message);
} }
export function log(level: LogLevel, configured: LogLevel, message: string): void { 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.red
: COLORS.cyan; : COLORS.cyan;
process.stdout.write(`${color}[${level.toUpperCase()}]${COLORS.reset} ${message}\n`); process.stdout.write(`${color}[${level.toUpperCase()}]${COLORS.reset} ${message}\n`);
appendToMpvLog(`[${level.toUpperCase()}] ${message}`); appendToLauncherLog(`[${level.toUpperCase()}] ${message}`);
} }
export function fail(message: string): never { export function fail(message: string): never {
process.stderr.write(`${COLORS.red}[ERROR]${COLORS.reset} ${message}\n`); process.stderr.write(`${COLORS.red}[ERROR]${COLORS.reset} ${message}\n`);
appendToMpvLog(`[ERROR] ${message}`); appendToLauncherLog(`[ERROR] ${message}`);
process.exit(1); process.exit(1);
} }

View File

@@ -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 }, () => { test('launcher forwards --args to mpv as parsed tokens', { timeout: 15000 }, () => {
withTempDir((root) => { withTempDir((root) => {
const homeDir = path.join(root, 'home'); 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', () => { test('dictionary command forwards --dictionary and --dictionary-target to app command path', () => {
withTempDir((root) => { withTempDir((root) => {
const homeDir = path.join(root, 'home'); const homeDir = path.join(root, 'home');

View File

@@ -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 { dir } = createTempSocketPath();
const appPath = path.join(dir, 'fake-subminer.sh'); const appPath = path.join(dir, 'fake-subminer.sh');
const appInvocationsPath = path.join(dir, 'app-invocations.log'); const appInvocationsPath = path.join(dir, 'app-invocations.log');
@@ -345,8 +385,8 @@ test('cleanupPlaybackSession preserves background app while stopping mpv-owned c
try { try {
await cleanupPlaybackSession(makeArgs()); await cleanupPlaybackSession(makeArgs());
assert.deepEqual(calls, ['mpv-kill', 'helper-kill']); assert.deepEqual(calls, ['overlay-kill', 'mpv-kill', 'helper-kill']);
assert.equal(fs.existsSync(appInvocationsPath), false); assert.match(fs.readFileSync(appInvocationsPath, 'utf8'), /--stop/);
} finally { } finally {
state.overlayProc = null; state.overlayProc = null;
state.mpvProc = null; state.mpvProc = null;

View File

@@ -5,7 +5,7 @@ import net from 'node:net';
import { spawn, spawnSync } from 'node:child_process'; import { spawn, spawnSync } from 'node:child_process';
import type { LogLevel, Backend, Args, MpvTrack } from './types.js'; import type { LogLevel, Backend, Args, MpvTrack } from './types.js';
import { DEFAULT_MPV_SUBMINER_ARGS, DEFAULT_YOUTUBE_YTDL_FORMAT } 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 { buildSubminerScriptOpts, resolveAniSkipMetadataForFile } from './aniskip-metadata.js';
import { import {
commandExists, commandExists,
@@ -542,7 +542,7 @@ export async function startMpv(
socketPath: string, socketPath: string,
appPath: string, appPath: string,
preloadedSubtitles?: { primaryPath?: string; secondaryPath?: string }, preloadedSubtitles?: { primaryPath?: string; secondaryPath?: string },
options?: { startPaused?: boolean }, options?: { startPaused?: boolean; disableYoutubeSubtitleAutoLoad?: boolean },
): Promise<void> { ): Promise<void> {
if (targetKind === 'file' && (!fs.existsSync(target) || !fs.statSync(target).isFile())) { if (targetKind === 'file' && (!fs.existsSync(target) || !fs.statSync(target).isFile())) {
fail(`Video file not found: ${target}`); fail(`Video file not found: ${target}`);
@@ -575,6 +575,7 @@ export async function startMpv(
log('debug', args.logLevel, `YouTube subtitle langs: ${subtitleLangs}`); log('debug', args.logLevel, `YouTube subtitle langs: ${subtitleLangs}`);
log('debug', args.logLevel, `YouTube audio langs: ${audioLangs}`); log('debug', args.logLevel, `YouTube audio langs: ${audioLangs}`);
mpvArgs.push(`--ytdl-format=${DEFAULT_YOUTUBE_YTDL_FORMAT}`, `--alang=${audioLangs}`); mpvArgs.push(`--ytdl-format=${DEFAULT_YOUTUBE_YTDL_FORMAT}`, `--alang=${audioLangs}`);
if (options?.disableYoutubeSubtitleAutoLoad !== true) {
mpvArgs.push( mpvArgs.push(
'--sub-auto=fuzzy', '--sub-auto=fuzzy',
`--slang=${subtitleLangs}`, `--slang=${subtitleLangs}`,
@@ -582,6 +583,9 @@ export async function startMpv(
'--ytdl-raw-options-append=sub-format=vtt/best', '--ytdl-raw-options-append=sub-format=vtt/best',
`--ytdl-raw-options-append=sub-langs=${subtitleLangs}`, `--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) const aniSkipMetadata = shouldResolveAniSkipMetadata(target, targetKind, preloadedSubtitles)
? await resolveAniSkipMetadataForFile(target) ? await resolveAniSkipMetadataForFile(target)
: null; : 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) { if (aniSkipMetadata) {
log( log(
'debug', 'debug',
@@ -661,19 +675,25 @@ async function waitForOverlayStartCommandSettled(
}); });
} }
export async function startOverlay(appPath: string, args: Args, socketPath: string): Promise<void> { export async function startOverlay(
appPath: string,
args: Args,
socketPath: string,
extraAppArgs: string[] = [],
): Promise<void> {
const backend = detectBackend(args.backend); const backend = detectBackend(args.backend);
log('info', args.logLevel, `Starting SubMiner overlay (backend: ${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.logLevel !== 'info') overlayArgs.push('--log-level', args.logLevel);
if (args.useTexthooker) overlayArgs.push('--texthooker'); if (args.useTexthooker) overlayArgs.push('--texthooker');
const target = resolveAppSpawnTarget(appPath, overlayArgs); const target = resolveAppSpawnTarget(appPath, overlayArgs);
state.overlayProc = spawn(target.command, target.args, { state.overlayProc = spawn(target.command, target.args, {
stdio: 'inherit', stdio: ['ignore', 'pipe', 'pipe'],
env: buildAppEnv(), env: buildAppEnv(),
}); });
attachAppProcessLogging(state.overlayProc);
state.overlayManagedByLauncher = true; state.overlayManagedByLauncher = true;
const [socketReady] = await Promise.all([ 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); if (args.logLevel !== 'info') overlayArgs.push('--log-level', args.logLevel);
log('info', args.logLevel, 'Launching texthooker mode...'); log('info', args.logLevel, 'Launching texthooker mode...');
const result = spawnSync(appPath, overlayArgs, { const result = runSyncAppCommand(appPath, overlayArgs, true);
stdio: 'inherit',
env: buildAppEnv(),
});
if (result.error) { if (result.error) {
fail(`Failed to launch texthooker mode: ${result.error.message}`); fail(`Failed to launch texthooker mode: ${result.error.message}`);
} }
@@ -713,30 +730,7 @@ export function stopOverlay(args: Args): void {
if (state.stopRequested) return; if (state.stopRequested) return;
state.stopRequested = true; state.stopRequested = true;
if (state.overlayManagedByLauncher && state.appPath) { stopManagedOverlayApp(args);
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
}
}
}
if (state.mpvProc && !state.mpvProc.killed) { if (state.mpvProc && !state.mpvProc.killed) {
try { try {
@@ -761,6 +755,8 @@ export function stopOverlay(args: Args): void {
} }
export async function cleanupPlaybackSession(args: Args): Promise<void> { export async function cleanupPlaybackSession(args: Args): Promise<void> {
stopManagedOverlayApp(args);
if (state.mpvProc && !state.mpvProc.killed) { if (state.mpvProc && !state.mpvProc.killed) {
try { try {
state.mpvProc.kill('SIGTERM'); state.mpvProc.kill('SIGTERM');
@@ -783,9 +779,39 @@ export async function cleanupPlaybackSession(args: Args): Promise<void> {
await terminateTrackedDetachedMpv(args.logLevel); 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 { function buildAppEnv(): NodeJS.ProcessEnv {
const env: Record<string, string | undefined> = { const env: Record<string, string | undefined> = {
...process.env, ...process.env,
SUBMINER_APP_LOG: getAppLogPath(),
SUBMINER_MPV_LOG: getMpvLogPath(), SUBMINER_MPV_LOG: getMpvLogPath(),
}; };
delete env.ELECTRON_RUN_AS_NODE; delete env.ELECTRON_RUN_AS_NODE;
@@ -804,6 +830,64 @@ function buildAppEnv(): NodeJS.ProcessEnv {
return env; 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<typeof spawn>,
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 { function maybeCaptureAppArgs(appArgs: string[]): boolean {
const capturePath = process.env.SUBMINER_TEST_CAPTURE?.trim(); const capturePath = process.env.SUBMINER_TEST_CAPTURE?.trim();
if (!capturePath) { if (!capturePath) {
@@ -821,20 +905,23 @@ function resolveAppSpawnTarget(appPath: string, appArgs: string[]): SpawnTarget
return resolveCommandInvocation(appPath, appArgs); return resolveCommandInvocation(appPath, appArgs);
} }
export function runAppCommandWithInherit(appPath: string, appArgs: string[]): never { export function runAppCommandWithInherit(appPath: string, appArgs: string[]): void {
if (maybeCaptureAppArgs(appArgs)) { if (maybeCaptureAppArgs(appArgs)) {
process.exit(0); process.exit(0);
} }
const target = resolveAppSpawnTarget(appPath, appArgs); const target = resolveAppSpawnTarget(appPath, appArgs);
const result = spawnSync(target.command, target.args, { const proc = spawn(target.command, target.args, {
stdio: 'inherit', stdio: ['ignore', 'pipe', 'pipe'],
env: buildAppEnv(), env: buildAppEnv(),
}); });
if (result.error) { attachAppProcessLogging(proc, { mirrorStdout: true, mirrorStderr: true });
fail(`Failed to run app command: ${result.error.message}`); proc.once('error', (error) => {
} fail(`Failed to run app command: ${error.message}`);
process.exit(result.status ?? 0); });
proc.once('exit', (code) => {
process.exit(code ?? 0);
});
} }
export function runAppCommandCaptureOutput( export function runAppCommandCaptureOutput(
@@ -854,18 +941,7 @@ export function runAppCommandCaptureOutput(
}; };
} }
const target = resolveAppSpawnTarget(appPath, appArgs); return runSyncAppCommand(appPath, appArgs, false);
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,
};
} }
export function runAppCommandAttached( export function runAppCommandAttached(
@@ -887,9 +963,10 @@ export function runAppCommandAttached(
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const proc = spawn(target.command, target.args, { const proc = spawn(target.command, target.args, {
stdio: 'inherit', stdio: ['ignore', 'pipe', 'pipe'],
env: buildAppEnv(), env: buildAppEnv(),
}); });
attachAppProcessLogging(proc, { mirrorStdout: true, mirrorStderr: true });
proc.once('error', (error) => { proc.once('error', (error) => {
reject(error); reject(error);
}); });
@@ -921,10 +998,7 @@ export function runAppCommandWithInheritLogged(
logLevel, logLevel,
`${label}: launching app with args: ${[target.command, ...target.args].join(' ')}`, `${label}: launching app with args: ${[target.command, ...target.args].join(' ')}`,
); );
const result = spawnSync(target.command, target.args, { const result = runSyncAppCommand(appPath, appArgs, true);
stdio: 'inherit',
env: buildAppEnv(),
});
if (result.error) { if (result.error) {
fail(`Failed to run app command: ${result.error.message}`); fail(`Failed to run app command: ${result.error.message}`);
} }
@@ -953,8 +1027,13 @@ export function launchAppCommandDetached(
logLevel, logLevel,
`${label}: launching detached app with args: ${[target.command, ...target.args].join(' ')}`, `${label}: launching detached app with args: ${[target.command, ...target.args].join(' ')}`,
); );
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, { const proc = spawn(target.command, target.args, {
stdio: 'ignore', stdio: ['ignore', stdoutFd, stderrFd],
detached: true, detached: true,
env: buildAppEnv(), env: buildAppEnv(),
}); });
@@ -962,6 +1041,10 @@ export function launchAppCommandDetached(
log('warn', logLevel, `${label}: failed to launch detached app: ${error.message}`); log('warn', logLevel, `${label}: failed to launch detached app: ${error.message}`);
}); });
proc.unref(); proc.unref();
} finally {
fs.closeSync(stdoutFd);
fs.closeSync(stderrFd);
}
} }
export function launchMpvIdleDetached( export function launchMpvIdleDetached(

View File

@@ -85,6 +85,13 @@ test('parseArgs maps mpv idle action', () => {
assert.equal(parsed.mpvStatus, false); 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', () => { test('parseArgs maps dictionary command and log-level override', () => {
const parsed = parseArgs(['dictionary', '.', '--log-level', 'debug'], 'subminer', {}); const parsed = parseArgs(['dictionary', '.', '--log-level', 'debug'], 'subminer', {});

View File

@@ -324,7 +324,7 @@ test(
assert.match(result.stdout, /Starting SubMiner overlay/i); assert.match(result.stdout, /Starting SubMiner overlay/i);
assert.equal(appStartEntries.length, 1); assert.equal(appStartEntries.length, 1);
assert.equal(appStopEntries.length, 0); assert.equal(appStopEntries.length, 1);
assert.equal(mpvEntries.length >= 1, true); assert.equal(mpvEntries.length >= 1, true);
const appStartArgs = appStartEntries[0]?.argv; const appStartArgs = appStartEntries[0]?.argv;

View File

@@ -1,5 +1,6 @@
import path from 'node:path'; import path from 'node:path';
import os from 'node:os'; import os from 'node:os';
import { resolveDefaultLogFilePath } from '../src/shared/log-files.js';
export { VIDEO_EXTENSIONS } from '../src/shared/video-extensions.js'; export { VIDEO_EXTENSIONS } from '../src/shared/video-extensions.js';
export const ROFI_THEME_FILE = 'subminer.rasi'; export const ROFI_THEME_FILE = 'subminer.rasi';
@@ -29,21 +30,28 @@ export const DEFAULT_YOUTUBE_SUBGEN_OUT_DIR = path.join(
'subminer', 'subminer',
'youtube-subs', '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?: { export function getDefaultMpvLogFile(options?: {
platform?: NodeJS.Platform; platform?: NodeJS.Platform;
homeDir?: string; homeDir?: string;
appDataDir?: string; appDataDir?: string;
}): string { }): string {
const platform = options?.platform ?? process.platform; return resolveDefaultLogFilePath('mpv', {
const homeDir = options?.homeDir ?? os.homedir(); platform: options?.platform ?? process.platform,
const baseDir = homeDir: options?.homeDir ?? os.homedir(),
platform === 'win32' appDataDir: options?.appDataDir,
? 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`);
} }
export const DEFAULT_MPV_LOG_FILE = getDefaultMpvLogFile(); export const DEFAULT_MPV_LOG_FILE = getDefaultMpvLogFile();
@@ -79,6 +87,7 @@ export interface Args {
recursive: boolean; recursive: boolean;
profile: string; profile: string;
startOverlay: boolean; startOverlay: boolean;
youtubeMode?: 'download' | 'generate';
whisperBin: string; whisperBin: string;
whisperModel: string; whisperModel: string;
whisperVadModel: string; whisperVadModel: string;

View File

@@ -1,6 +1,6 @@
{ {
"name": "subminer", "name": "subminer",
"version": "0.8.0", "version": "0.9.0",
"description": "All-in-one sentence mining overlay with AnkiConnect and dictionary integration", "description": "All-in-one sentence mining overlay with AnkiConnect and dictionary integration",
"packageManager": "bun@1.3.5", "packageManager": "bun@1.3.5",
"main": "dist/main-entry.js", "main": "dist/main-entry.js",

View File

@@ -33,6 +33,7 @@ function M.load(options_lib, default_socket_path)
auto_start = true, auto_start = true,
auto_start_visible_overlay = true, auto_start_visible_overlay = true,
auto_start_pause_until_ready = true, auto_start_pause_until_ready = true,
auto_start_pause_until_ready_timeout_seconds = 15,
osd_messages = true, osd_messages = true,
log_level = "info", log_level = "info",
aniskip_enabled = true, aniskip_enabled = true,

View File

@@ -2,7 +2,6 @@ local M = {}
local OVERLAY_START_RETRY_DELAY_SECONDS = 0.2 local OVERLAY_START_RETRY_DELAY_SECONDS = 0.2
local OVERLAY_START_MAX_ATTEMPTS = 6 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_LOADING_OSD = "Loading subtitle tokenization..."
local AUTO_PLAY_READY_READY_OSD = "Subtitle tokenization ready" 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) return options_helper.coerce_bool(raw_pause_until_ready, false)
end 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) local function normalize_socket_path(path)
if type(path) ~= "string" then if type(path) ~= "string" then
return nil return nil
@@ -118,7 +134,9 @@ function M.create(ctx)
end) end)
end end
subminer_log("info", "process", "Pausing playback until SubMiner overlay/tokenization readiness signal") 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() 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 if not state.auto_play_ready_gate_armed then
return return
end end
@@ -130,6 +148,7 @@ function M.create(ctx)
release_auto_play_ready_gate("timeout") release_auto_play_ready_gate("timeout")
end) end)
end end
end
local function notify_auto_play_ready() local function notify_auto_play_ready()
release_auto_play_ready_gate("tokenization-ready") release_auto_play_ready_gate("tokenization-ready")

View File

@@ -56,6 +56,15 @@ test('parseArgs captures launch-mpv targets and keeps it out of app startup', ()
assert.equal(shouldStartApp(args), false); 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', () => { test('parseArgs handles jellyfin item listing controls', () => {
const args = parseArgs([ const args = parseArgs([
'--jellyfin-items', '--jellyfin-items',

View File

@@ -3,6 +3,8 @@ export interface CliArgs {
start: boolean; start: boolean;
launchMpv: boolean; launchMpv: boolean;
launchMpvTargets: string[]; launchMpvTargets: string[];
youtubePlay?: string;
youtubeMode?: 'download' | 'generate';
stop: boolean; stop: boolean;
toggle: boolean; toggle: boolean;
toggleVisibleOverlay: boolean; toggleVisibleOverlay: boolean;
@@ -79,6 +81,8 @@ export function parseArgs(argv: string[]): CliArgs {
start: false, start: false,
launchMpv: false, launchMpv: false,
launchMpvTargets: [], launchMpvTargets: [],
youtubePlay: undefined,
youtubeMode: undefined,
stop: false, stop: false,
toggle: false, toggle: false,
toggleVisibleOverlay: false, toggleVisibleOverlay: false,
@@ -140,7 +144,19 @@ export function parseArgs(argv: string[]): CliArgs {
if (arg === '--background') args.background = true; if (arg === '--background') args.background = true;
else if (arg === '--start') args.start = 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.launchMpv = true;
args.launchMpvTargets = argv.slice(i + 1).filter((value) => value && !value.startsWith('--')); args.launchMpvTargets = argv.slice(i + 1).filter((value) => value && !value.startsWith('--'));
break; break;
@@ -334,6 +350,7 @@ export function hasExplicitCommand(args: CliArgs): boolean {
return ( return (
args.background || args.background ||
args.start || args.start ||
Boolean(args.youtubePlay) ||
args.launchMpv || args.launchMpv ||
args.stop || args.stop ||
args.toggle || args.toggle ||
@@ -385,6 +402,7 @@ export function shouldStartApp(args: CliArgs): boolean {
if ( if (
args.background || args.background ||
args.start || args.start ||
Boolean(args.youtubePlay) ||
args.launchMpv || args.launchMpv ||
args.toggle || args.toggle ||
args.toggleVisibleOverlay || args.toggleVisibleOverlay ||
@@ -405,6 +423,7 @@ export function shouldStartApp(args: CliArgs): boolean {
args.stats || args.stats ||
args.jellyfin || args.jellyfin ||
args.jellyfinPlay || args.jellyfinPlay ||
Boolean(args.youtubePlay) ||
args.texthooker args.texthooker
) { ) {
if (args.launchMpv) { if (args.launchMpv) {
@@ -452,6 +471,7 @@ export function shouldRunSettingsOnlyStartup(args: CliArgs): boolean {
!args.jellyfinItems && !args.jellyfinItems &&
!args.jellyfinSubtitles && !args.jellyfinSubtitles &&
!args.jellyfinPlay && !args.jellyfinPlay &&
!args.youtubePlay &&
!args.jellyfinRemoteAnnounce && !args.jellyfinRemoteAnnounce &&
!args.jellyfinPreviewAuth && !args.jellyfinPreviewAuth &&
!args.texthooker && !args.texthooker &&
@@ -481,5 +501,6 @@ export function commandNeedsOverlayRuntime(args: CliArgs): boolean {
args.triggerSubsync || args.triggerSubsync ||
args.markAudioCard || args.markAudioCard ||
args.openRuntimeOptions args.openRuntimeOptions
|| Boolean(args.youtubePlay)
); );
} }

View File

@@ -13,6 +13,8 @@ ${B}Session${R}
--background Start in tray/background mode --background Start in tray/background mode
--start Connect to mpv and launch overlay --start Connect to mpv and launch overlay
--launch-mpv ${D}[targets...]${R} Launch mpv with the SubMiner mpv profile and exit --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 --stop Stop the running instance
--stats Open the stats dashboard in your browser --stats Open the stats dashboard in your browser
--texthooker Start texthooker server only ${D}(no overlay)${R} --texthooker Start texthooker server only ${D}(no overlay)${R}

View File

@@ -9,6 +9,8 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
start: false, start: false,
launchMpv: false, launchMpv: false,
launchMpvTargets: [], launchMpvTargets: [],
youtubePlay: undefined,
youtubeMode: undefined,
stop: false, stop: false,
toggle: false, toggle: false,
toggleVisibleOverlay: false, toggleVisibleOverlay: false,
@@ -184,6 +186,9 @@ function createDeps(overrides: Partial<CliCommandServiceDeps> = {}) {
runJellyfinCommand: async () => { runJellyfinCommand: async () => {
calls.push('runJellyfinCommand'); calls.push('runJellyfinCommand');
}, },
runYoutubePlaybackFlow: async ({ url, mode }) => {
calls.push(`runYoutubePlaybackFlow:${url}:${mode}`);
},
printHelp: () => { printHelp: () => {
calls.push('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', () => { test('handleCliCommand processes --start for second-instance when overlay runtime is not initialized', () => {
const { deps, calls } = createDeps(); const { deps, calls } = createDeps();
const args = makeArgs({ start: true }); const args = makeArgs({ start: true });

View File

@@ -63,6 +63,11 @@ export interface CliCommandServiceDeps {
}>; }>;
runStatsCommand: (args: CliArgs, source: CliCommandSource) => Promise<void>; runStatsCommand: (args: CliArgs, source: CliCommandSource) => Promise<void>;
runJellyfinCommand: (args: CliArgs) => Promise<void>; runJellyfinCommand: (args: CliArgs) => Promise<void>;
runYoutubePlaybackFlow: (request: {
url: string;
mode: NonNullable<CliArgs['youtubeMode']>;
source: CliCommandSource;
}) => Promise<void>;
printHelp: () => void; printHelp: () => void;
hasMainWindow: () => boolean; hasMainWindow: () => boolean;
getMultiCopyTimeoutMs: () => number; getMultiCopyTimeoutMs: () => number;
@@ -135,6 +140,7 @@ interface AnilistCliRuntime {
interface AppCliRuntime { interface AppCliRuntime {
stop: () => void; stop: () => void;
hasMainWindow: () => boolean; hasMainWindow: () => boolean;
runYoutubePlaybackFlow: CliCommandServiceDeps['runYoutubePlaybackFlow'];
} }
export interface CliCommandDepsRuntimeOptions { export interface CliCommandDepsRuntimeOptions {
@@ -226,6 +232,7 @@ export function createCliCommandDepsRuntime(
generateCharacterDictionary: options.dictionary.generate, generateCharacterDictionary: options.dictionary.generate,
runStatsCommand: options.jellyfin.runStatsCommand, runStatsCommand: options.jellyfin.runStatsCommand,
runJellyfinCommand: options.jellyfin.runCommand, runJellyfinCommand: options.jellyfin.runCommand,
runYoutubePlaybackFlow: options.app.runYoutubePlaybackFlow,
printHelp: options.ui.printHelp, printHelp: options.ui.printHelp,
hasMainWindow: options.app.hasMainWindow, hasMainWindow: options.app.hasMainWindow,
getMultiCopyTimeoutMs: options.getMultiCopyTimeoutMs, getMultiCopyTimeoutMs: options.getMultiCopyTimeoutMs,
@@ -396,6 +403,19 @@ export function handleCliCommand(
} else if (args.jellyfin) { } else if (args.jellyfin) {
deps.openJellyfinSetup(); deps.openJellyfinSetup();
deps.log('Opened Jellyfin setup flow.'); 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) { } else if (args.dictionary) {
const shouldStopAfterRun = source === 'initial' && !deps.hasMainWindow(); const shouldStopAfterRun = source === 'initial' && !deps.hasMainWindow();
deps.log('Generating character dictionary for current anime...'); deps.log('Generating character dictionary for current anime...');

View File

@@ -144,6 +144,7 @@ function createRegisterIpcDeps(overrides: Partial<IpcServiceDeps> = {}): IpcServ
getAnilistQueueStatus: () => ({}), getAnilistQueueStatus: () => ({}),
retryAnilistQueueNow: async () => ({ ok: true, message: 'ok' }), retryAnilistQueueNow: async () => ({ ok: true, message: 'ok' }),
appendClipboardVideoToQueue: () => ({ ok: true, message: 'ok' }), appendClipboardVideoToQueue: () => ({ ok: true, message: 'ok' }),
onYoutubePickerResolve: async () => ({ ok: true, message: 'ok' }),
immersionTracker: null, immersionTracker: null,
...overrides, ...overrides,
}; };
@@ -236,6 +237,7 @@ test('createIpcDepsRuntime wires AniList handlers', async () => {
return { ok: true, message: 'done' }; return { ok: true, message: 'done' };
}, },
appendClipboardVideoToQueue: () => ({ ok: true, message: 'queued' }), appendClipboardVideoToQueue: () => ({ ok: true, message: 'queued' }),
onYoutubePickerResolve: async () => ({ ok: true, message: 'ok' }),
}); });
assert.deepEqual(deps.getAnilistStatus(), { tokenStatus: 'resolved' }); assert.deepEqual(deps.getAnilistStatus(), { tokenStatus: 'resolved' });
@@ -305,6 +307,7 @@ test('registerIpcHandlers rejects malformed runtime-option payloads', async () =
getAnilistQueueStatus: () => ({}), getAnilistQueueStatus: () => ({}),
retryAnilistQueueNow: async () => ({ ok: true, message: 'ok' }), retryAnilistQueueNow: async () => ({ ok: true, message: 'ok' }),
appendClipboardVideoToQueue: () => ({ ok: true, message: 'ok' }), appendClipboardVideoToQueue: () => ({ ok: true, message: 'ok' }),
onYoutubePickerResolve: async () => ({ ok: true, message: 'ok' }),
}, },
registrar, registrar,
); );
@@ -611,6 +614,7 @@ test('registerIpcHandlers ignores malformed fire-and-forget payloads', () => {
getAnilistQueueStatus: () => ({}), getAnilistQueueStatus: () => ({}),
retryAnilistQueueNow: async () => ({ ok: true, message: 'ok' }), retryAnilistQueueNow: async () => ({ ok: true, message: 'ok' }),
appendClipboardVideoToQueue: () => ({ ok: true, message: 'ok' }), appendClipboardVideoToQueue: () => ({ ok: true, message: 'ok' }),
onYoutubePickerResolve: async () => ({ ok: true, message: 'ok' }),
}, },
registrar, registrar,
); );
@@ -677,6 +681,7 @@ test('registerIpcHandlers awaits saveControllerPreference through request-respon
getAnilistQueueStatus: () => ({}), getAnilistQueueStatus: () => ({}),
retryAnilistQueueNow: async () => ({ ok: true, message: 'ok' }), retryAnilistQueueNow: async () => ({ ok: true, message: 'ok' }),
appendClipboardVideoToQueue: () => ({ ok: true, message: 'ok' }), appendClipboardVideoToQueue: () => ({ ok: true, message: 'ok' }),
onYoutubePickerResolve: async () => ({ ok: true, message: 'ok' }),
}, },
registrar, registrar,
); );
@@ -746,6 +751,7 @@ test('registerIpcHandlers rejects malformed controller preference payloads', asy
getAnilistQueueStatus: () => ({}), getAnilistQueueStatus: () => ({}),
retryAnilistQueueNow: async () => ({ ok: true, message: 'ok' }), retryAnilistQueueNow: async () => ({ ok: true, message: 'ok' }),
appendClipboardVideoToQueue: () => ({ ok: true, message: 'ok' }), appendClipboardVideoToQueue: () => ({ ok: true, message: 'ok' }),
onYoutubePickerResolve: async () => ({ ok: true, message: 'ok' }),
}, },
registrar, registrar,
); );

View File

@@ -10,6 +10,8 @@ import type {
SubtitlePosition, SubtitlePosition,
SubsyncManualRunRequest, SubsyncManualRunRequest,
SubsyncResult, SubsyncResult,
YoutubePickerResolveRequest,
YoutubePickerResolveResult,
} from '../../types'; } from '../../types';
import { IPC_CHANNELS, type OverlayHostedModal } from '../../shared/ipc/contracts'; import { IPC_CHANNELS, type OverlayHostedModal } from '../../shared/ipc/contracts';
import { import {
@@ -23,6 +25,7 @@ import {
parseRuntimeOptionValue, parseRuntimeOptionValue,
parseSubtitlePosition, parseSubtitlePosition,
parseSubsyncManualRunRequest, parseSubsyncManualRunRequest,
parseYoutubePickerResolveRequest,
} from '../../shared/ipc/validators'; } from '../../shared/ipc/validators';
const { BrowserWindow, ipcMain } = electron; const { BrowserWindow, ipcMain } = electron;
@@ -61,6 +64,7 @@ export interface IpcServiceDeps {
getCurrentSecondarySub: () => string; getCurrentSecondarySub: () => string;
focusMainWindow: () => void; focusMainWindow: () => void;
runSubsyncManual: (request: SubsyncManualRunRequest) => Promise<SubsyncResult>; runSubsyncManual: (request: SubsyncManualRunRequest) => Promise<SubsyncResult>;
onYoutubePickerResolve: (request: YoutubePickerResolveRequest) => Promise<YoutubePickerResolveResult>;
getAnkiConnectStatus: () => boolean; getAnkiConnectStatus: () => boolean;
getRuntimeOptions: () => unknown; getRuntimeOptions: () => unknown;
setRuntimeOption: (id: RuntimeOptionId, value: RuntimeOptionValue) => unknown; setRuntimeOption: (id: RuntimeOptionId, value: RuntimeOptionValue) => unknown;
@@ -163,6 +167,7 @@ export interface IpcDepsRuntimeOptions {
getMpvClient: () => MpvClientLike | null; getMpvClient: () => MpvClientLike | null;
focusMainWindow: () => void; focusMainWindow: () => void;
runSubsyncManual: (request: SubsyncManualRunRequest) => Promise<SubsyncResult>; runSubsyncManual: (request: SubsyncManualRunRequest) => Promise<SubsyncResult>;
onYoutubePickerResolve: (request: YoutubePickerResolveRequest) => Promise<YoutubePickerResolveResult>;
getAnkiConnectStatus: () => boolean; getAnkiConnectStatus: () => boolean;
getRuntimeOptions: () => unknown; getRuntimeOptions: () => unknown;
setRuntimeOption: (id: RuntimeOptionId, value: RuntimeOptionValue) => unknown; setRuntimeOption: (id: RuntimeOptionId, value: RuntimeOptionValue) => unknown;
@@ -225,6 +230,7 @@ export function createIpcDepsRuntime(options: IpcDepsRuntimeOptions): IpcService
mainWindow.focus(); mainWindow.focus();
}, },
runSubsyncManual: options.runSubsyncManual, runSubsyncManual: options.runSubsyncManual,
onYoutubePickerResolve: options.onYoutubePickerResolve,
getAnkiConnectStatus: options.getAnkiConnectStatus, getAnkiConnectStatus: options.getAnkiConnectStatus,
getRuntimeOptions: options.getRuntimeOptions, getRuntimeOptions: options.getRuntimeOptions,
setRuntimeOption: options.setRuntimeOption, setRuntimeOption: options.setRuntimeOption,
@@ -285,6 +291,14 @@ export function registerIpcHandlers(deps: IpcServiceDeps, ipc: IpcMainRegistrar
deps.onOverlayModalOpened(parsedModal); 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, () => { ipc.on(IPC_CHANNELS.command.openYomitanSettings, () => {
deps.openYomitanSettings(); deps.openYomitanSettings();
}); });

View File

@@ -17,7 +17,7 @@ test('resolveDefaultLogFilePath uses APPDATA on windows', () => {
'C:\\Users\\tester\\AppData\\Roaming', 'C:\\Users\\tester\\AppData\\Roaming',
'SubMiner', 'SubMiner',
'logs', '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', '.config',
'SubMiner', 'SubMiner',
'logs', 'logs',
`SubMiner-${new Date().toISOString().slice(0, 10)}.log`, `app-${new Date().toISOString().slice(0, 10)}.log`,
), ),
); );
}); });

View File

@@ -1,6 +1,4 @@
import fs from 'node:fs'; import { appendLogLine, resolveDefaultLogFilePath as resolveSharedDefaultLogFilePath } from './shared/log-files';
import os from 'node:os';
import path from 'node:path';
export type LogLevel = 'debug' | 'info' | 'warn' | 'error'; export type LogLevel = 'debug' | 'info' | 'warn' | 'error';
export type LogLevelSource = 'cli' | 'config'; export type LogLevelSource = 'cli' | 'config';
@@ -112,15 +110,11 @@ function safeStringify(value: unknown): string {
} }
function resolveLogFilePath(): string { function resolveLogFilePath(): string {
const envPath = process.env.SUBMINER_MPV_LOG?.trim(); const envPath = process.env.SUBMINER_APP_LOG?.trim();
if (envPath) { if (envPath) {
return envPath; return envPath;
} }
return resolveDefaultLogFilePath({ return resolveDefaultLogFilePath();
platform: process.platform,
homeDir: os.homedir(),
appDataDir: process.env.APPDATA,
});
} }
export function resolveDefaultLogFilePath(options?: { export function resolveDefaultLogFilePath(options?: {
@@ -128,27 +122,11 @@ export function resolveDefaultLogFilePath(options?: {
homeDir?: string; homeDir?: string;
appDataDir?: string; appDataDir?: string;
}): string { }): string {
const date = new Date().toISOString().slice(0, 10); return resolveSharedDefaultLogFilePath('app', options);
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`);
} }
function appendToLogFile(line: string): void { function appendToLogFile(line: string): void {
try { appendLogLine(resolveLogFilePath(), line);
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
}
} }
function emit(level: LogLevel, scope: string, message: string, meta: unknown[]): void { function emit(level: LogLevel, scope: string, message: string, meta: unknown[]): void {

View File

@@ -1,5 +1,6 @@
import { handleCliCommand, createCliCommandDepsRuntime } from '../core/services'; import { handleCliCommand, createCliCommandDepsRuntime } from '../core/services';
import type { CliArgs, CliCommandSource } from '../cli/args'; import type { CliArgs, CliCommandSource } from '../cli/args';
import type { YoutubeFlowMode } from '../types';
import { import {
createCliCommandRuntimeServiceDeps, createCliCommandRuntimeServiceDeps,
CliCommandRuntimeServiceDepsParams, CliCommandRuntimeServiceDepsParams,
@@ -38,6 +39,11 @@ export interface CliCommandRuntimeServiceContext {
openJellyfinSetup: CliCommandRuntimeServiceDepsParams['jellyfin']['openSetup']; openJellyfinSetup: CliCommandRuntimeServiceDepsParams['jellyfin']['openSetup'];
runStatsCommand: CliCommandRuntimeServiceDepsParams['jellyfin']['runStatsCommand']; runStatsCommand: CliCommandRuntimeServiceDepsParams['jellyfin']['runStatsCommand'];
runJellyfinCommand: CliCommandRuntimeServiceDepsParams['jellyfin']['runCommand']; runJellyfinCommand: CliCommandRuntimeServiceDepsParams['jellyfin']['runCommand'];
runYoutubePlaybackFlow: (request: {
url: string;
mode: YoutubeFlowMode;
source: CliCommandSource;
}) => Promise<void>;
openYomitanSettings: () => void; openYomitanSettings: () => void;
cycleSecondarySubMode: () => void; cycleSecondarySubMode: () => void;
openRuntimeOptionsPalette: () => void; openRuntimeOptionsPalette: () => void;
@@ -105,6 +111,11 @@ function createCliCommandDepsFromContext(
runStatsCommand: context.runStatsCommand, runStatsCommand: context.runStatsCommand,
runCommand: context.runJellyfinCommand, runCommand: context.runJellyfinCommand,
}, },
app: {
stop: context.stopApp,
hasMainWindow: context.hasMainWindow,
runYoutubePlaybackFlow: context.runYoutubePlaybackFlow,
},
ui: { ui: {
openFirstRunSetup: context.openFirstRunSetup, openFirstRunSetup: context.openFirstRunSetup,
openYomitanSettings: context.openYomitanSettings, openYomitanSettings: context.openYomitanSettings,
@@ -112,10 +123,6 @@ function createCliCommandDepsFromContext(
openRuntimeOptionsPalette: context.openRuntimeOptionsPalette, openRuntimeOptionsPalette: context.openRuntimeOptionsPalette,
printHelp: context.printHelp, printHelp: context.printHelp,
}, },
app: {
stop: context.stopApp,
hasMainWindow: context.hasMainWindow,
},
getMultiCopyTimeoutMs: context.getMultiCopyTimeoutMs, getMultiCopyTimeoutMs: context.getMultiCopyTimeoutMs,
schedule: context.schedule, schedule: context.schedule,
log: context.log, log: context.log,

View File

@@ -57,6 +57,7 @@ export interface MainIpcRuntimeServiceDepsParams {
getVisibleOverlayVisibility: IpcDepsRuntimeOptions['getVisibleOverlayVisibility']; getVisibleOverlayVisibility: IpcDepsRuntimeOptions['getVisibleOverlayVisibility'];
onOverlayModalClosed: IpcDepsRuntimeOptions['onOverlayModalClosed']; onOverlayModalClosed: IpcDepsRuntimeOptions['onOverlayModalClosed'];
onOverlayModalOpened?: IpcDepsRuntimeOptions['onOverlayModalOpened']; onOverlayModalOpened?: IpcDepsRuntimeOptions['onOverlayModalOpened'];
onYoutubePickerResolve: IpcDepsRuntimeOptions['onYoutubePickerResolve'];
openYomitanSettings: IpcDepsRuntimeOptions['openYomitanSettings']; openYomitanSettings: IpcDepsRuntimeOptions['openYomitanSettings'];
quitApp: IpcDepsRuntimeOptions['quitApp']; quitApp: IpcDepsRuntimeOptions['quitApp'];
toggleVisibleOverlay: IpcDepsRuntimeOptions['toggleVisibleOverlay']; toggleVisibleOverlay: IpcDepsRuntimeOptions['toggleVisibleOverlay'];
@@ -166,6 +167,11 @@ export interface CliCommandRuntimeServiceDepsParams {
runStatsCommand: CliCommandDepsRuntimeOptions['jellyfin']['runStatsCommand']; runStatsCommand: CliCommandDepsRuntimeOptions['jellyfin']['runStatsCommand'];
runCommand: CliCommandDepsRuntimeOptions['jellyfin']['runCommand']; runCommand: CliCommandDepsRuntimeOptions['jellyfin']['runCommand'];
}; };
app: {
stop: CliCommandDepsRuntimeOptions['app']['stop'];
hasMainWindow: CliCommandDepsRuntimeOptions['app']['hasMainWindow'];
runYoutubePlaybackFlow: CliCommandDepsRuntimeOptions['app']['runYoutubePlaybackFlow'];
};
ui: { ui: {
openFirstRunSetup: CliCommandDepsRuntimeOptions['ui']['openFirstRunSetup']; openFirstRunSetup: CliCommandDepsRuntimeOptions['ui']['openFirstRunSetup'];
openYomitanSettings: CliCommandDepsRuntimeOptions['ui']['openYomitanSettings']; openYomitanSettings: CliCommandDepsRuntimeOptions['ui']['openYomitanSettings'];
@@ -173,10 +179,6 @@ export interface CliCommandRuntimeServiceDepsParams {
openRuntimeOptionsPalette: CliCommandDepsRuntimeOptions['ui']['openRuntimeOptionsPalette']; openRuntimeOptionsPalette: CliCommandDepsRuntimeOptions['ui']['openRuntimeOptionsPalette'];
printHelp: CliCommandDepsRuntimeOptions['ui']['printHelp']; printHelp: CliCommandDepsRuntimeOptions['ui']['printHelp'];
}; };
app: {
stop: CliCommandDepsRuntimeOptions['app']['stop'];
hasMainWindow: CliCommandDepsRuntimeOptions['app']['hasMainWindow'];
};
getMultiCopyTimeoutMs: CliCommandDepsRuntimeOptions['getMultiCopyTimeoutMs']; getMultiCopyTimeoutMs: CliCommandDepsRuntimeOptions['getMultiCopyTimeoutMs'];
schedule: CliCommandDepsRuntimeOptions['schedule']; schedule: CliCommandDepsRuntimeOptions['schedule'];
log: CliCommandDepsRuntimeOptions['log']; log: CliCommandDepsRuntimeOptions['log'];
@@ -207,6 +209,7 @@ export function createMainIpcRuntimeServiceDeps(
getVisibleOverlayVisibility: params.getVisibleOverlayVisibility, getVisibleOverlayVisibility: params.getVisibleOverlayVisibility,
onOverlayModalClosed: params.onOverlayModalClosed, onOverlayModalClosed: params.onOverlayModalClosed,
onOverlayModalOpened: params.onOverlayModalOpened, onOverlayModalOpened: params.onOverlayModalOpened,
onYoutubePickerResolve: params.onYoutubePickerResolve,
openYomitanSettings: params.openYomitanSettings, openYomitanSettings: params.openYomitanSettings,
quitApp: params.quitApp, quitApp: params.quitApp,
toggleVisibleOverlay: params.toggleVisibleOverlay, toggleVisibleOverlay: params.toggleVisibleOverlay,
@@ -324,6 +327,11 @@ export function createCliCommandRuntimeServiceDeps(
runStatsCommand: params.jellyfin.runStatsCommand, runStatsCommand: params.jellyfin.runStatsCommand,
runCommand: params.jellyfin.runCommand, runCommand: params.jellyfin.runCommand,
}, },
app: {
stop: params.app.stop,
hasMainWindow: params.app.hasMainWindow,
runYoutubePlaybackFlow: params.app.runYoutubePlaybackFlow,
},
ui: { ui: {
openFirstRunSetup: params.ui.openFirstRunSetup, openFirstRunSetup: params.ui.openFirstRunSetup,
openYomitanSettings: params.ui.openYomitanSettings, openYomitanSettings: params.ui.openYomitanSettings,
@@ -331,10 +339,6 @@ export function createCliCommandRuntimeServiceDeps(
openRuntimeOptionsPalette: params.ui.openRuntimeOptionsPalette, openRuntimeOptionsPalette: params.ui.openRuntimeOptionsPalette,
printHelp: params.ui.printHelp, printHelp: params.ui.printHelp,
}, },
app: {
stop: params.app.stop,
hasMainWindow: params.app.hasMainWindow,
},
getMultiCopyTimeoutMs: params.getMultiCopyTimeoutMs, getMultiCopyTimeoutMs: params.getMultiCopyTimeoutMs,
schedule: params.schedule, schedule: params.schedule,
log: params.log, log: params.log,

View File

@@ -275,6 +275,82 @@ test('sendToActiveOverlayWindow prefers visible main overlay window for modal op
assert.deepEqual(mainWindow.sent, [['runtime-options:open']]); 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', () => { test('modal runtime notifies callers when modal input state becomes active/inactive', () => {
const window = createMockWindow(); const window = createMockWindow();
const state: boolean[] = []; const state: boolean[] = [];
@@ -430,3 +506,33 @@ test('modal fallback reveal keeps mouse events ignored until modal confirms open
runtime.notifyOverlayModalOpened('jimaku'); runtime.notifyOverlayModalOpened('jimaku');
assert.equal(window.ignoreMouseEvents, false); 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);
});

View File

@@ -16,11 +16,15 @@ export interface OverlayModalRuntime {
sendToActiveOverlayWindow: ( sendToActiveOverlayWindow: (
channel: string, channel: string,
payload?: unknown, payload?: unknown,
runtimeOptions?: { restoreOnModalClose?: OverlayHostedModal }, runtimeOptions?: {
restoreOnModalClose?: OverlayHostedModal;
preferModalWindow?: boolean;
},
) => boolean; ) => boolean;
openRuntimeOptionsPalette: () => void; openRuntimeOptionsPalette: () => void;
handleOverlayModalClosed: (modal: OverlayHostedModal) => void; handleOverlayModalClosed: (modal: OverlayHostedModal) => void;
notifyOverlayModalOpened: (modal: OverlayHostedModal) => void; notifyOverlayModalOpened: (modal: OverlayHostedModal) => void;
waitForModalOpen: (modal: OverlayHostedModal, timeoutMs: number) => Promise<boolean>;
getRestoreVisibleOverlayOnModalClose: () => Set<OverlayHostedModal>; getRestoreVisibleOverlayOnModalClose: () => Set<OverlayHostedModal>;
} }
@@ -33,7 +37,10 @@ export function createOverlayModalRuntimeService(
options: OverlayModalRuntimeOptions = {}, options: OverlayModalRuntimeOptions = {},
): OverlayModalRuntime { ): OverlayModalRuntime {
const restoreVisibleOverlayOnModalClose = new Set<OverlayHostedModal>(); const restoreVisibleOverlayOnModalClose = new Set<OverlayHostedModal>();
const modalOpenWaiters = new Map<OverlayHostedModal, Array<(opened: boolean) => void>>();
let modalActive = false; let modalActive = false;
let mainWindowMousePassthroughForcedByModal = false;
let mainWindowHiddenByModal = false;
let pendingModalWindowReveal: BrowserWindow | null = null; let pendingModalWindowReveal: BrowserWindow | null = null;
let pendingModalWindowRevealTimeout: ReturnType<typeof setTimeout> | null = null; let pendingModalWindowRevealTimeout: ReturnType<typeof setTimeout> | null = null;
@@ -163,6 +170,54 @@ export function createOverlayModalRuntimeService(
pendingModalWindowReveal = null; 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 => { const scheduleModalWindowReveal = (window: BrowserWindow): void => {
pendingModalWindowReveal = window; pendingModalWindowReveal = window;
if (pendingModalWindowRevealTimeout !== null) { if (pendingModalWindowRevealTimeout !== null) {
@@ -182,9 +237,13 @@ export function createOverlayModalRuntimeService(
const sendToActiveOverlayWindow = ( const sendToActiveOverlayWindow = (
channel: string, channel: string,
payload?: unknown, payload?: unknown,
runtimeOptions?: { restoreOnModalClose?: OverlayHostedModal }, runtimeOptions?: {
restoreOnModalClose?: OverlayHostedModal;
preferModalWindow?: boolean;
},
): boolean => { ): boolean => {
const restoreOnModalClose = runtimeOptions?.restoreOnModalClose; const restoreOnModalClose = runtimeOptions?.restoreOnModalClose;
const preferModalWindow = runtimeOptions?.preferModalWindow === true;
const sendNow = (window: BrowserWindow): void => { const sendNow = (window: BrowserWindow): void => {
ensureModalWindowInteractive(window); ensureModalWindowInteractive(window);
@@ -198,7 +257,7 @@ export function createOverlayModalRuntimeService(
if (restoreOnModalClose) { if (restoreOnModalClose) {
restoreVisibleOverlayOnModalClose.add(restoreOnModalClose); restoreVisibleOverlayOnModalClose.add(restoreOnModalClose);
const mainWindow = getTargetOverlayWindow(); const mainWindow = getTargetOverlayWindow();
if (mainWindow && !mainWindow.isDestroyed() && mainWindow.isVisible()) { if (!preferModalWindow && mainWindow && !mainWindow.isDestroyed() && mainWindow.isVisible()) {
sendOrQueueForWindow(mainWindow, (window) => { sendOrQueueForWindow(mainWindow, (window) => {
if (payload === undefined) { if (payload === undefined) {
window.webContents.send(channel); window.webContents.send(channel);
@@ -255,6 +314,8 @@ export function createOverlayModalRuntimeService(
if (restoreVisibleOverlayOnModalClose.size === 0) { if (restoreVisibleOverlayOnModalClose.size === 0) {
clearPendingModalWindowReveal(); clearPendingModalWindowReveal();
notifyModalStateChange(false); notifyModalStateChange(false);
setMainWindowMousePassthroughForModal(false);
setMainWindowVisibilityForModal(false);
if (modalWindow && !modalWindow.isDestroyed()) { if (modalWindow && !modalWindow.isDestroyed()) {
modalWindow.hide(); modalWindow.hide();
} }
@@ -263,6 +324,11 @@ export function createOverlayModalRuntimeService(
const notifyOverlayModalOpened = (modal: OverlayHostedModal): void => { const notifyOverlayModalOpened = (modal: OverlayHostedModal): void => {
if (!restoreVisibleOverlayOnModalClose.has(modal)) return; if (!restoreVisibleOverlayOnModalClose.has(modal)) return;
const waiters = modalOpenWaiters.get(modal) ?? [];
modalOpenWaiters.delete(modal);
for (const resolve of waiters) {
resolve(true);
}
notifyModalStateChange(true); notifyModalStateChange(true);
const targetWindow = getActiveOverlayWindowForModalInput(); const targetWindow = getActiveOverlayWindowForModalInput();
clearPendingModalWindowReveal(); clearPendingModalWindowReveal();
@@ -270,6 +336,12 @@ export function createOverlayModalRuntimeService(
return; return;
} }
const modalWindow = deps.getModalWindow();
if (modalWindow && !modalWindow.isDestroyed() && targetWindow === modalWindow) {
setMainWindowMousePassthroughForModal(true);
setMainWindowVisibilityForModal(true);
}
if (targetWindow.isVisible()) { if (targetWindow.isVisible()) {
targetWindow.setIgnoreMouseEvents(false); targetWindow.setIgnoreMouseEvents(false);
elevateModalWindow(targetWindow); elevateModalWindow(targetWindow);
@@ -285,11 +357,34 @@ export function createOverlayModalRuntimeService(
showModalWindow(targetWindow); showModalWindow(targetWindow);
}; };
const waitForModalOpen = async (
modal: OverlayHostedModal,
timeoutMs: number,
): Promise<boolean> =>
await new Promise<boolean>((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 { return {
sendToActiveOverlayWindow, sendToActiveOverlayWindow,
openRuntimeOptionsPalette, openRuntimeOptionsPalette,
handleOverlayModalClosed, handleOverlayModalClosed,
notifyOverlayModalOpened, notifyOverlayModalOpened,
waitForModalOpen,
getRestoreVisibleOverlayOnModalClose: () => restoreVisibleOverlayOnModalClose, getRestoreVisibleOverlayOnModalClose: () => restoreVisibleOverlayOnModalClose,
}; };
} }

View File

@@ -60,6 +60,9 @@ test('build cli command context deps maps handlers and values', () => {
runJellyfinCommand: async () => { runJellyfinCommand: async () => {
calls.push('run-jellyfin'); calls.push('run-jellyfin');
}, },
runYoutubePlaybackFlow: async () => {
calls.push('run-youtube');
},
openYomitanSettings: () => calls.push('yomitan'), openYomitanSettings: () => calls.push('yomitan'),
cycleSecondarySubMode: () => calls.push('cycle-secondary'), cycleSecondarySubMode: () => calls.push('cycle-secondary'),
openRuntimeOptionsPalette: () => calls.push('runtime-options'), openRuntimeOptionsPalette: () => calls.push('runtime-options'),

View File

@@ -36,6 +36,7 @@ export function createBuildCliCommandContextDepsHandler(deps: {
generateCharacterDictionary: CliCommandContextFactoryDeps['generateCharacterDictionary']; generateCharacterDictionary: CliCommandContextFactoryDeps['generateCharacterDictionary'];
runStatsCommand: CliCommandContextFactoryDeps['runStatsCommand']; runStatsCommand: CliCommandContextFactoryDeps['runStatsCommand'];
runJellyfinCommand: (args: CliArgs) => Promise<void>; runJellyfinCommand: (args: CliArgs) => Promise<void>;
runYoutubePlaybackFlow: CliCommandContextFactoryDeps['runYoutubePlaybackFlow'];
openYomitanSettings: () => void; openYomitanSettings: () => void;
cycleSecondarySubMode: () => void; cycleSecondarySubMode: () => void;
openRuntimeOptionsPalette: () => void; openRuntimeOptionsPalette: () => void;
@@ -83,6 +84,7 @@ export function createBuildCliCommandContextDepsHandler(deps: {
generateCharacterDictionary: deps.generateCharacterDictionary, generateCharacterDictionary: deps.generateCharacterDictionary,
runStatsCommand: deps.runStatsCommand, runStatsCommand: deps.runStatsCommand,
runJellyfinCommand: deps.runJellyfinCommand, runJellyfinCommand: deps.runJellyfinCommand,
runYoutubePlaybackFlow: deps.runYoutubePlaybackFlow,
openYomitanSettings: deps.openYomitanSettings, openYomitanSettings: deps.openYomitanSettings,
cycleSecondarySubMode: deps.cycleSecondarySubMode, cycleSecondarySubMode: deps.cycleSecondarySubMode,
openRuntimeOptionsPalette: deps.openRuntimeOptionsPalette, openRuntimeOptionsPalette: deps.openRuntimeOptionsPalette,

View File

@@ -9,6 +9,7 @@ test('cli command context factory composes main deps and context handlers', () =
mpvClient: null, mpvClient: null,
texthookerPort: 5174, texthookerPort: 5174,
overlayRuntimeInitialized: false, overlayRuntimeInitialized: false,
youtubePlaybackFlowPending: false,
}; };
const createContext = createCliCommandContextFactory({ const createContext = createCliCommandContextFactory({
@@ -63,6 +64,7 @@ test('cli command context factory composes main deps and context handlers', () =
}), }),
runStatsCommand: async () => {}, runStatsCommand: async () => {},
runJellyfinCommand: async () => {}, runJellyfinCommand: async () => {},
runYoutubePlaybackFlow: async () => {},
openYomitanSettings: () => {}, openYomitanSettings: () => {},
cycleSecondarySubMode: () => {}, cycleSecondarySubMode: () => {},
openRuntimeOptionsPalette: () => {}, openRuntimeOptionsPalette: () => {},

View File

@@ -9,6 +9,7 @@ test('cli command context main deps builder maps state and callbacks', async ()
mpvClient: null, mpvClient: null,
texthookerPort: 5174, texthookerPort: 5174,
overlayRuntimeInitialized: false, overlayRuntimeInitialized: false,
youtubePlaybackFlowPending: false,
}; };
const build = createBuildCliCommandContextMainDepsHandler({ const build = createBuildCliCommandContextMainDepsHandler({
@@ -84,6 +85,9 @@ test('cli command context main deps builder maps state and callbacks', async ()
runJellyfinCommand: async () => { runJellyfinCommand: async () => {
calls.push('run-jellyfin'); calls.push('run-jellyfin');
}, },
runYoutubePlaybackFlow: async () => {
calls.push('run-youtube');
},
openYomitanSettings: () => calls.push('open-yomitan'), openYomitanSettings: () => calls.push('open-yomitan'),
cycleSecondarySubMode: () => calls.push('cycle-secondary'), cycleSecondarySubMode: () => calls.push('cycle-secondary'),

View File

@@ -1,4 +1,5 @@
import type { CliArgs } from '../../cli/args'; import type { CliArgs } from '../../cli/args';
import type { YoutubeFlowMode } from '../../types';
import type { CliCommandContextFactoryDeps } from './cli-command-context'; import type { CliCommandContextFactoryDeps } from './cli-command-context';
type CliCommandContextMainState = { type CliCommandContextMainState = {
@@ -41,6 +42,11 @@ export function createBuildCliCommandContextMainDepsHandler(deps: {
generateCharacterDictionary: CliCommandContextFactoryDeps['generateCharacterDictionary']; generateCharacterDictionary: CliCommandContextFactoryDeps['generateCharacterDictionary'];
runStatsCommand: CliCommandContextFactoryDeps['runStatsCommand']; runStatsCommand: CliCommandContextFactoryDeps['runStatsCommand'];
runJellyfinCommand: (args: CliArgs) => Promise<void>; runJellyfinCommand: (args: CliArgs) => Promise<void>;
runYoutubePlaybackFlow: (request: {
url: string;
mode: YoutubeFlowMode;
source: 'initial' | 'second-instance';
}) => Promise<void>;
openYomitanSettings: () => void; openYomitanSettings: () => void;
cycleSecondarySubMode: () => void; cycleSecondarySubMode: () => void;
@@ -95,6 +101,7 @@ export function createBuildCliCommandContextMainDepsHandler(deps: {
deps.generateCharacterDictionary(targetPath), deps.generateCharacterDictionary(targetPath),
runStatsCommand: (args: CliArgs, source) => deps.runStatsCommand(args, source), runStatsCommand: (args: CliArgs, source) => deps.runStatsCommand(args, source),
runJellyfinCommand: (args: CliArgs) => deps.runJellyfinCommand(args), runJellyfinCommand: (args: CliArgs) => deps.runJellyfinCommand(args),
runYoutubePlaybackFlow: (request) => deps.runYoutubePlaybackFlow(request),
openYomitanSettings: () => deps.openYomitanSettings(), openYomitanSettings: () => deps.openYomitanSettings(),
cycleSecondarySubMode: () => deps.cycleSecondarySubMode(), cycleSecondarySubMode: () => deps.cycleSecondarySubMode(),
openRuntimeOptionsPalette: () => deps.openRuntimeOptionsPalette(), openRuntimeOptionsPalette: () => deps.openRuntimeOptionsPalette(),

View File

@@ -50,6 +50,7 @@ function createDeps() {
}), }),
runStatsCommand: async () => {}, runStatsCommand: async () => {},
runJellyfinCommand: async () => {}, runJellyfinCommand: async () => {},
runYoutubePlaybackFlow: async () => {},
openYomitanSettings: () => {}, openYomitanSettings: () => {},
cycleSecondarySubMode: () => {}, cycleSecondarySubMode: () => {},
openRuntimeOptionsPalette: () => {}, openRuntimeOptionsPalette: () => {},

View File

@@ -1,4 +1,5 @@
import type { CliArgs } from '../../cli/args'; import type { CliArgs } from '../../cli/args';
import type { YoutubeFlowMode } from '../../types';
import type { import type {
CliCommandRuntimeServiceContext, CliCommandRuntimeServiceContext,
CliCommandRuntimeServiceContextHandlers, CliCommandRuntimeServiceContextHandlers,
@@ -41,6 +42,11 @@ export type CliCommandContextFactoryDeps = {
generateCharacterDictionary: CliCommandRuntimeServiceContext['generateCharacterDictionary']; generateCharacterDictionary: CliCommandRuntimeServiceContext['generateCharacterDictionary'];
runStatsCommand: CliCommandRuntimeServiceContext['runStatsCommand']; runStatsCommand: CliCommandRuntimeServiceContext['runStatsCommand'];
runJellyfinCommand: (args: CliArgs) => Promise<void>; runJellyfinCommand: (args: CliArgs) => Promise<void>;
runYoutubePlaybackFlow: (request: {
url: string;
mode: YoutubeFlowMode;
source: 'initial' | 'second-instance';
}) => Promise<void>;
openYomitanSettings: () => void; openYomitanSettings: () => void;
cycleSecondarySubMode: () => void; cycleSecondarySubMode: () => void;
openRuntimeOptionsPalette: () => void; openRuntimeOptionsPalette: () => void;
@@ -95,6 +101,7 @@ export function createCliCommandContext(
generateCharacterDictionary: deps.generateCharacterDictionary, generateCharacterDictionary: deps.generateCharacterDictionary,
runStatsCommand: deps.runStatsCommand, runStatsCommand: deps.runStatsCommand,
runJellyfinCommand: deps.runJellyfinCommand, runJellyfinCommand: deps.runJellyfinCommand,
runYoutubePlaybackFlow: deps.runYoutubePlaybackFlow,
openYomitanSettings: deps.openYomitanSettings, openYomitanSettings: deps.openYomitanSettings,
cycleSecondarySubMode: deps.cycleSecondarySubMode, cycleSecondarySubMode: deps.cycleSecondarySubMode,
openRuntimeOptionsPalette: deps.openRuntimeOptionsPalette, openRuntimeOptionsPalette: deps.openRuntimeOptionsPalette,

View File

@@ -67,6 +67,7 @@ test('composeIpcRuntimeHandlers returns callable IPC handlers and registration b
getAnilistQueueStatus: () => ({}) as never, getAnilistQueueStatus: () => ({}) as never,
retryAnilistQueueNow: async () => ({ ok: true, message: 'ok' }), retryAnilistQueueNow: async () => ({ ok: true, message: 'ok' }),
appendClipboardVideoToQueue: () => ({ ok: true, message: 'ok' }), appendClipboardVideoToQueue: () => ({ ok: true, message: 'ok' }),
onYoutubePickerResolve: async () => ({ ok: true, message: 'ok' }),
}, },
ankiJimakuDeps: { ankiJimakuDeps: {
patchAnkiConnectEnabled: () => {}, patchAnkiConnectEnabled: () => {},

View File

@@ -72,6 +72,7 @@ test('composeMpvRuntimeHandlers returns callable handlers and forwards to inject
currentSubAssText: '', currentSubAssText: '',
playbackPaused: null, playbackPaused: null,
previousSecondarySubVisibility: null, previousSecondarySubVisibility: null,
youtubePlaybackFlowPending: false,
}, },
getQuitOnDisconnectArmed: () => false, getQuitOnDisconnectArmed: () => false,
scheduleQuitCheck: () => {}, scheduleQuitCheck: () => {},
@@ -280,6 +281,7 @@ test('composeMpvRuntimeHandlers skips MeCab warmup when all POS-dependent annota
currentSubAssText: '', currentSubAssText: '',
playbackPaused: null, playbackPaused: null,
previousSecondarySubVisibility: null, previousSecondarySubVisibility: null,
youtubePlaybackFlowPending: false,
}, },
getQuitOnDisconnectArmed: () => false, getQuitOnDisconnectArmed: () => false,
scheduleQuitCheck: () => {}, scheduleQuitCheck: () => {},
@@ -411,6 +413,7 @@ test('composeMpvRuntimeHandlers runs tokenization warmup once across sequential
currentSubAssText: '', currentSubAssText: '',
playbackPaused: null, playbackPaused: null,
previousSecondarySubVisibility: null, previousSecondarySubVisibility: null,
youtubePlaybackFlowPending: false,
}, },
getQuitOnDisconnectArmed: () => false, getQuitOnDisconnectArmed: () => false,
scheduleQuitCheck: () => {}, scheduleQuitCheck: () => {},
@@ -550,6 +553,7 @@ test('composeMpvRuntimeHandlers does not block first tokenization on dictionary
currentSubAssText: '', currentSubAssText: '',
playbackPaused: null, playbackPaused: null,
previousSecondarySubVisibility: null, previousSecondarySubVisibility: null,
youtubePlaybackFlowPending: false,
}, },
getQuitOnDisconnectArmed: () => false, getQuitOnDisconnectArmed: () => false,
scheduleQuitCheck: () => {}, scheduleQuitCheck: () => {},
@@ -683,6 +687,7 @@ test('composeMpvRuntimeHandlers shows annotation loading OSD after tokenization-
currentSubAssText: '', currentSubAssText: '',
playbackPaused: null, playbackPaused: null,
previousSecondarySubVisibility: null, previousSecondarySubVisibility: null,
youtubePlaybackFlowPending: false,
}, },
getQuitOnDisconnectArmed: () => false, getQuitOnDisconnectArmed: () => false,
scheduleQuitCheck: () => {}, scheduleQuitCheck: () => {},
@@ -830,6 +835,7 @@ test('composeMpvRuntimeHandlers reuses completed background tokenization warmups
currentSubAssText: '', currentSubAssText: '',
playbackPaused: null, playbackPaused: null,
previousSecondarySubVisibility: null, previousSecondarySubVisibility: null,
youtubePlaybackFlowPending: false,
}, },
getQuitOnDisconnectArmed: () => false, getQuitOnDisconnectArmed: () => false,
scheduleQuitCheck: () => {}, scheduleQuitCheck: () => {},

View File

@@ -25,6 +25,7 @@ test('mpv main event main deps map app state updates and delegate callbacks', as
currentSubAssText: '', currentSubAssText: '',
playbackPaused: null, playbackPaused: null,
previousSecondarySubVisibility: false, previousSecondarySubVisibility: false,
youtubePlaybackFlowPending: false,
}; };
const deps = createBuildBindMpvMainEventHandlersMainDepsHandler({ const deps = createBuildBindMpvMainEventHandlersMainDepsHandler({

View File

@@ -34,6 +34,7 @@ export function createBuildBindMpvMainEventHandlersMainDepsHandler(deps: {
currentSubtitleData?: SubtitleData | null; currentSubtitleData?: SubtitleData | null;
playbackPaused: boolean | null; playbackPaused: boolean | null;
previousSecondarySubVisibility: boolean | null; previousSecondarySubVisibility: boolean | null;
youtubePlaybackFlowPending: boolean;
}; };
getQuitOnDisconnectArmed: () => boolean; getQuitOnDisconnectArmed: () => boolean;
scheduleQuitCheck: (callback: () => void) => void; scheduleQuitCheck: (callback: () => void) => void;

View File

@@ -33,13 +33,17 @@ export function resolveWindowsMpvPath(deps: WindowsMpvLaunchDeps): string {
return ''; return '';
} }
export function buildWindowsMpvLaunchArgs(targets: string[]): string[] { export function buildWindowsMpvLaunchArgs(
return ['--player-operation-mode=pseudo-gui', '--profile=subminer', ...targets]; targets: string[],
extraArgs: string[] = [],
): string[] {
return ['--player-operation-mode=pseudo-gui', '--profile=subminer', ...extraArgs, ...targets];
} }
export function launchWindowsMpv( export function launchWindowsMpv(
targets: string[], targets: string[],
deps: WindowsMpvLaunchDeps, deps: WindowsMpvLaunchDeps,
extraArgs: string[] = [],
): { ok: boolean; mpvPath: string } { ): { ok: boolean; mpvPath: string } {
const mpvPath = resolveWindowsMpvPath(deps); const mpvPath = resolveWindowsMpvPath(deps);
if (!mpvPath) { if (!mpvPath) {
@@ -51,7 +55,7 @@ export function launchWindowsMpv(
} }
try { try {
deps.spawnDetached(mpvPath, buildWindowsMpvLaunchArgs(targets)); deps.spawnDetached(mpvPath, buildWindowsMpvLaunchArgs(targets, extraArgs));
return { ok: true, mpvPath }; return { ok: true, mpvPath };
} catch (error) { } catch (error) {
const message = error instanceof Error ? error.message : String(error); const message = error instanceof Error ? error.message : String(error);

View File

@@ -188,6 +188,7 @@ export interface AppState {
overlayDebugVisualizationEnabled: boolean; overlayDebugVisualizationEnabled: boolean;
statsOverlayVisible: boolean; statsOverlayVisible: boolean;
subsyncInProgress: boolean; subsyncInProgress: boolean;
youtubePlaybackFlowPending: boolean;
initialArgs: CliArgs | null; initialArgs: CliArgs | null;
mpvSocketPath: string; mpvSocketPath: string;
texthookerPort: number; texthookerPort: number;
@@ -272,6 +273,7 @@ export function createAppState(values: AppStateInitialValues): AppState {
fieldGroupingResolver: null, fieldGroupingResolver: null,
fieldGroupingResolverSequence: 0, fieldGroupingResolverSequence: 0,
subsyncInProgress: false, subsyncInProgress: false,
youtubePlaybackFlowPending: false,
initialArgs: null, initialArgs: null,
mpvSocketPath: values.mpvSocketPath, mpvSocketPath: values.mpvSocketPath,
texthookerPort: values.texthookerPort, texthookerPort: values.texthookerPort,
@@ -291,6 +293,7 @@ export function createAppState(values: AppStateInitialValues): AppState {
export function applyStartupState(appState: AppState, startupState: StartupState): void { export function applyStartupState(appState: AppState, startupState: StartupState): void {
appState.initialArgs = startupState.initialArgs; appState.initialArgs = startupState.initialArgs;
appState.youtubePlaybackFlowPending = Boolean(startupState.initialArgs.youtubePlay);
appState.mpvSocketPath = startupState.mpvSocketPath; appState.mpvSocketPath = startupState.mpvSocketPath;
appState.texthookerPort = startupState.texthookerPort; appState.texthookerPort = startupState.texthookerPort;
appState.backendOverride = startupState.backendOverride; appState.backendOverride = startupState.backendOverride;

View File

@@ -4,6 +4,7 @@ export const OVERLAY_HOSTED_MODALS = [
'runtime-options', 'runtime-options',
'subsync', 'subsync',
'jimaku', 'jimaku',
'youtube-track-picker',
'kiku', 'kiku',
'controller-select', 'controller-select',
'controller-debug', 'controller-debug',
@@ -18,6 +19,7 @@ export const IPC_CHANNELS = {
openYomitanSettings: 'open-yomitan-settings', openYomitanSettings: 'open-yomitan-settings',
recordYomitanLookup: 'record-yomitan-lookup', recordYomitanLookup: 'record-yomitan-lookup',
quitApp: 'quit-app', quitApp: 'quit-app',
youtubePickerResolve: 'youtube:picker-resolve',
toggleDevTools: 'toggle-dev-tools', toggleDevTools: 'toggle-dev-tools',
toggleOverlay: 'toggle-overlay', toggleOverlay: 'toggle-overlay',
saveSubtitlePosition: 'save-subtitle-position', saveSubtitlePosition: 'save-subtitle-position',
@@ -51,6 +53,7 @@ export const IPC_CHANNELS = {
getControllerConfig: 'get-controller-config', getControllerConfig: 'get-controller-config',
getSecondarySubMode: 'get-secondary-sub-mode', getSecondarySubMode: 'get-secondary-sub-mode',
getCurrentSecondarySub: 'get-current-secondary-sub', getCurrentSecondarySub: 'get-current-secondary-sub',
youtubePickerResolve: 'youtube:picker-resolve',
focusMainWindow: 'focus-main-window', focusMainWindow: 'focus-main-window',
runSubsyncManual: 'subsync:run-manual', runSubsyncManual: 'subsync:run-manual',
getAnkiConnectStatus: 'get-anki-connect-status', getAnkiConnectStatus: 'get-anki-connect-status',
@@ -94,6 +97,8 @@ export const IPC_CHANNELS = {
runtimeOptionsChanged: 'runtime-options:changed', runtimeOptionsChanged: 'runtime-options:changed',
runtimeOptionsOpen: 'runtime-options:open', runtimeOptionsOpen: 'runtime-options:open',
jimakuOpen: 'jimaku:open', jimakuOpen: 'jimaku:open',
youtubePickerOpen: 'youtube:picker-open',
youtubePickerCancel: 'youtube:picker-cancel',
keyboardModeToggleRequested: 'keyboard-mode-toggle:requested', keyboardModeToggleRequested: 'keyboard-mode-toggle:requested',
lookupWindowToggleRequested: 'lookup-window-toggle:requested', lookupWindowToggleRequested: 'lookup-window-toggle:requested',
configHotReload: 'config:hot-reload', configHotReload: 'config:hot-reload',

View File

@@ -10,6 +10,7 @@ import type {
RuntimeOptionValue, RuntimeOptionValue,
SubtitlePosition, SubtitlePosition,
SubsyncManualRunRequest, SubsyncManualRunRequest,
YoutubePickerResolveRequest,
} from '../../types'; } from '../../types';
import { OVERLAY_HOSTED_MODALS, type OverlayHostedModal } from './contracts'; import { OVERLAY_HOSTED_MODALS, type OverlayHostedModal } from './contracts';
@@ -253,3 +254,25 @@ export function parseJimakuDownloadQuery(value: unknown): JimakuDownloadQuery |
name: value.name, 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,
};
}