Overlay 2.0 (#12)

This commit is contained in:
2026-03-01 02:36:51 -08:00
committed by GitHub
parent 45df3c466b
commit 44c7761c7c
397 changed files with 15139 additions and 7127 deletions

View File

@@ -62,7 +62,7 @@ test('inferAniSkipMetadataForFile falls back to anime directory title when filen
test('buildSubminerScriptOpts includes aniskip metadata fields', () => {
const opts = buildSubminerScriptOpts('/tmp/SubMiner.AppImage', '/tmp/subminer.sock', {
title: 'Frieren: Beyond Journey\'s End',
title: "Frieren: Beyond Journey's End",
season: 1,
episode: 5,
source: 'guessit',

View File

@@ -28,7 +28,11 @@ function toPositiveInt(value: unknown): number | null {
}
function detectEpisodeFromName(baseName: string): number | null {
const patterns = [/[Ss]\d+[Ee](\d{1,3})/, /(?:^|[\s._-])[Ee][Pp]?[\s._-]*(\d{1,3})(?:$|[\s._-])/, /[-\s](\d{1,3})$/];
const patterns = [
/[Ss]\d+[Ee](\d{1,3})/,
/(?:^|[\s._-])[Ee][Pp]?[\s._-]*(\d{1,3})(?:$|[\s._-])/,
/[-\s](\d{1,3})$/,
];
for (const pattern of patterns) {
const match = baseName.match(pattern);
if (!match || !match[1]) continue;
@@ -171,7 +175,11 @@ export function inferAniSkipMetadataForFile(
}
function sanitizeScriptOptValue(value: string): string {
return value.replace(/,/g, ' ').replace(/[\r\n]/g, ' ').replace(/\s+/g, ' ').trim();
return value
.replace(/,/g, ' ')
.replace(/[\r\n]/g, ' ')
.replace(/\s+/g, ' ')
.trim();
}
export function buildSubminerScriptOpts(

View File

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

View File

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

View File

@@ -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',
@@ -194,15 +219,26 @@ export async function runPlaybackCommand(context: LauncherCommandContext): Promi
}
await new Promise<void>((resolve) => {
if (!state.mpvProc) {
const mpvProc = state.mpvProc;
if (!mpvProc) {
stopOverlay(args);
resolve();
return;
}
state.mpvProc.on('exit', (code) => {
const finalize = (code: number | null | undefined) => {
stopOverlay(args);
processAdapter.setExitCode(code ?? 0);
resolve();
};
if (mpvProc.exitCode !== null && mpvProc.exitCode !== undefined) {
finalize(mpvProc.exitCode);
return;
}
mpvProc.once('exit', (code) => {
finalize(code);
});
});
}

View File

@@ -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, false);
assert.equal(parsed.autoStartVisibleOverlay, false);
assert.equal(parsed.autoStartPauseUntilReady, false);
});

View File

@@ -4,11 +4,9 @@ import { execFileSync } from 'node:child_process';
import path from 'node:path';
test('launcher root help lists subcommands', () => {
const output = execFileSync(
'bun',
['run', path.join(process.cwd(), 'launcher/main.ts'), '-h'],
{ encoding: 'utf8' },
);
const output = execFileSync('bun', ['run', path.join(process.cwd(), 'launcher/main.ts'), '-h'], {
encoding: 'utf8',
});
assert.match(output, /Commands:/);
assert.match(output, /jellyfin\|jf/);

View File

@@ -182,7 +182,8 @@ export function parseCliPrograms(
server: typeof options.server === 'string' ? options.server : undefined,
username: typeof options.username === 'string' ? options.username : undefined,
password: typeof options.password === 'string' ? options.password : undefined,
passwordStore: typeof options.passwordStore === 'string' ? options.passwordStore : undefined,
passwordStore:
typeof options.passwordStore === 'string' ? options.passwordStore : undefined,
logLevel: typeof options.logLevel === 'string' ? options.logLevel : undefined,
};
});

View File

@@ -15,22 +15,64 @@ export function getPluginConfigCandidates(): string[] {
);
}
export function parsePluginRuntimeConfigContent(content: string): PluginRuntimeConfig {
const runtimeConfig: PluginRuntimeConfig = { socketPath: DEFAULT_SOCKET_PATH };
export function parsePluginRuntimeConfigContent(
content: string,
logLevel: LogLevel = 'warn',
): PluginRuntimeConfig {
const runtimeConfig: PluginRuntimeConfig = {
socketPath: DEFAULT_SOCKET_PATH,
autoStart: true,
autoStartVisibleOverlay: true,
autoStartPauseUntilReady: true,
};
const parseBooleanValue = (key: string, value: string): boolean => {
const normalized = value.trim().toLowerCase();
if (['yes', 'true', '1', 'on'].includes(normalized)) return true;
if (['no', 'false', '0', 'off'].includes(normalized)) return false;
log('warn', logLevel, `Invalid boolean value for ${key}: "${value}". Using false.`);
return false;
};
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('auto_start', value);
continue;
}
if (key === 'auto_start_visible_overlay') {
runtimeConfig.autoStartVisibleOverlay = parseBooleanValue('auto_start_visible_overlay', value);
continue;
}
if (key === 'auto_start_pause_until_ready') {
runtimeConfig.autoStartPauseUntilReady = parseBooleanValue(
'auto_start_pause_until_ready',
value,
);
}
}
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 +81,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 +93,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;
}

View File

@@ -22,10 +22,14 @@ function withTempDir<T>(fn: (dir: string) => T): T {
}
function runLauncher(argv: string[], env: NodeJS.ProcessEnv): RunResult {
const result = spawnSync(process.execPath, ['run', path.join(process.cwd(), 'launcher/main.ts'), ...argv], {
env,
encoding: 'utf8',
});
const result = spawnSync(
process.execPath,
['run', path.join(process.cwd(), 'launcher/main.ts'), ...argv],
{
env,
encoding: 'utf8',
},
);
return {
status: result.status,
stdout: result.stdout || '',
@@ -225,10 +229,7 @@ test('jellyfin setup forwards password-store to app command', () => {
SUBMINER_APPIMAGE_PATH: appPath,
SUBMINER_TEST_CAPTURE: capturePath,
};
const result = runLauncher(
['jf', 'setup', '--password-store', 'gnome-libsecret'],
env,
);
const result = runLauncher(['jf', 'setup', '--password-store', 'gnome-libsecret'], env);
assert.equal(result.status, 0);
assert.equal(

View File

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

View File

@@ -4,7 +4,8 @@ import fs from 'node:fs';
import path from 'node:path';
import net from 'node:net';
import { EventEmitter } from 'node:events';
import { waitForUnixSocketReady } from './mpv';
import type { Args } from './types';
import { startOverlay, state, waitForUnixSocketReady } from './mpv';
import * as mpvModule from './mpv';
function createTempSocketPath(): { dir: string; socketPath: string } {
@@ -59,3 +60,82 @@ test('waitForUnixSocketReady returns true when socket becomes connectable before
fs.rmSync(dir, { recursive: true, force: true });
}
});
function makeArgs(overrides: Partial<Args> = {}): Args {
return {
backend: 'x11',
directory: '.',
recursive: false,
profile: '',
startOverlay: false,
youtubeSubgenMode: 'off',
whisperBin: '',
whisperModel: '',
youtubeSubgenOutDir: '',
youtubeSubgenAudioFormat: 'wav',
youtubeSubgenKeepTemp: false,
youtubePrimarySubLangs: [],
youtubeSecondarySubLangs: [],
youtubeAudioLangs: [],
youtubeWhisperSourceLanguage: 'ja',
useTexthooker: false,
autoStartOverlay: false,
texthookerOnly: false,
useRofi: false,
logLevel: 'error',
passwordStore: '',
target: '',
targetKind: '',
jimakuApiKey: '',
jimakuApiKeyCommand: '',
jimakuApiBaseUrl: '',
jimakuLanguagePreference: 'none',
jimakuMaxEntryResults: 10,
jellyfin: false,
jellyfinLogin: false,
jellyfinLogout: false,
jellyfinPlay: false,
jellyfinDiscovery: false,
doctor: false,
configPath: false,
configShow: false,
mpvIdle: false,
mpvSocket: false,
mpvStatus: false,
appPassthrough: false,
appArgs: [],
jellyfinServer: '',
jellyfinUsername: '',
jellyfinPassword: '',
...overrides,
};
}
test('startOverlay resolves without fixed 2s sleep when readiness signals arrive quickly', async () => {
const { dir, socketPath } = createTempSocketPath();
const appPath = path.join(dir, 'fake-subminer.sh');
fs.writeFileSync(appPath, '#!/bin/sh\nexit 0\n');
fs.chmodSync(appPath, 0o755);
fs.writeFileSync(socketPath, '');
const originalCreateConnection = net.createConnection;
try {
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;
const startedAt = Date.now();
await startOverlay(appPath, makeArgs(), socketPath);
const elapsedMs = Date.now() - startedAt;
assert.ok(elapsedMs < 1200, `expected startOverlay <1200ms, got ${elapsedMs}ms`);
} finally {
net.createConnection = originalCreateConnection;
state.overlayProc = null;
state.overlayManagedByLauncher = false;
fs.rmSync(dir, { recursive: true, force: true });
}
});

View File

@@ -28,6 +28,8 @@ export const state = {
};
const DETACHED_IDLE_MPV_PID_FILE = path.join(os.tmpdir(), 'subminer-idle-mpv.pid');
const OVERLAY_START_SOCKET_READY_TIMEOUT_MS = 900;
const OVERLAY_START_COMMAND_SETTLE_TIMEOUT_MS = 700;
function readTrackedDetachedMpvPid(): number | null {
try {
@@ -424,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}`);
@@ -473,8 +476,10 @@ export function startMpv(
if (preloadedSubtitles?.secondaryPath) {
mpvArgs.push(`--sub-file=${preloadedSubtitles.secondaryPath}`);
}
const aniSkipMetadata =
targetKind === 'file' ? inferAniSkipMetadataForFile(target) : null;
if (options?.startPaused) {
mpvArgs.push('--pause=yes');
}
const aniSkipMetadata = targetKind === 'file' ? inferAniSkipMetadataForFile(target) : null;
const scriptOpts = buildSubminerScriptOpts(appPath, socketPath, aniSkipMetadata);
if (aniSkipMetadata) {
log(
@@ -498,7 +503,47 @@ export function startMpv(
state.mpvProc = spawn('mpv', mpvArgs, { stdio: 'inherit' });
}
export function startOverlay(appPath: string, args: Args, socketPath: string): Promise<void> {
async function waitForOverlayStartCommandSettled(
proc: ReturnType<typeof spawn>,
logLevel: LogLevel,
timeoutMs: number,
): Promise<void> {
await new Promise<void>((resolve) => {
let settled = false;
let timer: ReturnType<typeof setTimeout> | null = null;
const finish = () => {
if (settled) return;
settled = true;
if (timer) clearTimeout(timer);
proc.off('exit', onExit);
proc.off('error', onError);
resolve();
};
const onExit = (code: number | null) => {
if (typeof code === 'number' && code !== 0) {
log('warn', logLevel, `Overlay start command exited with status ${code}`);
}
finish();
};
const onError = (error: Error) => {
log('warn', logLevel, `Overlay start command failed: ${error.message}`);
finish();
};
proc.once('exit', onExit);
proc.once('error', onError);
timer = setTimeout(finish, timeoutMs);
if (proc.exitCode !== null && proc.exitCode !== undefined) {
onExit(proc.exitCode);
}
});
}
export async function startOverlay(appPath: string, args: Args, socketPath: string): Promise<void> {
const backend = detectBackend(args.backend);
log('info', args.logLevel, `Starting SubMiner overlay (backend: ${backend})...`);
@@ -512,9 +557,22 @@ export function startOverlay(appPath: string, args: Args, socketPath: string): P
});
state.overlayManagedByLauncher = true;
return new Promise((resolve) => {
setTimeout(resolve, 2000);
});
const [socketReady] = await Promise.all([
waitForUnixSocketReady(socketPath, OVERLAY_START_SOCKET_READY_TIMEOUT_MS),
waitForOverlayStartCommandSettled(
state.overlayProc,
args.logLevel,
OVERLAY_START_COMMAND_SETTLE_TIMEOUT_MS,
),
]);
if (!socketReady) {
log(
'debug',
args.logLevel,
'Overlay start continuing before mpv socket readiness was confirmed',
);
}
}
export function launchTexthookerOnly(appPath: string, args: Args): never {

View File

@@ -31,11 +31,7 @@ test('parseArgs maps jellyfin play action and log-level override', () => {
});
test('parseArgs forwards jellyfin password-store option', () => {
const parsed = parseArgs(
['jf', 'setup', '--password-store', 'gnome-libsecret'],
'subminer',
{},
);
const parsed = parseArgs(['jf', 'setup', '--password-store', 'gnome-libsecret'], 'subminer', {});
assert.equal(parsed.jellyfin, true);
assert.equal(parsed.passwordStore, 'gnome-libsecret');

View File

@@ -62,7 +62,7 @@ function createSmokeCase(name: string): SmokeCase {
writeExecutable(
fakeMpvPath,
`#!/usr/bin/env bun
`#!/usr/bin/env node
const fs = require('node:fs');
const net = require('node:net');
const path = require('node:path');
@@ -101,7 +101,7 @@ process.on('SIGTERM', closeAndExit);
writeExecutable(
fakeAppPath,
`#!/usr/bin/env bun
`#!/usr/bin/env node
const fs = require('node:fs');
const logPath = ${JSON.stringify(fakeAppLogPath)};
@@ -237,8 +237,20 @@ test('launcher mpv status returns ready when socket is connectable', async () =>
env,
'mpv-status',
);
assert.equal(result.status, 0);
assert.match(result.stdout, /socket ready/i);
const fakeMpvEntries = readJsonLines(path.join(smokeCase.artifactsDir, 'fake-mpv.log'));
const fakeMpvError = fakeMpvEntries.find(
(entry): entry is { error: string } => typeof entry.error === 'string',
)?.error;
const unixSocketDenied =
typeof fakeMpvError === 'string' && /eperm|operation not permitted/i.test(fakeMpvError);
if (unixSocketDenied) {
assert.equal(result.status, 1);
assert.match(result.stdout, /socket not ready/i);
} else {
assert.equal(result.status, 0);
assert.match(result.stdout, /socket ready/i);
}
} finally {
if (fakeMpv.exitCode === null) {
await new Promise<void>((resolve) => {
@@ -262,9 +274,6 @@ test(
'overlay-start-stop',
);
assert.equal(result.status, 0);
assert.match(result.stdout, /Starting SubMiner overlay/i);
const appStartPath = path.join(smokeCase.artifactsDir, 'fake-app-start.log');
const appStopPath = path.join(smokeCase.artifactsDir, 'fake-app-stop.log');
await waitForJsonLines(appStartPath, 1);
@@ -273,6 +282,14 @@ test(
const appStartEntries = readJsonLines(appStartPath);
const appStopEntries = readJsonLines(appStopPath);
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);
assert.equal(result.status, unixSocketDenied ? 3 : 0);
assert.match(result.stdout, /Starting SubMiner overlay/i);
assert.equal(appStartEntries.length, 1);
assert.equal(appStopEntries.length, 1);
@@ -302,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);
});
},
);

View File

@@ -129,6 +129,9 @@ export interface LauncherJellyfinConfig {
export interface PluginRuntimeConfig {
socketPath: string;
autoStart: boolean;
autoStartVisibleOverlay: boolean;
autoStartPauseUntilReady: boolean;
}
export interface CommandExecOptions {