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