mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-05-13 08:12:54 -07:00
feat: inject bundled mpv plugin for managed launches, remove legacy glob (#62)
* feat: inject bundled mpv plugin for managed launches, remove legacy glob - SubMiner-managed launcher and Windows shortcut launches inject the bundled plugin when no global plugin is detected - First-run setup detects and removes legacy global plugin files via OS trash before managed playback starts - Makefile `install-plugin` target and Windows config-rewrite script removed; Linux/macOS install now copies plugin to app data dir - AniList stats search and post-watch tracking now go through the shared rate limiter - Stats cover-art lookup reuses cached AniList data before issuing a new request - Closing mpv in a launcher-managed session now terminates the background Electron app * harden bootstrap version load and clean plugin on uninstall - Use pcall for version.lua in bootstrap.lua so missing version module does not crash plugin startup - Remove plugin/subminer from app-data dirs in uninstall-linux and uninstall-macos targets - Add Lua compat test asserting bootstrap uses defensive pcall for version load - Add release-workflow test asserting uninstall targets clean bundled plugin dirs - Delete completed planning document
This commit is contained in:
@@ -1,5 +1,9 @@
|
||||
import { fail, log } from '../log.js';
|
||||
import { waitForUnixSocketReady, launchMpvIdleDetached } from '../mpv.js';
|
||||
import {
|
||||
waitForUnixSocketReady,
|
||||
launchMpvIdleDetached,
|
||||
resolveLauncherRuntimePluginPath,
|
||||
} from '../mpv.js';
|
||||
import type { LauncherCommandContext } from './context.js';
|
||||
|
||||
interface MpvCommandDeps {
|
||||
@@ -8,6 +12,7 @@ interface MpvCommandDeps {
|
||||
socketPath: string,
|
||||
appPath: string,
|
||||
args: LauncherCommandContext['args'],
|
||||
runtimePluginPath?: string | null,
|
||||
): Promise<void>;
|
||||
}
|
||||
|
||||
@@ -44,7 +49,7 @@ export async function runMpvPostAppCommand(
|
||||
context: LauncherCommandContext,
|
||||
deps: MpvCommandDeps = defaultDeps,
|
||||
): Promise<boolean> {
|
||||
const { args, appPath, mpvSocketPath } = context;
|
||||
const { args, appPath, scriptPath, mpvSocketPath } = context;
|
||||
if (!args.mpvIdle) {
|
||||
return false;
|
||||
}
|
||||
@@ -52,7 +57,12 @@ export async function runMpvPostAppCommand(
|
||||
fail('SubMiner app binary not found. Install to ~/.local/bin/ or set SUBMINER_APPIMAGE_PATH.');
|
||||
}
|
||||
|
||||
await deps.launchMpvIdleDetached(mpvSocketPath, appPath, args);
|
||||
await deps.launchMpvIdleDetached(
|
||||
mpvSocketPath,
|
||||
appPath,
|
||||
args,
|
||||
resolveLauncherRuntimePluginPath({ appPath, scriptPath }),
|
||||
);
|
||||
const ready = await deps.waitForUnixSocketReady(mpvSocketPath, 8000);
|
||||
if (!ready) {
|
||||
fail(`MPV IPC socket not ready after idle launch: ${mpvSocketPath}`);
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { EventEmitter } from 'node:events';
|
||||
import type { LauncherCommandContext } from './context.js';
|
||||
import { runPlaybackCommandWithDeps } from './playback-command.js';
|
||||
import { state } from '../mpv.js';
|
||||
|
||||
function createContext(): LauncherCommandContext {
|
||||
return {
|
||||
@@ -95,7 +97,7 @@ test('youtube playback launches overlay with app-owned youtube flow args', async
|
||||
autoStartVisibleOverlay: false,
|
||||
autoStartPauseUntilReady: false,
|
||||
};
|
||||
let receivedStartMpvOptions: Record<string, unknown> | null = null;
|
||||
const receivedStartMpvOptions: Record<string, unknown>[] = [];
|
||||
|
||||
await runPlaybackCommandWithDeps(context, {
|
||||
ensurePlaybackSetupReady: async () => {},
|
||||
@@ -111,7 +113,9 @@ test('youtube playback launches overlay with app-owned youtube flow args', async
|
||||
_preloadedSubtitles,
|
||||
options,
|
||||
) => {
|
||||
receivedStartMpvOptions = options ?? null;
|
||||
if (options) {
|
||||
receivedStartMpvOptions.push(options as Record<string, unknown>);
|
||||
}
|
||||
calls.push('startMpv');
|
||||
},
|
||||
waitForUnixSocketReady: async () => true,
|
||||
@@ -130,8 +134,63 @@ test('youtube playback launches overlay with app-owned youtube flow args', async
|
||||
'startMpv',
|
||||
'startOverlay:--youtube-play https://www.youtube.com/watch?v=65Ovd7t8sNw --youtube-mode download',
|
||||
]);
|
||||
assert.deepEqual(receivedStartMpvOptions, {
|
||||
startPaused: true,
|
||||
disableYoutubeSubtitleAutoLoad: true,
|
||||
});
|
||||
assert.equal(receivedStartMpvOptions[0]?.startPaused, true);
|
||||
assert.equal(receivedStartMpvOptions[0]?.disableYoutubeSubtitleAutoLoad, true);
|
||||
});
|
||||
|
||||
test('plugin auto-start playback marks background app for cleanup when mpv exits', async () => {
|
||||
const context = createContext();
|
||||
context.args = {
|
||||
...context.args,
|
||||
target: '/tmp/movie.mkv',
|
||||
targetKind: 'file',
|
||||
};
|
||||
context.pluginRuntimeConfig = {
|
||||
socketPath: '/tmp/subminer.sock',
|
||||
autoStart: true,
|
||||
autoStartVisibleOverlay: false,
|
||||
autoStartPauseUntilReady: false,
|
||||
};
|
||||
const appPath = context.appPath ?? '';
|
||||
state.appPath = appPath;
|
||||
state.overlayManagedByLauncher = false;
|
||||
const mpvProc = new EventEmitter() as EventEmitter & {
|
||||
exitCode: number | null;
|
||||
killed: boolean;
|
||||
kill: () => boolean;
|
||||
};
|
||||
mpvProc.exitCode = null;
|
||||
mpvProc.killed = false;
|
||||
mpvProc.kill = () => true;
|
||||
let cleanupSawManagedOverlay = false;
|
||||
|
||||
try {
|
||||
await runPlaybackCommandWithDeps(context, {
|
||||
ensurePlaybackSetupReady: async () => {},
|
||||
chooseTarget: async () => ({ target: context.args.target, kind: 'file' }),
|
||||
checkDependencies: () => {},
|
||||
registerCleanup: () => {},
|
||||
startMpv: async () => {
|
||||
setTimeout(() => {
|
||||
mpvProc.exitCode = 0;
|
||||
mpvProc.emit('exit', 0);
|
||||
}, 5);
|
||||
},
|
||||
waitForUnixSocketReady: async () => true,
|
||||
startOverlay: async () => {
|
||||
throw new Error('startOverlay should not run when plugin auto-start is used');
|
||||
},
|
||||
launchAppCommandDetached: () => {},
|
||||
log: () => {},
|
||||
cleanupPlaybackSession: async () => {
|
||||
cleanupSawManagedOverlay = state.overlayManagedByLauncher;
|
||||
},
|
||||
getMpvProc: () => mpvProc as NonNullable<typeof state.mpvProc>,
|
||||
});
|
||||
|
||||
assert.equal(cleanupSawManagedOverlay, true);
|
||||
} finally {
|
||||
state.appPath = '';
|
||||
state.overlayManagedByLauncher = false;
|
||||
}
|
||||
});
|
||||
|
||||
@@ -7,6 +7,8 @@ import { collectVideos, showFzfMenu, showRofiMenu } from '../picker.js';
|
||||
import {
|
||||
cleanupPlaybackSession,
|
||||
launchAppCommandDetached,
|
||||
markOverlayManagedByLauncher,
|
||||
resolveLauncherRuntimePluginPath,
|
||||
startMpv,
|
||||
startOverlay,
|
||||
state,
|
||||
@@ -21,9 +23,8 @@ import {
|
||||
getDefaultConfigDir,
|
||||
getSetupStatePath,
|
||||
readSetupState,
|
||||
resolveDefaultMpvInstallPaths,
|
||||
} from '../../src/shared/setup-state.js';
|
||||
import { detectInstalledFirstRunPlugin } from '../../src/main/runtime/first-run-setup-plugin.js';
|
||||
import { detectInstalledFirstRunPluginCandidates } from '../../src/main/runtime/first-run-setup-plugin.js';
|
||||
import { hasLauncherExternalYomitanProfileConfig } from '../config.js';
|
||||
|
||||
const SETUP_WAIT_TIMEOUT_MS = 10 * 60 * 1000;
|
||||
@@ -107,14 +108,13 @@ async function ensurePlaybackSetupReady(context: LauncherCommandContext): Promis
|
||||
const ready = await ensureLauncherSetupReady({
|
||||
readSetupState: () => readSetupState(statePath),
|
||||
isExternalYomitanConfigured: () => hasLauncherExternalYomitanProfileConfig(),
|
||||
isPluginInstalled: () => {
|
||||
const installPaths = resolveDefaultMpvInstallPaths(
|
||||
process.platform,
|
||||
os.homedir(),
|
||||
process.env.XDG_CONFIG_HOME,
|
||||
);
|
||||
return detectInstalledFirstRunPlugin(installPaths);
|
||||
},
|
||||
hasLegacyMpvPlugin: () =>
|
||||
detectInstalledFirstRunPluginCandidates({
|
||||
platform: process.platform,
|
||||
homeDir: os.homedir(),
|
||||
xdgConfigHome: process.env.XDG_CONFIG_HOME,
|
||||
appDataDir: process.env.APPDATA,
|
||||
}).length > 0,
|
||||
launchSetupApp: () => {
|
||||
const setupArgs = ['--background', '--setup'];
|
||||
if (args.logLevel) {
|
||||
@@ -237,6 +237,7 @@ export async function runPlaybackCommandWithDeps(
|
||||
{
|
||||
startPaused: shouldPauseUntilOverlayReady || isAppOwnedYoutubeFlow,
|
||||
disableYoutubeSubtitleAutoLoad: isAppOwnedYoutubeFlow,
|
||||
runtimePluginPath: resolveLauncherRuntimePluginPath({ appPath, scriptPath }),
|
||||
},
|
||||
);
|
||||
|
||||
@@ -262,6 +263,7 @@ export async function runPlaybackCommandWithDeps(
|
||||
: [],
|
||||
);
|
||||
} else if (pluginAutoStartEnabled) {
|
||||
markOverlayManagedByLauncher(appPath);
|
||||
if (ready) {
|
||||
deps.log('info', args.logLevel, 'MPV IPC socket ready, relying on mpv plugin auto-start');
|
||||
} else {
|
||||
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
runAppCommandCaptureOutput,
|
||||
launchAppStartDetached,
|
||||
launchMpvIdleDetached,
|
||||
resolveLauncherRuntimePluginPath,
|
||||
waitForUnixSocketReady,
|
||||
} from './mpv.js';
|
||||
|
||||
@@ -1014,7 +1015,12 @@ export async function runJellyfinPlayMenu(
|
||||
mpvReady = await waitForUnixSocketReady(mpvSocketPath, 250);
|
||||
}
|
||||
if (!mpvReady) {
|
||||
await launchMpvIdleDetached(mpvSocketPath, appPath, args);
|
||||
await launchMpvIdleDetached(
|
||||
mpvSocketPath,
|
||||
appPath,
|
||||
args,
|
||||
resolveLauncherRuntimePluginPath({ appPath, scriptPath }),
|
||||
);
|
||||
mpvReady = await waitForUnixSocketReady(mpvSocketPath, 8000);
|
||||
}
|
||||
log('debug', args.logLevel, `MPV socket ready check result: ${mpvReady ? 'ready' : 'not ready'}`);
|
||||
|
||||
@@ -17,6 +17,8 @@ import {
|
||||
launchTexthookerOnly,
|
||||
parseMpvArgString,
|
||||
runAppCommandCaptureOutput,
|
||||
resolveLauncherRuntimePluginPath,
|
||||
resolveLauncherRuntimePluginPlan,
|
||||
shouldResolveAniSkipMetadata,
|
||||
stopOverlay,
|
||||
startOverlay,
|
||||
@@ -262,6 +264,89 @@ test('buildConfiguredMpvDefaultArgs appends maximized launch mode to configured
|
||||
});
|
||||
});
|
||||
|
||||
test('resolveLauncherRuntimePluginPath finds bundled plugin from explicit environment path', () => {
|
||||
const pluginDir = '/opt/SubMiner/plugin/subminer';
|
||||
assert.equal(
|
||||
resolveLauncherRuntimePluginPath({
|
||||
appPath: '/opt/SubMiner/SubMiner.AppImage',
|
||||
env: { SUBMINER_MPV_PLUGIN_PATH: pluginDir },
|
||||
existsSync: (candidate) => candidate === path.join(pluginDir, 'main.lua'),
|
||||
}),
|
||||
path.join(pluginDir, 'main.lua'),
|
||||
);
|
||||
});
|
||||
|
||||
test('resolveLauncherRuntimePluginPath finds Linux app-support plugin assets', () => {
|
||||
const homeDir = '/home/tester';
|
||||
const expected = path.join(
|
||||
homeDir,
|
||||
'.local',
|
||||
'share',
|
||||
'SubMiner',
|
||||
'plugin',
|
||||
'subminer',
|
||||
'main.lua',
|
||||
);
|
||||
|
||||
assert.equal(
|
||||
resolveLauncherRuntimePluginPath({
|
||||
appPath: '/home/tester/.local/bin/SubMiner.AppImage',
|
||||
scriptPath: '/home/tester/.local/bin/subminer',
|
||||
platform: 'linux',
|
||||
homeDir,
|
||||
env: {},
|
||||
existsSync: (candidate) => candidate === expected,
|
||||
}),
|
||||
expected,
|
||||
);
|
||||
});
|
||||
|
||||
test('resolveLauncherRuntimePluginPlan injects bundled plugin when no installed plugin exists', () => {
|
||||
const plan = resolveLauncherRuntimePluginPlan({
|
||||
runtimePluginPath: '/opt/SubMiner/plugin/subminer/main.lua',
|
||||
platform: 'linux',
|
||||
homeDir: '/home/tester',
|
||||
existsSync: () => false,
|
||||
});
|
||||
|
||||
assert.equal(plan.scriptPath, '/opt/SubMiner/plugin/subminer/main.lua');
|
||||
assert.equal(plan.installedPlugin.installed, false);
|
||||
assert.equal(plan.warningMessage, null);
|
||||
assert.equal(plan.errorMessage, null);
|
||||
});
|
||||
|
||||
test('resolveLauncherRuntimePluginPlan uses installed plugin instead of bundled injection', () => {
|
||||
const installedPath = '/home/tester/.config/mpv/scripts/subminer/main.lua';
|
||||
const versionPath = '/home/tester/.config/mpv/scripts/subminer/version.lua';
|
||||
const existing = new Set([installedPath, versionPath]);
|
||||
const plan = resolveLauncherRuntimePluginPlan({
|
||||
runtimePluginPath: '/opt/SubMiner/plugin/subminer/main.lua',
|
||||
platform: 'linux',
|
||||
homeDir: '/home/tester',
|
||||
existsSync: (candidate) => existing.has(candidate),
|
||||
readFileSync: () => 'return { version = "0.12.0" }',
|
||||
});
|
||||
|
||||
assert.equal(plan.scriptPath, null);
|
||||
assert.equal(plan.installedPlugin.path, installedPath);
|
||||
assert.equal(plan.installedPlugin.version, '0.12.0');
|
||||
assert.match(plan.warningMessage ?? '', /This mpv session will use the installed plugin/);
|
||||
assert.equal(plan.errorMessage, null);
|
||||
});
|
||||
|
||||
test('resolveLauncherRuntimePluginPlan reports missing bundled plugin when no installed plugin exists', () => {
|
||||
const plan = resolveLauncherRuntimePluginPlan({
|
||||
runtimePluginPath: null,
|
||||
platform: 'linux',
|
||||
homeDir: '/home/tester',
|
||||
existsSync: () => false,
|
||||
});
|
||||
|
||||
assert.equal(plan.scriptPath, null);
|
||||
assert.equal(plan.installedPlugin.installed, false);
|
||||
assert.match(plan.errorMessage ?? '', /Packaged mpv plugin assets were not found/);
|
||||
});
|
||||
|
||||
test('launchTexthookerOnly exits non-zero when app binary cannot be spawned', () => {
|
||||
const error = withProcessExitIntercept(() => {
|
||||
launchTexthookerOnly('/definitely-missing-subminer-binary', makeArgs());
|
||||
|
||||
+216
-2
@@ -4,6 +4,10 @@ import os from 'node:os';
|
||||
import net from 'node:net';
|
||||
import { spawn, spawnSync } from 'node:child_process';
|
||||
import { buildMpvLaunchModeArgs } from '../src/shared/mpv-launch-mode.js';
|
||||
import {
|
||||
detectInstalledMpvPlugin,
|
||||
type InstalledMpvPluginDetection,
|
||||
} from '../src/main/runtime/first-run-setup-plugin.js';
|
||||
import type { LogLevel, Backend, Args, MpvTrack } from './types.js';
|
||||
import { DEFAULT_MPV_SUBMINER_ARGS, DEFAULT_YOUTUBE_YTDL_FORMAT } from './types.js';
|
||||
import { appendToAppLog, getAppLogPath, log, fail, getMpvLogPath } from './log.js';
|
||||
@@ -42,6 +46,13 @@ 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;
|
||||
|
||||
export interface LauncherRuntimePluginPlan {
|
||||
scriptPath: string | null;
|
||||
installedPlugin: InstalledMpvPluginDetection;
|
||||
warningMessage: string | null;
|
||||
errorMessage: string | null;
|
||||
}
|
||||
|
||||
export function parseMpvArgString(input: string): string[] {
|
||||
const chars = input;
|
||||
const args: string[] = [];
|
||||
@@ -226,6 +237,182 @@ export function makeTempDir(prefix: string): string {
|
||||
return fs.mkdtempSync(path.join(os.tmpdir(), prefix));
|
||||
}
|
||||
|
||||
function normalizeRuntimePluginEntrypoint(
|
||||
candidate: string,
|
||||
deps: {
|
||||
pathModule: typeof path;
|
||||
existsSync: (candidate: string) => boolean;
|
||||
},
|
||||
): string | null {
|
||||
const trimmed = candidate.trim();
|
||||
if (!trimmed) return null;
|
||||
if (trimmed.endsWith('.lua')) {
|
||||
return deps.existsSync(trimmed) ? trimmed : null;
|
||||
}
|
||||
const entrypoint = deps.pathModule.join(trimmed, 'main.lua');
|
||||
return deps.existsSync(entrypoint) ? entrypoint : null;
|
||||
}
|
||||
|
||||
function pushMacAppRuntimePluginCandidate(
|
||||
candidates: string[],
|
||||
appPath: string,
|
||||
pathModule: typeof path,
|
||||
): void {
|
||||
const appIndex = appPath.indexOf('.app');
|
||||
if (appIndex < 0) return;
|
||||
candidates.push(
|
||||
pathModule.join(
|
||||
appPath.slice(0, appIndex + '.app'.length),
|
||||
'Contents',
|
||||
'Resources',
|
||||
'plugin',
|
||||
'subminer',
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
export function resolveLauncherRuntimePluginPath(options: {
|
||||
appPath: string;
|
||||
scriptPath?: string;
|
||||
platform?: NodeJS.Platform;
|
||||
homeDir?: string;
|
||||
cwd?: string;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
dirname?: string;
|
||||
pathModule?: typeof path;
|
||||
existsSync?: (candidate: string) => boolean;
|
||||
}): string | null {
|
||||
const pathModule = options.pathModule ?? path;
|
||||
const existsSync = options.existsSync ?? fs.existsSync;
|
||||
const env = options.env ?? process.env;
|
||||
const dirname = options.dirname ?? __dirname;
|
||||
const cwd = options.cwd ?? process.cwd();
|
||||
const platform = options.platform ?? process.platform;
|
||||
const homeDir = options.homeDir ?? os.homedir();
|
||||
const candidates: string[] = [];
|
||||
|
||||
if (env.SUBMINER_MPV_PLUGIN_PATH) {
|
||||
candidates.push(env.SUBMINER_MPV_PLUGIN_PATH);
|
||||
}
|
||||
|
||||
pushMacAppRuntimePluginCandidate(candidates, options.appPath, pathModule);
|
||||
|
||||
const appDir = pathModule.dirname(options.appPath);
|
||||
candidates.push(
|
||||
pathModule.join(appDir, 'resources', 'plugin', 'subminer'),
|
||||
pathModule.join(appDir, 'plugin', 'subminer'),
|
||||
);
|
||||
|
||||
if (options.scriptPath) {
|
||||
const scriptDir = pathModule.dirname(realpathMaybe(options.scriptPath));
|
||||
candidates.push(
|
||||
pathModule.join(scriptDir, '..', 'share', 'SubMiner', 'plugin', 'subminer'),
|
||||
pathModule.join(scriptDir, '..', 'lib', 'SubMiner', 'plugin', 'subminer'),
|
||||
pathModule.join(scriptDir, 'plugin', 'subminer'),
|
||||
);
|
||||
}
|
||||
|
||||
if (platform === 'darwin') {
|
||||
candidates.push(
|
||||
pathModule.join(homeDir, 'Library', 'Application Support', 'SubMiner', 'plugin', 'subminer'),
|
||||
);
|
||||
} else if (platform !== 'win32') {
|
||||
candidates.push(
|
||||
pathModule.join(
|
||||
env.XDG_DATA_HOME?.trim() || pathModule.join(homeDir, '.local', 'share'),
|
||||
'SubMiner',
|
||||
'plugin',
|
||||
'subminer',
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
candidates.push(
|
||||
pathModule.join(cwd, 'plugin', 'subminer'),
|
||||
pathModule.join(dirname, '..', 'plugin', 'subminer'),
|
||||
pathModule.join(dirname, '..', '..', 'plugin', 'subminer'),
|
||||
);
|
||||
|
||||
const seen = new Set<string>();
|
||||
for (const candidate of candidates) {
|
||||
const resolved = pathModule.resolve(candidate);
|
||||
if (seen.has(resolved)) continue;
|
||||
seen.add(resolved);
|
||||
const entrypoint = normalizeRuntimePluginEntrypoint(resolved, { pathModule, existsSync });
|
||||
if (entrypoint) {
|
||||
return entrypoint;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function resolveLauncherRuntimePluginPlan(options: {
|
||||
runtimePluginPath?: string | null;
|
||||
platform?: NodeJS.Platform;
|
||||
homeDir?: string;
|
||||
xdgConfigHome?: string;
|
||||
appDataDir?: string;
|
||||
mpvExecutablePath?: string;
|
||||
existsSync?: (candidate: string) => boolean;
|
||||
readFileSync?: (candidate: string, encoding: BufferEncoding) => string;
|
||||
}): LauncherRuntimePluginPlan {
|
||||
const installedPlugin = detectInstalledMpvPlugin({
|
||||
platform: options.platform ?? process.platform,
|
||||
homeDir: options.homeDir ?? os.homedir(),
|
||||
xdgConfigHome: options.xdgConfigHome ?? process.env.XDG_CONFIG_HOME,
|
||||
appDataDir: options.appDataDir ?? process.env.APPDATA,
|
||||
mpvExecutablePath: options.mpvExecutablePath,
|
||||
existsSync: options.existsSync,
|
||||
readFileSync: options.readFileSync,
|
||||
});
|
||||
|
||||
if (installedPlugin.installed) {
|
||||
const versionText = installedPlugin.version
|
||||
? `Detected plugin version: ${installedPlugin.version}.`
|
||||
: 'Detected plugin version: unknown or legacy.';
|
||||
return {
|
||||
scriptPath: null,
|
||||
installedPlugin,
|
||||
warningMessage: `SubMiner detected an installed mpv plugin at: ${installedPlugin.path}. This mpv session will use the installed plugin. Remove it to use SubMiner's bundled runtime plugin automatically. ${versionText}`,
|
||||
errorMessage: null,
|
||||
};
|
||||
}
|
||||
|
||||
if (options.runtimePluginPath) {
|
||||
return {
|
||||
scriptPath: options.runtimePluginPath,
|
||||
installedPlugin,
|
||||
warningMessage: null,
|
||||
errorMessage: null,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
scriptPath: null,
|
||||
installedPlugin,
|
||||
warningMessage: null,
|
||||
errorMessage:
|
||||
'Packaged mpv plugin assets were not found. Install the SubMiner assets bundle or set SUBMINER_MPV_PLUGIN_PATH to plugin/subminer/main.lua.',
|
||||
};
|
||||
}
|
||||
|
||||
function appendRuntimePluginLaunchArgs(
|
||||
mpvArgs: string[],
|
||||
plan: LauncherRuntimePluginPlan,
|
||||
logLevel: LogLevel,
|
||||
): void {
|
||||
if (plan.warningMessage) {
|
||||
log('warn', logLevel, plan.warningMessage);
|
||||
}
|
||||
if (plan.errorMessage) {
|
||||
fail(plan.errorMessage);
|
||||
}
|
||||
if (plan.scriptPath) {
|
||||
mpvArgs.push(`--script=${plan.scriptPath}`);
|
||||
}
|
||||
}
|
||||
|
||||
export function detectBackend(
|
||||
backend: Backend,
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
@@ -658,7 +845,11 @@ export async function startMpv(
|
||||
socketPath: string,
|
||||
appPath: string,
|
||||
preloadedSubtitles?: { primaryPath?: string; secondaryPath?: string },
|
||||
options?: { startPaused?: boolean; disableYoutubeSubtitleAutoLoad?: boolean },
|
||||
options?: {
|
||||
startPaused?: boolean;
|
||||
disableYoutubeSubtitleAutoLoad?: boolean;
|
||||
runtimePluginPath?: string | null;
|
||||
},
|
||||
): Promise<void> {
|
||||
if (targetKind === 'file' && (!fs.existsSync(target) || !fs.statSync(target).isFile())) {
|
||||
fail(`Video file not found: ${target}`);
|
||||
@@ -672,6 +863,14 @@ export async function startMpv(
|
||||
|
||||
const mpvArgs: string[] = [];
|
||||
mpvArgs.push(...buildConfiguredMpvDefaultArgs(args));
|
||||
appendRuntimePluginLaunchArgs(
|
||||
mpvArgs,
|
||||
resolveLauncherRuntimePluginPlan({
|
||||
runtimePluginPath:
|
||||
options?.runtimePluginPath ?? resolveLauncherRuntimePluginPath({ appPath }),
|
||||
}),
|
||||
args.logLevel,
|
||||
);
|
||||
if (targetKind === 'url' && isYoutubeTarget(target)) {
|
||||
log('info', args.logLevel, 'Applying URL playback options');
|
||||
mpvArgs.push('--ytdl=yes');
|
||||
@@ -811,7 +1010,7 @@ export async function startOverlay(
|
||||
env: buildAppEnv(),
|
||||
});
|
||||
attachAppProcessLogging(state.overlayProc);
|
||||
state.overlayManagedByLauncher = true;
|
||||
markOverlayManagedByLauncher(appPath);
|
||||
|
||||
const [socketReady] = await Promise.all([
|
||||
waitForUnixSocketReady(socketPath, OVERLAY_START_SOCKET_READY_TIMEOUT_MS),
|
||||
@@ -831,6 +1030,13 @@ export async function startOverlay(
|
||||
}
|
||||
}
|
||||
|
||||
export function markOverlayManagedByLauncher(appPath?: string): void {
|
||||
if (appPath) {
|
||||
state.appPath = appPath;
|
||||
}
|
||||
state.overlayManagedByLauncher = true;
|
||||
}
|
||||
|
||||
export function openUrlInDefaultBrowser(url: string, logLevel: LogLevel): void {
|
||||
const target =
|
||||
process.platform === 'darwin'
|
||||
@@ -1236,6 +1442,7 @@ export function launchMpvIdleDetached(
|
||||
socketPath: string,
|
||||
appPath: string,
|
||||
args: Args,
|
||||
runtimePluginPath?: string | null,
|
||||
): Promise<void> {
|
||||
return (async () => {
|
||||
await terminateTrackedDetachedMpv(args.logLevel);
|
||||
@@ -1246,6 +1453,13 @@ export function launchMpvIdleDetached(
|
||||
}
|
||||
|
||||
const mpvArgs: string[] = buildConfiguredMpvDefaultArgs(args);
|
||||
appendRuntimePluginLaunchArgs(
|
||||
mpvArgs,
|
||||
resolveLauncherRuntimePluginPlan({
|
||||
runtimePluginPath: runtimePluginPath ?? resolveLauncherRuntimePluginPath({ appPath }),
|
||||
}),
|
||||
args.logLevel,
|
||||
);
|
||||
if (args.mpvArgs) {
|
||||
mpvArgs.push(...parseMpvArgString(args.mpvArgs));
|
||||
}
|
||||
|
||||
+63
-16
@@ -116,34 +116,81 @@ test('ensureLauncherSetupReady bypasses setup gate when external yomitan is conf
|
||||
assert.deepEqual(calls, []);
|
||||
});
|
||||
|
||||
test('ensureLauncherSetupReady bypasses setup gate when plugin is already installed', async () => {
|
||||
test('ensureLauncherSetupReady waits for finish after legacy mpv plugin removal', async () => {
|
||||
const calls: string[] = [];
|
||||
let legacyPluginInstalled = true;
|
||||
let reads = 0;
|
||||
|
||||
const ready = await ensureLauncherSetupReady({
|
||||
readSetupState: () => ({
|
||||
version: 3,
|
||||
status: 'cancelled',
|
||||
completedAt: null,
|
||||
completionSource: null,
|
||||
yomitanSetupMode: null,
|
||||
lastSeenYomitanDictionaryCount: 0,
|
||||
pluginInstallStatus: 'unknown',
|
||||
pluginInstallPathSummary: null,
|
||||
windowsMpvShortcutPreferences: { startMenuEnabled: true, desktopEnabled: true },
|
||||
windowsMpvShortcutLastStatus: 'unknown',
|
||||
}),
|
||||
isPluginInstalled: () => true,
|
||||
readSetupState: () => {
|
||||
reads += 1;
|
||||
return {
|
||||
version: 3,
|
||||
status: 'completed',
|
||||
completedAt: reads < 3 ? '2026-03-07T00:00:00.000Z' : '2026-05-12T14:40:00.000Z',
|
||||
completionSource: 'user',
|
||||
yomitanSetupMode: null,
|
||||
lastSeenYomitanDictionaryCount: 0,
|
||||
pluginInstallStatus: 'unknown',
|
||||
pluginInstallPathSummary: null,
|
||||
windowsMpvShortcutPreferences: { startMenuEnabled: true, desktopEnabled: true },
|
||||
windowsMpvShortcutLastStatus: 'unknown',
|
||||
};
|
||||
},
|
||||
hasLegacyMpvPlugin: () => legacyPluginInstalled,
|
||||
launchSetupApp: () => {
|
||||
calls.push('launch');
|
||||
legacyPluginInstalled = false;
|
||||
},
|
||||
sleep: async () => undefined,
|
||||
now: () => 0,
|
||||
now: (() => {
|
||||
let value = 0;
|
||||
return () => (value += 100);
|
||||
})(),
|
||||
timeoutMs: 5_000,
|
||||
pollIntervalMs: 100,
|
||||
});
|
||||
|
||||
assert.equal(ready, true);
|
||||
assert.deepEqual(calls, []);
|
||||
assert.deepEqual(calls, ['launch']);
|
||||
assert.equal(reads >= 3, true);
|
||||
});
|
||||
|
||||
test('ensureLauncherSetupReady lets users continue without removing a legacy mpv plugin', async () => {
|
||||
const calls: string[] = [];
|
||||
let reads = 0;
|
||||
|
||||
const ready = await ensureLauncherSetupReady({
|
||||
readSetupState: () => {
|
||||
reads += 1;
|
||||
return {
|
||||
version: 3,
|
||||
status: 'completed',
|
||||
completedAt: reads < 3 ? '2026-03-07T00:00:00.000Z' : '2026-05-12T14:30:00.000Z',
|
||||
completionSource: 'user',
|
||||
yomitanSetupMode: 'internal',
|
||||
lastSeenYomitanDictionaryCount: 2,
|
||||
pluginInstallStatus: 'unknown',
|
||||
pluginInstallPathSummary: null,
|
||||
windowsMpvShortcutPreferences: { startMenuEnabled: true, desktopEnabled: true },
|
||||
windowsMpvShortcutLastStatus: 'unknown',
|
||||
};
|
||||
},
|
||||
hasLegacyMpvPlugin: () => true,
|
||||
launchSetupApp: () => {
|
||||
calls.push('launch');
|
||||
},
|
||||
sleep: async () => undefined,
|
||||
now: (() => {
|
||||
let value = 0;
|
||||
return () => (value += 100);
|
||||
})(),
|
||||
timeoutMs: 5_000,
|
||||
pollIntervalMs: 100,
|
||||
});
|
||||
|
||||
assert.equal(ready, true);
|
||||
assert.deepEqual(calls, ['launch']);
|
||||
});
|
||||
|
||||
test('ensureLauncherSetupReady fails on timeout/cancelled state', async () => {
|
||||
|
||||
+58
-8
@@ -32,31 +32,81 @@ export async function waitForSetupCompletion(deps: {
|
||||
return 'timeout';
|
||||
}
|
||||
|
||||
export async function waitForLegacyMpvPluginPromptResolution(deps: {
|
||||
readSetupState: () => SetupState | null;
|
||||
sleep: (ms: number) => Promise<void>;
|
||||
now: () => number;
|
||||
timeoutMs: number;
|
||||
pollIntervalMs: number;
|
||||
initialState?: SetupState | null;
|
||||
}): Promise<'acknowledged' | 'cancelled' | 'timeout'> {
|
||||
const deadline = deps.now() + deps.timeoutMs;
|
||||
const initialCompleted = isSetupCompleted(deps.initialState);
|
||||
const initialCompletedAt = deps.initialState?.completedAt ?? null;
|
||||
|
||||
while (deps.now() <= deadline) {
|
||||
const state = deps.readSetupState();
|
||||
if (
|
||||
isSetupCompleted(state) &&
|
||||
(!initialCompleted || state?.completedAt !== initialCompletedAt)
|
||||
) {
|
||||
return 'acknowledged';
|
||||
}
|
||||
if (!initialCompleted && state?.status === 'cancelled') {
|
||||
return 'cancelled';
|
||||
}
|
||||
|
||||
await deps.sleep(deps.pollIntervalMs);
|
||||
}
|
||||
|
||||
return 'timeout';
|
||||
}
|
||||
|
||||
export async function ensureLauncherSetupReady(deps: {
|
||||
readSetupState: () => SetupState | null;
|
||||
isExternalYomitanConfigured?: () => boolean;
|
||||
isPluginInstalled?: () => boolean;
|
||||
hasLegacyMpvPlugin?: () => boolean;
|
||||
launchSetupApp: () => void;
|
||||
sleep: (ms: number) => Promise<void>;
|
||||
now: () => number;
|
||||
timeoutMs: number;
|
||||
pollIntervalMs: number;
|
||||
}): Promise<boolean> {
|
||||
const initialState = deps.readSetupState();
|
||||
let setupLaunched = false;
|
||||
const launchSetupApp = () => {
|
||||
if (setupLaunched) return;
|
||||
setupLaunched = true;
|
||||
deps.launchSetupApp();
|
||||
};
|
||||
|
||||
if (deps.hasLegacyMpvPlugin?.()) {
|
||||
launchSetupApp();
|
||||
const result = await waitForLegacyMpvPluginPromptResolution({
|
||||
readSetupState: deps.readSetupState,
|
||||
sleep: deps.sleep,
|
||||
now: deps.now,
|
||||
timeoutMs: deps.timeoutMs,
|
||||
pollIntervalMs: deps.pollIntervalMs,
|
||||
initialState,
|
||||
});
|
||||
if (result === 'cancelled' || result === 'timeout') {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (deps.isExternalYomitanConfigured?.()) {
|
||||
return true;
|
||||
}
|
||||
if (deps.isPluginInstalled?.()) {
|
||||
return true;
|
||||
}
|
||||
const initialState = deps.readSetupState();
|
||||
if (isSetupCompleted(initialState)) {
|
||||
const stateAfterLegacyPrompt = deps.readSetupState();
|
||||
if (isSetupCompleted(stateAfterLegacyPrompt)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
deps.launchSetupApp();
|
||||
launchSetupApp();
|
||||
const result = await waitForSetupCompletion({
|
||||
...deps,
|
||||
ignoreInitialCancelledState: initialState?.status === 'cancelled',
|
||||
ignoreInitialCancelledState: stateAfterLegacyPrompt?.status === 'cancelled',
|
||||
});
|
||||
return result === 'completed';
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user