mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-03-01 06:22:44 -08:00
feat(launcher): pause auto-start playback until overlay is ready
This commit is contained in:
@@ -33,6 +33,12 @@ function createContext(overrides: Partial<LauncherCommandContext> = {}): Launche
|
||||
scriptPath: '/tmp/subminer',
|
||||
scriptName: 'subminer',
|
||||
mpvSocketPath: '/tmp/subminer.sock',
|
||||
pluginRuntimeConfig: {
|
||||
socketPath: '/tmp/subminer.sock',
|
||||
autoStart: true,
|
||||
autoStartVisibleOverlay: true,
|
||||
autoStartPauseUntilReady: true,
|
||||
},
|
||||
appPath: '/tmp/subminer.app',
|
||||
launcherJellyfinConfig: {},
|
||||
processAdapter: adapter,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { Args, LauncherJellyfinConfig } from '../types.js';
|
||||
import type { Args, LauncherJellyfinConfig, PluginRuntimeConfig } from '../types.js';
|
||||
import type { ProcessAdapter } from '../process-adapter.js';
|
||||
|
||||
export interface LauncherCommandContext {
|
||||
@@ -6,6 +6,7 @@ export interface LauncherCommandContext {
|
||||
scriptPath: string;
|
||||
scriptName: string;
|
||||
mpvSocketPath: string;
|
||||
pluginRuntimeConfig: PluginRuntimeConfig;
|
||||
appPath: string | null;
|
||||
launcherJellyfinConfig: LauncherJellyfinConfig;
|
||||
processAdapter: ProcessAdapter;
|
||||
|
||||
@@ -86,7 +86,7 @@ function registerCleanup(context: LauncherCommandContext): void {
|
||||
}
|
||||
|
||||
export async function runPlaybackCommand(context: LauncherCommandContext): Promise<void> {
|
||||
const { args, appPath, scriptPath, mpvSocketPath, processAdapter } = context;
|
||||
const { args, appPath, scriptPath, mpvSocketPath, pluginRuntimeConfig, processAdapter } = context;
|
||||
if (!appPath) {
|
||||
fail('SubMiner AppImage not found. Install to ~/.local/bin/ or set SUBMINER_APPIMAGE_PATH.');
|
||||
}
|
||||
@@ -137,6 +137,19 @@ export async function runPlaybackCommand(context: LauncherCommandContext): Promi
|
||||
log('info', args.logLevel, 'YouTube subtitle mode: off');
|
||||
}
|
||||
|
||||
const shouldPauseUntilOverlayReady =
|
||||
pluginRuntimeConfig.autoStart &&
|
||||
pluginRuntimeConfig.autoStartVisibleOverlay &&
|
||||
pluginRuntimeConfig.autoStartPauseUntilReady;
|
||||
|
||||
if (shouldPauseUntilOverlayReady) {
|
||||
log(
|
||||
'info',
|
||||
args.logLevel,
|
||||
'Configured to pause mpv until overlay and tokenization are ready',
|
||||
);
|
||||
}
|
||||
|
||||
startMpv(
|
||||
selectedTarget.target,
|
||||
selectedTarget.kind,
|
||||
@@ -144,6 +157,7 @@ export async function runPlaybackCommand(context: LauncherCommandContext): Promi
|
||||
mpvSocketPath,
|
||||
appPath,
|
||||
preloadedSubtitles,
|
||||
{ startPaused: shouldPauseUntilOverlayReady },
|
||||
);
|
||||
|
||||
if (isYoutubeUrl && args.youtubeSubgenMode === 'automatic') {
|
||||
@@ -167,6 +181,7 @@ export async function runPlaybackCommand(context: LauncherCommandContext): Promi
|
||||
}
|
||||
|
||||
const ready = await waitForUnixSocketReady(mpvSocketPath, 10000);
|
||||
const pluginAutoStartEnabled = pluginRuntimeConfig.autoStart;
|
||||
const shouldStartOverlay = args.startOverlay || args.autoStartOverlay;
|
||||
if (shouldStartOverlay) {
|
||||
if (ready) {
|
||||
@@ -179,6 +194,16 @@ export async function runPlaybackCommand(context: LauncherCommandContext): Promi
|
||||
);
|
||||
}
|
||||
await startOverlay(appPath, args, mpvSocketPath);
|
||||
} else if (pluginAutoStartEnabled) {
|
||||
if (ready) {
|
||||
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',
|
||||
);
|
||||
}
|
||||
} else if (ready) {
|
||||
log(
|
||||
'info',
|
||||
|
||||
@@ -51,10 +51,27 @@ test('parseLauncherJellyfinConfig omits legacy token and user id fields', () =>
|
||||
assert.equal('userId' in parsed, false);
|
||||
});
|
||||
|
||||
test('parsePluginRuntimeConfigContent reads socket_path and ignores inline comments', () => {
|
||||
test('parsePluginRuntimeConfigContent reads socket path and startup gate options', () => {
|
||||
const parsed = parsePluginRuntimeConfigContent(`
|
||||
# comment
|
||||
socket_path = /tmp/custom.sock # trailing comment
|
||||
auto_start = yes
|
||||
auto_start_visible_overlay = true
|
||||
auto_start_pause_until_ready = 1
|
||||
`);
|
||||
assert.equal(parsed.socketPath, '/tmp/custom.sock');
|
||||
assert.equal(parsed.autoStart, true);
|
||||
assert.equal(parsed.autoStartVisibleOverlay, true);
|
||||
assert.equal(parsed.autoStartPauseUntilReady, true);
|
||||
});
|
||||
|
||||
test('parsePluginRuntimeConfigContent falls back to disabled startup gate options', () => {
|
||||
const parsed = parsePluginRuntimeConfigContent(`
|
||||
auto_start = maybe
|
||||
auto_start_visible_overlay = no
|
||||
auto_start_pause_until_ready = off
|
||||
`);
|
||||
assert.equal(parsed.autoStart, true);
|
||||
assert.equal(parsed.autoStartVisibleOverlay, false);
|
||||
assert.equal(parsed.autoStartPauseUntilReady, false);
|
||||
});
|
||||
|
||||
@@ -16,21 +16,62 @@ export function getPluginConfigCandidates(): string[] {
|
||||
}
|
||||
|
||||
export function parsePluginRuntimeConfigContent(content: string): PluginRuntimeConfig {
|
||||
const runtimeConfig: PluginRuntimeConfig = { socketPath: DEFAULT_SOCKET_PATH };
|
||||
const runtimeConfig: PluginRuntimeConfig = {
|
||||
socketPath: DEFAULT_SOCKET_PATH,
|
||||
autoStart: true,
|
||||
autoStartVisibleOverlay: true,
|
||||
autoStartPauseUntilReady: true,
|
||||
};
|
||||
|
||||
const parseBooleanValue = (value: string, fallback: boolean): boolean => {
|
||||
const normalized = value.trim().toLowerCase();
|
||||
if (['yes', 'true', '1', 'on'].includes(normalized)) return true;
|
||||
if (['no', 'false', '0', 'off'].includes(normalized)) return false;
|
||||
return fallback;
|
||||
};
|
||||
|
||||
for (const line of content.split(/\r?\n/)) {
|
||||
const trimmed = line.trim();
|
||||
if (trimmed.length === 0 || trimmed.startsWith('#')) continue;
|
||||
const socketMatch = trimmed.match(/^socket_path\s*=\s*(.+)$/i);
|
||||
if (!socketMatch) continue;
|
||||
const value = (socketMatch[1] || '').split('#', 1)[0]?.trim() || '';
|
||||
if (value) runtimeConfig.socketPath = value;
|
||||
const keyValueMatch = trimmed.match(/^([a-z0-9_-]+)\s*=\s*(.+)$/i);
|
||||
if (!keyValueMatch) continue;
|
||||
const key = (keyValueMatch[1] || '').toLowerCase();
|
||||
const value = (keyValueMatch[2] || '').split('#', 1)[0]?.trim() || '';
|
||||
if (!value) continue;
|
||||
|
||||
if (key === 'socket_path') {
|
||||
runtimeConfig.socketPath = value;
|
||||
continue;
|
||||
}
|
||||
if (key === 'auto_start') {
|
||||
runtimeConfig.autoStart = parseBooleanValue(value, runtimeConfig.autoStart);
|
||||
continue;
|
||||
}
|
||||
if (key === 'auto_start_visible_overlay') {
|
||||
runtimeConfig.autoStartVisibleOverlay = parseBooleanValue(
|
||||
value,
|
||||
runtimeConfig.autoStartVisibleOverlay,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
if (key === 'auto_start_pause_until_ready') {
|
||||
runtimeConfig.autoStartPauseUntilReady = parseBooleanValue(
|
||||
value,
|
||||
runtimeConfig.autoStartPauseUntilReady,
|
||||
);
|
||||
}
|
||||
}
|
||||
return runtimeConfig;
|
||||
}
|
||||
|
||||
export function readPluginRuntimeConfig(logLevel: LogLevel): PluginRuntimeConfig {
|
||||
const candidates = getPluginConfigCandidates();
|
||||
const defaults: PluginRuntimeConfig = { socketPath: DEFAULT_SOCKET_PATH };
|
||||
const defaults: PluginRuntimeConfig = {
|
||||
socketPath: DEFAULT_SOCKET_PATH,
|
||||
autoStart: true,
|
||||
autoStartVisibleOverlay: true,
|
||||
autoStartPauseUntilReady: true,
|
||||
};
|
||||
|
||||
for (const configPath of candidates) {
|
||||
if (!fs.existsSync(configPath)) continue;
|
||||
@@ -39,7 +80,7 @@ export function readPluginRuntimeConfig(logLevel: LogLevel): PluginRuntimeConfig
|
||||
log(
|
||||
'debug',
|
||||
logLevel,
|
||||
`Using mpv plugin settings from ${configPath}: socket_path=${parsed.socketPath}`,
|
||||
`Using mpv plugin settings from ${configPath}: socket_path=${parsed.socketPath}, auto_start=${parsed.autoStart}, auto_start_visible_overlay=${parsed.autoStartVisibleOverlay}, auto_start_pause_until_ready=${parsed.autoStartPauseUntilReady}`,
|
||||
);
|
||||
return parsed;
|
||||
} catch {
|
||||
@@ -51,7 +92,7 @@ export function readPluginRuntimeConfig(logLevel: LogLevel): PluginRuntimeConfig
|
||||
log(
|
||||
'debug',
|
||||
logLevel,
|
||||
`No mpv subminer.conf found; using launcher defaults (socket_path=${defaults.socketPath})`,
|
||||
`No mpv subminer.conf found; using launcher defaults (socket_path=${defaults.socketPath}, auto_start=${defaults.autoStart}, auto_start_visible_overlay=${defaults.autoStartVisibleOverlay}, auto_start_pause_until_ready=${defaults.autoStartPauseUntilReady})`,
|
||||
);
|
||||
return defaults;
|
||||
}
|
||||
|
||||
@@ -19,14 +19,15 @@ import { runPlaybackCommand } from './commands/playback-command.js';
|
||||
function createCommandContext(
|
||||
args: ReturnType<typeof parseArgs>,
|
||||
scriptPath: string,
|
||||
mpvSocketPath: string,
|
||||
pluginRuntimeConfig: ReturnType<typeof readPluginRuntimeConfig>,
|
||||
appPath: string | null,
|
||||
): LauncherCommandContext {
|
||||
return {
|
||||
args,
|
||||
scriptPath,
|
||||
scriptName: path.basename(scriptPath),
|
||||
mpvSocketPath,
|
||||
mpvSocketPath: pluginRuntimeConfig.socketPath,
|
||||
pluginRuntimeConfig,
|
||||
appPath,
|
||||
launcherJellyfinConfig: loadLauncherJellyfinConfig(),
|
||||
processAdapter: nodeProcessAdapter,
|
||||
@@ -55,7 +56,7 @@ async function main(): Promise<void> {
|
||||
|
||||
log('debug', args.logLevel, `Wrapper log level set to: ${args.logLevel}`);
|
||||
|
||||
const context = createCommandContext(args, scriptPath, pluginRuntimeConfig.socketPath, appPath);
|
||||
const context = createCommandContext(args, scriptPath, pluginRuntimeConfig, appPath);
|
||||
|
||||
if (runDoctorCommand(context)) {
|
||||
return;
|
||||
@@ -71,6 +72,7 @@ async function main(): Promise<void> {
|
||||
|
||||
const resolvedAppPath = ensureAppPath(context);
|
||||
state.appPath = resolvedAppPath;
|
||||
log('debug', args.logLevel, `Using SubMiner app binary: ${resolvedAppPath}`);
|
||||
const appContext: LauncherCommandContext = {
|
||||
...context,
|
||||
appPath: resolvedAppPath,
|
||||
|
||||
@@ -426,6 +426,7 @@ export function startMpv(
|
||||
socketPath: string,
|
||||
appPath: string,
|
||||
preloadedSubtitles?: { primaryPath?: string; secondaryPath?: string },
|
||||
options?: { startPaused?: boolean },
|
||||
): void {
|
||||
if (targetKind === 'file' && (!fs.existsSync(target) || !fs.statSync(target).isFile())) {
|
||||
fail(`Video file not found: ${target}`);
|
||||
@@ -475,6 +476,9 @@ export function startMpv(
|
||||
if (preloadedSubtitles?.secondaryPath) {
|
||||
mpvArgs.push(`--sub-file=${preloadedSubtitles.secondaryPath}`);
|
||||
}
|
||||
if (options?.startPaused) {
|
||||
mpvArgs.push('--pause=yes');
|
||||
}
|
||||
const aniSkipMetadata = targetKind === 'file' ? inferAniSkipMetadataForFile(target) : null;
|
||||
const scriptOpts = buildSubminerScriptOpts(appPath, socketPath, aniSkipMetadata);
|
||||
if (aniSkipMetadata) {
|
||||
|
||||
@@ -319,3 +319,43 @@ test(
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'launcher starts mpv paused when plugin auto-start visible overlay gate is enabled',
|
||||
{ timeout: 20000 },
|
||||
async () => {
|
||||
await withSmokeCase('autoplay-ready-gate', async (smokeCase) => {
|
||||
fs.writeFileSync(
|
||||
path.join(smokeCase.xdgConfigHome, 'mpv', 'script-opts', 'subminer.conf'),
|
||||
[
|
||||
`socket_path=${smokeCase.socketPath}`,
|
||||
'auto_start=yes',
|
||||
'auto_start_visible_overlay=yes',
|
||||
'auto_start_pause_until_ready=yes',
|
||||
'',
|
||||
].join('\n'),
|
||||
);
|
||||
|
||||
const env = makeTestEnv(smokeCase);
|
||||
const result = runLauncher(
|
||||
smokeCase,
|
||||
[smokeCase.videoPath, '--log-level', 'debug'],
|
||||
env,
|
||||
'autoplay-ready-gate',
|
||||
);
|
||||
|
||||
const mpvEntries = readJsonLines(path.join(smokeCase.artifactsDir, 'fake-mpv.log'));
|
||||
const mpvError = mpvEntries.find(
|
||||
(entry): entry is { error: string } => typeof entry.error === 'string',
|
||||
)?.error;
|
||||
const unixSocketDenied =
|
||||
typeof mpvError === 'string' && /eperm|operation not permitted/i.test(mpvError);
|
||||
const mpvFirstArgs = mpvEntries[0]?.argv;
|
||||
|
||||
assert.equal(result.status, unixSocketDenied ? 3 : 0);
|
||||
assert.equal(Array.isArray(mpvFirstArgs), true);
|
||||
assert.equal((mpvFirstArgs as string[]).includes('--pause=yes'), true);
|
||||
assert.match(result.stdout, /pause mpv until overlay and tokenization are ready/i);
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
@@ -129,6 +129,9 @@ export interface LauncherJellyfinConfig {
|
||||
|
||||
export interface PluginRuntimeConfig {
|
||||
socketPath: string;
|
||||
autoStart: boolean;
|
||||
autoStartVisibleOverlay: boolean;
|
||||
autoStartPauseUntilReady: boolean;
|
||||
}
|
||||
|
||||
export interface CommandExecOptions {
|
||||
|
||||
Reference in New Issue
Block a user