mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-05-26 00:55:16 -07:00
feat(config): add configuration window (#70)
This commit is contained in:
@@ -6,8 +6,8 @@ export function runAppPassthroughCommand(context: LauncherCommandContext): boole
|
||||
if (!appPath) {
|
||||
return false;
|
||||
}
|
||||
if (args.configSettings) {
|
||||
runAppCommandWithInherit(appPath, ['--config']);
|
||||
if (args.settings) {
|
||||
runAppCommandWithInherit(appPath, ['--settings']);
|
||||
return true;
|
||||
}
|
||||
if (!args.appPassthrough) {
|
||||
|
||||
@@ -38,9 +38,14 @@ function createContext(overrides: Partial<LauncherCommandContext> = {}): Launche
|
||||
mpvSocketPath: '/tmp/subminer.sock',
|
||||
pluginRuntimeConfig: {
|
||||
socketPath: '/tmp/subminer.sock',
|
||||
binaryPath: '',
|
||||
backend: 'auto',
|
||||
autoStart: true,
|
||||
autoStartVisibleOverlay: true,
|
||||
autoStartPauseUntilReady: true,
|
||||
texthookerEnabled: false,
|
||||
aniskipEnabled: true,
|
||||
aniskipButtonKey: 'TAB',
|
||||
},
|
||||
appPath: '/tmp/subminer.app',
|
||||
launcherJellyfinConfig: {},
|
||||
|
||||
@@ -13,6 +13,7 @@ interface MpvCommandDeps {
|
||||
appPath: string,
|
||||
args: LauncherCommandContext['args'],
|
||||
runtimePluginPath?: string | null,
|
||||
runtimePluginConfig?: LauncherCommandContext['pluginRuntimeConfig'],
|
||||
): Promise<void>;
|
||||
}
|
||||
|
||||
@@ -49,7 +50,7 @@ export async function runMpvPostAppCommand(
|
||||
context: LauncherCommandContext,
|
||||
deps: MpvCommandDeps = defaultDeps,
|
||||
): Promise<boolean> {
|
||||
const { args, appPath, scriptPath, mpvSocketPath } = context;
|
||||
const { args, appPath, scriptPath, mpvSocketPath, pluginRuntimeConfig } = context;
|
||||
if (!args.mpvIdle) {
|
||||
return false;
|
||||
}
|
||||
@@ -62,6 +63,11 @@ export async function runMpvPostAppCommand(
|
||||
appPath,
|
||||
args,
|
||||
resolveLauncherRuntimePluginPath({ appPath, scriptPath }),
|
||||
{
|
||||
...pluginRuntimeConfig,
|
||||
backend: args.backend,
|
||||
texthookerEnabled: args.useTexthooker && pluginRuntimeConfig.texthookerEnabled,
|
||||
},
|
||||
);
|
||||
const ready = await deps.waitForUnixSocketReady(mpvSocketPath, 8000);
|
||||
if (!ready) {
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { EventEmitter } from 'node:events';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import type { LauncherCommandContext } from './context.js';
|
||||
import { runPlaybackCommandWithDeps } from './playback-command.js';
|
||||
import { state } from '../mpv.js';
|
||||
@@ -53,7 +56,7 @@ function createContext(): LauncherCommandContext {
|
||||
doctor: false,
|
||||
doctorRefreshKnownWords: false,
|
||||
version: false,
|
||||
configSettings: false,
|
||||
settings: false,
|
||||
configPath: false,
|
||||
configShow: false,
|
||||
mpvIdle: false,
|
||||
@@ -72,9 +75,14 @@ function createContext(): LauncherCommandContext {
|
||||
mpvSocketPath: '/tmp/subminer.sock',
|
||||
pluginRuntimeConfig: {
|
||||
socketPath: '/tmp/subminer.sock',
|
||||
binaryPath: '',
|
||||
backend: 'auto',
|
||||
autoStart: true,
|
||||
autoStartVisibleOverlay: true,
|
||||
autoStartPauseUntilReady: true,
|
||||
texthookerEnabled: false,
|
||||
aniskipEnabled: true,
|
||||
aniskipButtonKey: 'TAB',
|
||||
},
|
||||
appPath: '/tmp/SubMiner.AppImage',
|
||||
launcherJellyfinConfig: {},
|
||||
@@ -140,18 +148,24 @@ test('youtube playback launches overlay with app-owned youtube flow args', async
|
||||
assert.equal(receivedStartMpvOptions[0]?.disableYoutubeSubtitleAutoLoad, true);
|
||||
});
|
||||
|
||||
test('plugin auto-start playback marks background app for cleanup when mpv exits', async () => {
|
||||
test('plugin auto-start playback leaves app lifetime to managed-playback owner', async () => {
|
||||
const context = createContext();
|
||||
context.args = {
|
||||
...context.args,
|
||||
target: '/tmp/movie.mkv',
|
||||
targetKind: 'file',
|
||||
useTexthooker: true,
|
||||
};
|
||||
context.pluginRuntimeConfig = {
|
||||
socketPath: '/tmp/subminer.sock',
|
||||
binaryPath: '',
|
||||
backend: 'auto',
|
||||
autoStart: true,
|
||||
autoStartVisibleOverlay: false,
|
||||
autoStartPauseUntilReady: false,
|
||||
texthookerEnabled: false,
|
||||
aniskipEnabled: true,
|
||||
aniskipButtonKey: 'TAB',
|
||||
};
|
||||
const appPath = context.appPath ?? '';
|
||||
state.appPath = appPath;
|
||||
@@ -164,7 +178,7 @@ test('plugin auto-start playback marks background app for cleanup when mpv exits
|
||||
mpvProc.exitCode = null;
|
||||
mpvProc.killed = false;
|
||||
mpvProc.kill = () => true;
|
||||
let cleanupSawManagedOverlay = false;
|
||||
let cleanupSawManagedOverlay = true;
|
||||
|
||||
try {
|
||||
await runPlaybackCommandWithDeps(context, {
|
||||
@@ -190,9 +204,178 @@ test('plugin auto-start playback marks background app for cleanup when mpv exits
|
||||
getMpvProc: () => mpvProc as NonNullable<typeof state.mpvProc>,
|
||||
});
|
||||
|
||||
assert.equal(cleanupSawManagedOverlay, true);
|
||||
assert.equal(cleanupSawManagedOverlay, false);
|
||||
} finally {
|
||||
state.appPath = '';
|
||||
state.overlayManagedByLauncher = false;
|
||||
}
|
||||
});
|
||||
|
||||
test('plugin auto-start playback attaches a warm background app through the launcher', async () => {
|
||||
const context = createContext();
|
||||
context.args = {
|
||||
...context.args,
|
||||
target: '/tmp/movie.mkv',
|
||||
targetKind: 'file',
|
||||
useTexthooker: true,
|
||||
};
|
||||
context.pluginRuntimeConfig = {
|
||||
socketPath: '/tmp/subminer.sock',
|
||||
binaryPath: '',
|
||||
backend: 'auto',
|
||||
autoStart: true,
|
||||
autoStartVisibleOverlay: true,
|
||||
autoStartPauseUntilReady: true,
|
||||
texthookerEnabled: true,
|
||||
aniskipEnabled: true,
|
||||
aniskipButtonKey: 'TAB',
|
||||
};
|
||||
const calls: string[] = [];
|
||||
const receivedStartMpvOptions: Record<string, unknown>[] = [];
|
||||
|
||||
await runPlaybackCommandWithDeps(context, {
|
||||
ensurePlaybackSetupReady: async () => {},
|
||||
chooseTarget: async () => ({ target: context.args.target, kind: 'file' }),
|
||||
checkDependencies: () => {},
|
||||
registerCleanup: () => {},
|
||||
startMpv: async (
|
||||
_target,
|
||||
_targetKind,
|
||||
_args,
|
||||
_socketPath,
|
||||
_appPath,
|
||||
_preloadedSubtitles,
|
||||
options,
|
||||
) => {
|
||||
calls.push('startMpv');
|
||||
if (options) {
|
||||
receivedStartMpvOptions.push(options as Record<string, unknown>);
|
||||
}
|
||||
},
|
||||
waitForUnixSocketReady: async () => true,
|
||||
startOverlay: async (_appPath, _args, _socketPath, extraAppArgs = []) => {
|
||||
calls.push(`startOverlay:${extraAppArgs.join(' ')}`);
|
||||
},
|
||||
launchAppCommandDetached: () => {},
|
||||
log: () => {},
|
||||
cleanupPlaybackSession: async () => {},
|
||||
getMpvProc: () => null,
|
||||
isAppControlServerAvailable: async () => true,
|
||||
} as Parameters<typeof runPlaybackCommandWithDeps>[1] & {
|
||||
isAppControlServerAvailable: () => Promise<boolean>;
|
||||
});
|
||||
|
||||
assert.deepEqual(calls, ['startMpv', 'startOverlay:--show-visible-overlay --texthooker']);
|
||||
assert.equal(receivedStartMpvOptions[0]?.startPaused, true);
|
||||
assert.equal(
|
||||
(receivedStartMpvOptions[0]?.runtimePluginConfig as { autoStart?: boolean } | undefined)
|
||||
?.autoStart,
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test('plugin auto-start attach mode reuses launcher-resolved config dir for app control', async () => {
|
||||
const context = createContext();
|
||||
const originalXdgConfigHome = process.env.XDG_CONFIG_HOME;
|
||||
const xdgConfigHome = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-test-xdg-'));
|
||||
const expectedConfigDir = path.join(xdgConfigHome, 'SubMiner');
|
||||
fs.mkdirSync(expectedConfigDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(expectedConfigDir, 'config.jsonc'), '{}');
|
||||
context.args = {
|
||||
...context.args,
|
||||
target: '/tmp/movie.mkv',
|
||||
targetKind: 'file',
|
||||
useTexthooker: true,
|
||||
};
|
||||
context.pluginRuntimeConfig = {
|
||||
socketPath: '/tmp/subminer.sock',
|
||||
binaryPath: '',
|
||||
backend: 'auto',
|
||||
autoStart: true,
|
||||
autoStartVisibleOverlay: true,
|
||||
autoStartPauseUntilReady: true,
|
||||
texthookerEnabled: true,
|
||||
aniskipEnabled: true,
|
||||
aniskipButtonKey: 'TAB',
|
||||
};
|
||||
let availabilityConfigDir: string | undefined;
|
||||
let overlayConfigDir: string | undefined;
|
||||
|
||||
try {
|
||||
process.env.XDG_CONFIG_HOME = xdgConfigHome;
|
||||
|
||||
await runPlaybackCommandWithDeps(context, {
|
||||
ensurePlaybackSetupReady: async () => {},
|
||||
chooseTarget: async () => ({ target: context.args.target, kind: 'file' }),
|
||||
checkDependencies: () => {},
|
||||
registerCleanup: () => {},
|
||||
startMpv: async () => {},
|
||||
waitForUnixSocketReady: async () => true,
|
||||
startOverlay: async (_appPath, _args, _socketPath, _extraAppArgs = [], configDir) => {
|
||||
overlayConfigDir = configDir;
|
||||
},
|
||||
launchAppCommandDetached: () => {},
|
||||
log: () => {},
|
||||
cleanupPlaybackSession: async () => {},
|
||||
getMpvProc: () => null,
|
||||
isAppControlServerAvailable: async (_logLevel, configDir) => {
|
||||
availabilityConfigDir = configDir;
|
||||
return true;
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(availabilityConfigDir, expectedConfigDir);
|
||||
assert.equal(overlayConfigDir, expectedConfigDir);
|
||||
} finally {
|
||||
if (originalXdgConfigHome === undefined) {
|
||||
delete process.env.XDG_CONFIG_HOME;
|
||||
} else {
|
||||
process.env.XDG_CONFIG_HOME = originalXdgConfigHome;
|
||||
}
|
||||
fs.rmSync(xdgConfigHome, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('plugin auto-start attach mode omits texthooker flag when CLI texthooker is disabled', async () => {
|
||||
const context = createContext();
|
||||
context.args = {
|
||||
...context.args,
|
||||
target: '/tmp/movie.mkv',
|
||||
targetKind: 'file',
|
||||
};
|
||||
context.pluginRuntimeConfig = {
|
||||
socketPath: '/tmp/subminer.sock',
|
||||
binaryPath: '',
|
||||
backend: 'auto',
|
||||
autoStart: true,
|
||||
autoStartVisibleOverlay: true,
|
||||
autoStartPauseUntilReady: true,
|
||||
texthookerEnabled: true,
|
||||
aniskipEnabled: true,
|
||||
aniskipButtonKey: 'TAB',
|
||||
};
|
||||
const calls: string[] = [];
|
||||
|
||||
await runPlaybackCommandWithDeps(context, {
|
||||
ensurePlaybackSetupReady: async () => {},
|
||||
chooseTarget: async () => ({ target: context.args.target, kind: 'file' }),
|
||||
checkDependencies: () => {},
|
||||
registerCleanup: () => {},
|
||||
startMpv: async () => {
|
||||
calls.push('startMpv');
|
||||
},
|
||||
waitForUnixSocketReady: async () => true,
|
||||
startOverlay: async (_appPath, _args, _socketPath, extraAppArgs = []) => {
|
||||
calls.push(`startOverlay:${extraAppArgs.join(' ')}`);
|
||||
},
|
||||
launchAppCommandDetached: () => {},
|
||||
log: () => {},
|
||||
cleanupPlaybackSession: async () => {},
|
||||
getMpvProc: () => null,
|
||||
isAppControlServerAvailable: async () => true,
|
||||
} as Parameters<typeof runPlaybackCommandWithDeps>[1] & {
|
||||
isAppControlServerAvailable: () => Promise<boolean>;
|
||||
});
|
||||
|
||||
assert.deepEqual(calls, ['startMpv', 'startOverlay:--show-visible-overlay']);
|
||||
});
|
||||
|
||||
@@ -7,8 +7,8 @@ import { collectVideos, showFzfMenu, showRofiMenu } from '../picker.js';
|
||||
import {
|
||||
cleanupPlaybackSession,
|
||||
launchAppCommandDetached,
|
||||
markOverlayManagedByLauncher,
|
||||
resolveLauncherRuntimePluginPath,
|
||||
isRunningAppControlServerAvailable,
|
||||
startMpv,
|
||||
startOverlay,
|
||||
state,
|
||||
@@ -30,6 +30,13 @@ import { hasLauncherExternalYomitanProfileConfig } from '../config.js';
|
||||
const SETUP_WAIT_TIMEOUT_MS = 10 * 60 * 1000;
|
||||
const SETUP_POLL_INTERVAL_MS = 500;
|
||||
|
||||
function getLauncherConfigDir(): string {
|
||||
return getDefaultConfigDir({
|
||||
xdgConfigHome: process.env.XDG_CONFIG_HOME,
|
||||
homeDir: os.homedir(),
|
||||
});
|
||||
}
|
||||
|
||||
function checkDependencies(args: Args): void {
|
||||
const missing: string[] = [];
|
||||
|
||||
@@ -100,10 +107,7 @@ async function ensurePlaybackSetupReady(context: LauncherCommandContext): Promis
|
||||
const { args, appPath } = context;
|
||||
if (!appPath) return;
|
||||
|
||||
const configDir = getDefaultConfigDir({
|
||||
xdgConfigHome: process.env.XDG_CONFIG_HOME,
|
||||
homeDir: os.homedir(),
|
||||
});
|
||||
const configDir = getLauncherConfigDir();
|
||||
const statePath = getSetupStatePath(configDir);
|
||||
const ready = await ensureLauncherSetupReady({
|
||||
readSetupState: () => readSetupState(statePath),
|
||||
@@ -147,6 +151,7 @@ export async function runPlaybackCommand(context: LauncherCommandContext): Promi
|
||||
waitForUnixSocketReady,
|
||||
startOverlay,
|
||||
launchAppCommandDetached,
|
||||
isAppControlServerAvailable: isRunningAppControlServerAvailable,
|
||||
log,
|
||||
cleanupPlaybackSession,
|
||||
getMpvProc: () => state.mpvProc,
|
||||
@@ -165,6 +170,7 @@ type PlaybackCommandDeps = {
|
||||
waitForUnixSocketReady: typeof waitForUnixSocketReady;
|
||||
startOverlay: typeof startOverlay;
|
||||
launchAppCommandDetached: typeof launchAppCommandDetached;
|
||||
isAppControlServerAvailable?: (logLevel: Args['logLevel'], configDir: string) => Promise<boolean>;
|
||||
log: typeof log;
|
||||
cleanupPlaybackSession: typeof cleanupPlaybackSession;
|
||||
getMpvProc: () => typeof state.mpvProc;
|
||||
@@ -209,11 +215,23 @@ export async function runPlaybackCommandWithDeps(
|
||||
const isYoutubeUrl = selectedTarget.kind === 'url' && isYoutubeTarget(selectedTarget.target);
|
||||
const isAppOwnedYoutubeFlow = isYoutubeUrl;
|
||||
const youtubeMode = args.youtubeMode ?? 'download';
|
||||
const configDir = getLauncherConfigDir();
|
||||
|
||||
if (isYoutubeUrl) {
|
||||
deps.log('info', args.logLevel, 'YouTube subtitle flow: app-owned picker after mpv bootstrap');
|
||||
}
|
||||
|
||||
const pluginAutoStartEnabled = pluginRuntimeConfig.autoStart;
|
||||
const shouldLauncherAttachRunningApp =
|
||||
pluginAutoStartEnabled &&
|
||||
!args.startOverlay &&
|
||||
!args.autoStartOverlay &&
|
||||
!isAppOwnedYoutubeFlow &&
|
||||
((await deps.isAppControlServerAvailable?.(args.logLevel, configDir)) ?? false);
|
||||
const effectivePluginRuntimeConfig = shouldLauncherAttachRunningApp
|
||||
? { ...pluginRuntimeConfig, autoStart: false }
|
||||
: pluginRuntimeConfig;
|
||||
|
||||
const shouldPauseUntilOverlayReady =
|
||||
pluginRuntimeConfig.autoStart &&
|
||||
pluginRuntimeConfig.autoStartVisibleOverlay &&
|
||||
@@ -238,12 +256,20 @@ export async function runPlaybackCommandWithDeps(
|
||||
startPaused: shouldPauseUntilOverlayReady || isAppOwnedYoutubeFlow,
|
||||
disableYoutubeSubtitleAutoLoad: isAppOwnedYoutubeFlow,
|
||||
runtimePluginPath: resolveLauncherRuntimePluginPath({ appPath, scriptPath }),
|
||||
runtimePluginConfig: {
|
||||
...effectivePluginRuntimeConfig,
|
||||
backend: args.backend,
|
||||
texthookerEnabled: args.useTexthooker && effectivePluginRuntimeConfig.texthookerEnabled,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const ready = await deps.waitForUnixSocketReady(mpvSocketPath, 10000);
|
||||
const pluginAutoStartEnabled = pluginRuntimeConfig.autoStart;
|
||||
const shouldStartOverlay = args.startOverlay || args.autoStartOverlay || isAppOwnedYoutubeFlow;
|
||||
const shouldStartOverlay =
|
||||
args.startOverlay ||
|
||||
args.autoStartOverlay ||
|
||||
isAppOwnedYoutubeFlow ||
|
||||
shouldLauncherAttachRunningApp;
|
||||
if (shouldStartOverlay) {
|
||||
if (ready) {
|
||||
deps.log('info', args.logLevel, 'MPV IPC socket ready, starting SubMiner overlay');
|
||||
@@ -254,16 +280,20 @@ export async function runPlaybackCommandWithDeps(
|
||||
'MPV IPC socket not ready after timeout, starting SubMiner overlay anyway',
|
||||
);
|
||||
}
|
||||
await deps.startOverlay(
|
||||
appPath,
|
||||
args,
|
||||
mpvSocketPath,
|
||||
isAppOwnedYoutubeFlow
|
||||
? ['--youtube-play', selectedTarget.target, '--youtube-mode', youtubeMode]
|
||||
: [],
|
||||
);
|
||||
const extraAppArgs = isAppOwnedYoutubeFlow
|
||||
? ['--youtube-play', selectedTarget.target, '--youtube-mode', youtubeMode]
|
||||
: shouldLauncherAttachRunningApp
|
||||
? [
|
||||
pluginRuntimeConfig.autoStartVisibleOverlay
|
||||
? '--show-visible-overlay'
|
||||
: '--hide-visible-overlay',
|
||||
...(args.useTexthooker && effectivePluginRuntimeConfig.texthookerEnabled
|
||||
? ['--texthooker']
|
||||
: []),
|
||||
]
|
||||
: [];
|
||||
await deps.startOverlay(appPath, args, mpvSocketPath, extraAppArgs, configDir);
|
||||
} else if (pluginAutoStartEnabled) {
|
||||
markOverlayManagedByLauncher(appPath);
|
||||
if (ready) {
|
||||
deps.log('info', args.logLevel, 'MPV IPC socket ready, relying on mpv plugin auto-start');
|
||||
} else {
|
||||
|
||||
Reference in New Issue
Block a user