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