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:
@@ -149,20 +149,16 @@ test('doctor command forwards refresh-known-words to app binary', () => {
|
||||
context.args.doctorRefreshKnownWords = true;
|
||||
const forwarded: string[][] = [];
|
||||
|
||||
assert.throws(
|
||||
() =>
|
||||
runDoctorCommand(context, {
|
||||
commandExists: () => false,
|
||||
configExists: () => true,
|
||||
resolveMainConfigPath: () => '/tmp/SubMiner/config.jsonc',
|
||||
runAppCommandWithInherit: (_appPath, appArgs) => {
|
||||
forwarded.push(appArgs);
|
||||
throw new ExitSignal(0);
|
||||
},
|
||||
}),
|
||||
(error: unknown) => error instanceof ExitSignal && error.code === 0,
|
||||
);
|
||||
const handled = runDoctorCommand(context, {
|
||||
commandExists: () => false,
|
||||
configExists: () => true,
|
||||
resolveMainConfigPath: () => '/tmp/SubMiner/config.jsonc',
|
||||
runAppCommandWithInherit: (_appPath, appArgs) => {
|
||||
forwarded.push(appArgs);
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(handled, true);
|
||||
assert.deepEqual(forwarded, [['--refresh-known-words']]);
|
||||
});
|
||||
|
||||
@@ -187,31 +183,25 @@ test('dictionary command forwards --dictionary and target path to app binary', (
|
||||
context.args.dictionaryTarget = '/tmp/anime';
|
||||
const forwarded: string[][] = [];
|
||||
|
||||
assert.throws(
|
||||
() =>
|
||||
runDictionaryCommand(context, {
|
||||
runAppCommandWithInherit: (_appPath, appArgs) => {
|
||||
forwarded.push(appArgs);
|
||||
throw new ExitSignal(0);
|
||||
},
|
||||
}),
|
||||
(error: unknown) => error instanceof ExitSignal && error.code === 0,
|
||||
);
|
||||
const handled = runDictionaryCommand(context, {
|
||||
runAppCommandWithInherit: (_appPath, appArgs) => {
|
||||
forwarded.push(appArgs);
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(handled, true);
|
||||
assert.deepEqual(forwarded, [['--dictionary', '--dictionary-target', '/tmp/anime']]);
|
||||
});
|
||||
|
||||
test('dictionary command throws if app handoff unexpectedly returns', () => {
|
||||
test('dictionary command returns after app handoff starts', () => {
|
||||
const context = createContext();
|
||||
context.args.dictionary = true;
|
||||
|
||||
assert.throws(
|
||||
() =>
|
||||
runDictionaryCommand(context, {
|
||||
runAppCommandWithInherit: () => undefined as never,
|
||||
}),
|
||||
/unexpectedly returned/,
|
||||
);
|
||||
const handled = runDictionaryCommand(context, {
|
||||
runAppCommandWithInherit: () => undefined,
|
||||
});
|
||||
|
||||
assert.equal(handled, true);
|
||||
});
|
||||
|
||||
test('stats command launches attached app command with response path', async () => {
|
||||
|
||||
@@ -2,7 +2,7 @@ import { runAppCommandWithInherit } from '../mpv.js';
|
||||
import type { LauncherCommandContext } from './context.js';
|
||||
|
||||
interface DictionaryCommandDeps {
|
||||
runAppCommandWithInherit: (appPath: string, appArgs: string[]) => never;
|
||||
runAppCommandWithInherit: (appPath: string, appArgs: string[]) => void;
|
||||
}
|
||||
|
||||
const defaultDeps: DictionaryCommandDeps = {
|
||||
@@ -27,5 +27,5 @@ export function runDictionaryCommand(
|
||||
}
|
||||
|
||||
deps.runAppCommandWithInherit(appPath, forwarded);
|
||||
throw new Error('Dictionary command app handoff unexpectedly returned.');
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ interface DoctorCommandDeps {
|
||||
commandExists(command: string): boolean;
|
||||
configExists(path: string): boolean;
|
||||
resolveMainConfigPath(): string;
|
||||
runAppCommandWithInherit(appPath: string, appArgs: string[]): never;
|
||||
runAppCommandWithInherit(appPath: string, appArgs: string[]): void;
|
||||
}
|
||||
|
||||
const defaultDeps: DoctorCommandDeps = {
|
||||
@@ -85,6 +85,7 @@ export function runDoctorCommand(
|
||||
return true;
|
||||
}
|
||||
deps.runAppCommandWithInherit(appPath, ['--refresh-known-words']);
|
||||
return true;
|
||||
}
|
||||
|
||||
const hasHardFailure = checks.some((entry) =>
|
||||
|
||||
@@ -21,6 +21,7 @@ export async function runJellyfinCommand(context: LauncherCommandContext): Promi
|
||||
if (args.logLevel !== 'info') forwarded.push('--log-level', args.logLevel);
|
||||
appendPasswordStore(forwarded);
|
||||
runAppCommandWithInherit(appPath, forwarded);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (args.jellyfinLogin) {
|
||||
@@ -44,6 +45,7 @@ export async function runJellyfinCommand(context: LauncherCommandContext): Promi
|
||||
if (args.logLevel !== 'info') forwarded.push('--log-level', args.logLevel);
|
||||
appendPasswordStore(forwarded);
|
||||
runAppCommandWithInherit(appPath, forwarded);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (args.jellyfinLogout) {
|
||||
@@ -51,6 +53,7 @@ export async function runJellyfinCommand(context: LauncherCommandContext): Promi
|
||||
if (args.logLevel !== 'info') forwarded.push('--log-level', args.logLevel);
|
||||
appendPasswordStore(forwarded);
|
||||
runAppCommandWithInherit(appPath, forwarded);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (args.jellyfinPlay) {
|
||||
@@ -69,6 +72,7 @@ export async function runJellyfinCommand(context: LauncherCommandContext): Promi
|
||||
if (args.logLevel !== 'info') forwarded.push('--log-level', args.logLevel);
|
||||
appendPasswordStore(forwarded);
|
||||
runAppCommandWithInherit(appPath, forwarded);
|
||||
return true;
|
||||
}
|
||||
|
||||
return Boolean(
|
||||
|
||||
113
launcher/commands/playback-command.test.ts
Normal file
113
launcher/commands/playback-command.test.ts
Normal file
@@ -0,0 +1,113 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import type { LauncherCommandContext } from './context.js';
|
||||
import { runPlaybackCommandWithDeps } from './playback-command.js';
|
||||
|
||||
function createContext(): LauncherCommandContext {
|
||||
return {
|
||||
args: {
|
||||
backend: 'auto',
|
||||
directory: '.',
|
||||
recursive: false,
|
||||
profile: '',
|
||||
startOverlay: false,
|
||||
youtubeMode: 'download',
|
||||
whisperBin: '',
|
||||
whisperModel: '',
|
||||
whisperVadModel: '',
|
||||
whisperThreads: 0,
|
||||
youtubeSubgenOutDir: '',
|
||||
youtubeSubgenAudioFormat: '',
|
||||
youtubeSubgenKeepTemp: false,
|
||||
youtubeFixWithAi: false,
|
||||
youtubePrimarySubLangs: [],
|
||||
youtubeSecondarySubLangs: [],
|
||||
youtubeAudioLangs: [],
|
||||
youtubeWhisperSourceLanguage: '',
|
||||
aiConfig: {},
|
||||
useTexthooker: false,
|
||||
autoStartOverlay: false,
|
||||
texthookerOnly: false,
|
||||
useRofi: false,
|
||||
logLevel: 'info',
|
||||
passwordStore: '',
|
||||
target: 'https://www.youtube.com/watch?v=65Ovd7t8sNw',
|
||||
targetKind: 'url',
|
||||
jimakuApiKey: '',
|
||||
jimakuApiKeyCommand: '',
|
||||
jimakuApiBaseUrl: '',
|
||||
jimakuLanguagePreference: 'ja',
|
||||
jimakuMaxEntryResults: 20,
|
||||
jellyfin: false,
|
||||
jellyfinLogin: false,
|
||||
jellyfinLogout: false,
|
||||
jellyfinPlay: false,
|
||||
jellyfinDiscovery: false,
|
||||
dictionary: false,
|
||||
stats: false,
|
||||
doctor: false,
|
||||
doctorRefreshKnownWords: false,
|
||||
configPath: false,
|
||||
configShow: false,
|
||||
mpvIdle: false,
|
||||
mpvSocket: false,
|
||||
mpvStatus: false,
|
||||
mpvArgs: '',
|
||||
appPassthrough: false,
|
||||
appArgs: [],
|
||||
jellyfinServer: '',
|
||||
jellyfinUsername: '',
|
||||
jellyfinPassword: '',
|
||||
},
|
||||
scriptPath: '/tmp/subminer',
|
||||
scriptName: 'subminer',
|
||||
mpvSocketPath: '/tmp/subminer.sock',
|
||||
pluginRuntimeConfig: {
|
||||
socketPath: '/tmp/subminer.sock',
|
||||
autoStart: true,
|
||||
autoStartVisibleOverlay: true,
|
||||
autoStartPauseUntilReady: true,
|
||||
},
|
||||
appPath: '/tmp/SubMiner.AppImage',
|
||||
launcherJellyfinConfig: {},
|
||||
processAdapter: {
|
||||
platform: () => 'linux',
|
||||
onSignal: () => {},
|
||||
writeStdout: () => {},
|
||||
exit: (_code: number): never => {
|
||||
throw new Error('unexpected exit');
|
||||
},
|
||||
setExitCode: () => {},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test('youtube playback launches overlay with youtube-play args in the primary app start', async () => {
|
||||
const calls: string[] = [];
|
||||
const context = createContext();
|
||||
|
||||
await runPlaybackCommandWithDeps(context, {
|
||||
ensurePlaybackSetupReady: async () => {},
|
||||
chooseTarget: async (_args, _scriptPath) => ({ target: context.args.target, kind: 'url' }),
|
||||
checkDependencies: () => {},
|
||||
registerCleanup: () => {},
|
||||
startMpv: async () => {
|
||||
calls.push('startMpv');
|
||||
},
|
||||
waitForUnixSocketReady: async () => true,
|
||||
startOverlay: async (_appPath, _args, _socketPath, extraAppArgs = []) => {
|
||||
calls.push(`startOverlay:${extraAppArgs.join(' ')}`);
|
||||
},
|
||||
launchAppCommandDetached: (_appPath: string, appArgs: string[]) => {
|
||||
calls.push(`launch:${appArgs.join(' ')}`);
|
||||
},
|
||||
log: () => {},
|
||||
cleanupPlaybackSession: async () => {},
|
||||
getMpvProc: () => null,
|
||||
});
|
||||
|
||||
assert.deepEqual(calls, [
|
||||
'startMpv',
|
||||
'startOverlay:--youtube-play https://www.youtube.com/watch?v=65Ovd7t8sNw --youtube-mode download',
|
||||
]);
|
||||
});
|
||||
@@ -6,13 +6,13 @@ import { commandExists, isYoutubeTarget, realpathMaybe, resolvePathMaybe } from
|
||||
import { collectVideos, showFzfMenu, showRofiMenu } from '../picker.js';
|
||||
import {
|
||||
cleanupPlaybackSession,
|
||||
launchAppCommandDetached,
|
||||
startMpv,
|
||||
startOverlay,
|
||||
state,
|
||||
stopOverlay,
|
||||
waitForUnixSocketReady,
|
||||
} from '../mpv.js';
|
||||
import { generateYoutubeSubtitles } from '../youtube.js';
|
||||
import type { Args } from '../types.js';
|
||||
import type { LauncherCommandContext } from './context.js';
|
||||
import { ensureLauncherSetupReady } from '../setup-gate.js';
|
||||
@@ -126,30 +126,66 @@ async function ensurePlaybackSetupReady(context: LauncherCommandContext): Promis
|
||||
}
|
||||
|
||||
export async function runPlaybackCommand(context: LauncherCommandContext): Promise<void> {
|
||||
return runPlaybackCommandWithDeps(context, {
|
||||
ensurePlaybackSetupReady,
|
||||
chooseTarget,
|
||||
checkDependencies,
|
||||
registerCleanup,
|
||||
startMpv,
|
||||
waitForUnixSocketReady,
|
||||
startOverlay,
|
||||
launchAppCommandDetached,
|
||||
log,
|
||||
cleanupPlaybackSession,
|
||||
getMpvProc: () => state.mpvProc,
|
||||
});
|
||||
}
|
||||
|
||||
type PlaybackCommandDeps = {
|
||||
ensurePlaybackSetupReady: (context: LauncherCommandContext) => Promise<void>;
|
||||
chooseTarget: (
|
||||
args: Args,
|
||||
scriptPath: string,
|
||||
) => Promise<{ target: string; kind: 'file' | 'url' } | null>;
|
||||
checkDependencies: (args: Args) => void;
|
||||
registerCleanup: (context: LauncherCommandContext) => void;
|
||||
startMpv: typeof startMpv;
|
||||
waitForUnixSocketReady: typeof waitForUnixSocketReady;
|
||||
startOverlay: typeof startOverlay;
|
||||
launchAppCommandDetached: typeof launchAppCommandDetached;
|
||||
log: typeof log;
|
||||
cleanupPlaybackSession: typeof cleanupPlaybackSession;
|
||||
getMpvProc: () => typeof state.mpvProc;
|
||||
};
|
||||
|
||||
export async function runPlaybackCommandWithDeps(
|
||||
context: LauncherCommandContext,
|
||||
deps: PlaybackCommandDeps,
|
||||
): Promise<void> {
|
||||
const { args, appPath, scriptPath, mpvSocketPath, pluginRuntimeConfig, processAdapter } = context;
|
||||
if (!appPath) {
|
||||
fail('SubMiner AppImage not found. Install to ~/.local/bin/ or set SUBMINER_APPIMAGE_PATH.');
|
||||
}
|
||||
|
||||
await ensurePlaybackSetupReady(context);
|
||||
await deps.ensurePlaybackSetupReady(context);
|
||||
|
||||
if (!args.target) {
|
||||
checkPickerDependencies(args);
|
||||
}
|
||||
|
||||
const targetChoice = await chooseTarget(args, scriptPath);
|
||||
const targetChoice = await deps.chooseTarget(args, scriptPath);
|
||||
if (!targetChoice) {
|
||||
log('info', args.logLevel, 'No video selected, exiting');
|
||||
deps.log('info', args.logLevel, 'No video selected, exiting');
|
||||
processAdapter.exit(0);
|
||||
}
|
||||
|
||||
checkDependencies({
|
||||
deps.checkDependencies({
|
||||
...args,
|
||||
target: targetChoice ? targetChoice.target : args.target,
|
||||
targetKind: targetChoice ? targetChoice.kind : 'url',
|
||||
});
|
||||
|
||||
registerCleanup(context);
|
||||
deps.registerCleanup(context);
|
||||
|
||||
const selectedTarget = targetChoice
|
||||
? {
|
||||
@@ -159,30 +195,11 @@ export async function runPlaybackCommand(context: LauncherCommandContext): Promi
|
||||
: { target: args.target, kind: 'url' as const };
|
||||
|
||||
const isYoutubeUrl = selectedTarget.kind === 'url' && isYoutubeTarget(selectedTarget.target);
|
||||
let preloadedSubtitles: { primaryPath?: string; secondaryPath?: string } | undefined;
|
||||
const isAppOwnedYoutubeFlow = isYoutubeUrl;
|
||||
const youtubeMode = args.youtubeMode ?? 'download';
|
||||
|
||||
if (isYoutubeUrl) {
|
||||
log('info', args.logLevel, 'YouTube subtitle generation: preload before mpv');
|
||||
const generated = await generateYoutubeSubtitles(selectedTarget.target, args);
|
||||
preloadedSubtitles = {
|
||||
primaryPath: generated.primaryPath,
|
||||
secondaryPath: generated.secondaryPath,
|
||||
};
|
||||
const primaryStatus = generated.primaryPath
|
||||
? 'ready'
|
||||
: generated.primaryNative
|
||||
? 'native'
|
||||
: 'missing';
|
||||
const secondaryStatus = generated.secondaryPath
|
||||
? 'ready'
|
||||
: generated.secondaryNative
|
||||
? 'native'
|
||||
: 'missing';
|
||||
log(
|
||||
'info',
|
||||
args.logLevel,
|
||||
`YouTube subtitle result: primary=${primaryStatus}, secondary=${secondaryStatus}`,
|
||||
);
|
||||
deps.log('info', args.logLevel, 'YouTube subtitle flow: app-owned picker after mpv bootstrap');
|
||||
}
|
||||
|
||||
const shouldPauseUntilOverlayReady =
|
||||
@@ -191,47 +208,57 @@ export async function runPlaybackCommand(context: LauncherCommandContext): Promi
|
||||
pluginRuntimeConfig.autoStartPauseUntilReady;
|
||||
|
||||
if (shouldPauseUntilOverlayReady) {
|
||||
log('info', args.logLevel, 'Configured to pause mpv until overlay and tokenization are ready');
|
||||
deps.log('info', args.logLevel, 'Configured to pause mpv until overlay and tokenization are ready');
|
||||
}
|
||||
|
||||
await startMpv(
|
||||
await deps.startMpv(
|
||||
selectedTarget.target,
|
||||
selectedTarget.kind,
|
||||
args,
|
||||
mpvSocketPath,
|
||||
appPath,
|
||||
preloadedSubtitles,
|
||||
{ startPaused: shouldPauseUntilOverlayReady },
|
||||
undefined,
|
||||
{
|
||||
startPaused: shouldPauseUntilOverlayReady || isAppOwnedYoutubeFlow,
|
||||
disableYoutubeSubtitleAutoLoad: isAppOwnedYoutubeFlow,
|
||||
},
|
||||
);
|
||||
|
||||
const ready = await waitForUnixSocketReady(mpvSocketPath, 10000);
|
||||
const ready = await deps.waitForUnixSocketReady(mpvSocketPath, 10000);
|
||||
const pluginAutoStartEnabled = pluginRuntimeConfig.autoStart;
|
||||
const shouldStartOverlay = args.startOverlay || args.autoStartOverlay;
|
||||
const shouldStartOverlay = args.startOverlay || args.autoStartOverlay || isAppOwnedYoutubeFlow;
|
||||
if (shouldStartOverlay) {
|
||||
if (ready) {
|
||||
log('info', args.logLevel, 'MPV IPC socket ready, starting SubMiner overlay');
|
||||
deps.log('info', args.logLevel, 'MPV IPC socket ready, starting SubMiner overlay');
|
||||
} else {
|
||||
log(
|
||||
deps.log(
|
||||
'info',
|
||||
args.logLevel,
|
||||
'MPV IPC socket not ready after timeout, starting SubMiner overlay anyway',
|
||||
);
|
||||
}
|
||||
await startOverlay(appPath, args, mpvSocketPath);
|
||||
await deps.startOverlay(
|
||||
appPath,
|
||||
args,
|
||||
mpvSocketPath,
|
||||
isAppOwnedYoutubeFlow
|
||||
? ['--youtube-play', selectedTarget.target, '--youtube-mode', youtubeMode]
|
||||
: [],
|
||||
);
|
||||
} else if (pluginAutoStartEnabled) {
|
||||
if (ready) {
|
||||
log('info', args.logLevel, 'MPV IPC socket ready, relying on mpv plugin auto-start');
|
||||
deps.log('info', args.logLevel, 'MPV IPC socket ready, relying on mpv plugin auto-start');
|
||||
} else {
|
||||
log('info', args.logLevel, 'MPV IPC socket not ready yet, relying on mpv plugin auto-start');
|
||||
deps.log('info', args.logLevel, 'MPV IPC socket not ready yet, relying on mpv plugin auto-start');
|
||||
}
|
||||
} else if (ready) {
|
||||
log(
|
||||
deps.log(
|
||||
'info',
|
||||
args.logLevel,
|
||||
'MPV IPC socket ready, overlay auto-start disabled (use y-s to start)',
|
||||
);
|
||||
} else {
|
||||
log(
|
||||
deps.log(
|
||||
'info',
|
||||
args.logLevel,
|
||||
'MPV IPC socket not ready yet, overlay auto-start disabled (use y-s to start)',
|
||||
@@ -239,7 +266,7 @@ export async function runPlaybackCommand(context: LauncherCommandContext): Promi
|
||||
}
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
const mpvProc = state.mpvProc;
|
||||
const mpvProc = deps.getMpvProc();
|
||||
if (!mpvProc) {
|
||||
stopOverlay(args);
|
||||
resolve();
|
||||
@@ -247,7 +274,7 @@ export async function runPlaybackCommand(context: LauncherCommandContext): Promi
|
||||
}
|
||||
|
||||
const finalize = (code: number | null | undefined) => {
|
||||
void cleanupPlaybackSession(args).finally(() => {
|
||||
void deps.cleanupPlaybackSession(args).finally(() => {
|
||||
processAdapter.setExitCode(code ?? 0);
|
||||
resolve();
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user