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

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

View File

@@ -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 () => {

View File

@@ -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;
}

View File

@@ -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) =>

View File

@@ -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(

View File

@@ -0,0 +1,113 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import type { LauncherCommandContext } from './context.js';
import { runPlaybackCommandWithDeps } from './playback-command.js';
function createContext(): LauncherCommandContext {
return {
args: {
backend: 'auto',
directory: '.',
recursive: false,
profile: '',
startOverlay: false,
youtubeMode: 'download',
whisperBin: '',
whisperModel: '',
whisperVadModel: '',
whisperThreads: 0,
youtubeSubgenOutDir: '',
youtubeSubgenAudioFormat: '',
youtubeSubgenKeepTemp: false,
youtubeFixWithAi: false,
youtubePrimarySubLangs: [],
youtubeSecondarySubLangs: [],
youtubeAudioLangs: [],
youtubeWhisperSourceLanguage: '',
aiConfig: {},
useTexthooker: false,
autoStartOverlay: false,
texthookerOnly: false,
useRofi: false,
logLevel: 'info',
passwordStore: '',
target: 'https://www.youtube.com/watch?v=65Ovd7t8sNw',
targetKind: 'url',
jimakuApiKey: '',
jimakuApiKeyCommand: '',
jimakuApiBaseUrl: '',
jimakuLanguagePreference: 'ja',
jimakuMaxEntryResults: 20,
jellyfin: false,
jellyfinLogin: false,
jellyfinLogout: false,
jellyfinPlay: false,
jellyfinDiscovery: false,
dictionary: false,
stats: false,
doctor: false,
doctorRefreshKnownWords: false,
configPath: false,
configShow: false,
mpvIdle: false,
mpvSocket: false,
mpvStatus: false,
mpvArgs: '',
appPassthrough: false,
appArgs: [],
jellyfinServer: '',
jellyfinUsername: '',
jellyfinPassword: '',
},
scriptPath: '/tmp/subminer',
scriptName: 'subminer',
mpvSocketPath: '/tmp/subminer.sock',
pluginRuntimeConfig: {
socketPath: '/tmp/subminer.sock',
autoStart: true,
autoStartVisibleOverlay: true,
autoStartPauseUntilReady: true,
},
appPath: '/tmp/SubMiner.AppImage',
launcherJellyfinConfig: {},
processAdapter: {
platform: () => 'linux',
onSignal: () => {},
writeStdout: () => {},
exit: (_code: number): never => {
throw new Error('unexpected exit');
},
setExitCode: () => {},
},
};
}
test('youtube playback launches overlay with youtube-play args in the primary app start', async () => {
const calls: string[] = [];
const context = createContext();
await runPlaybackCommandWithDeps(context, {
ensurePlaybackSetupReady: async () => {},
chooseTarget: async (_args, _scriptPath) => ({ target: context.args.target, kind: 'url' }),
checkDependencies: () => {},
registerCleanup: () => {},
startMpv: async () => {
calls.push('startMpv');
},
waitForUnixSocketReady: async () => true,
startOverlay: async (_appPath, _args, _socketPath, extraAppArgs = []) => {
calls.push(`startOverlay:${extraAppArgs.join(' ')}`);
},
launchAppCommandDetached: (_appPath: string, appArgs: string[]) => {
calls.push(`launch:${appArgs.join(' ')}`);
},
log: () => {},
cleanupPlaybackSession: async () => {},
getMpvProc: () => null,
});
assert.deepEqual(calls, [
'startMpv',
'startOverlay:--youtube-play https://www.youtube.com/watch?v=65Ovd7t8sNw --youtube-mode download',
]);
});

View File

@@ -6,13 +6,13 @@ import { commandExists, isYoutubeTarget, realpathMaybe, resolvePathMaybe } from
import { collectVideos, showFzfMenu, showRofiMenu } from '../picker.js';
import {
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();
});