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:
2026-05-12 23:11:19 -07:00
committed by GitHub
parent e5c1135501
commit 7c9b65db8b
43 changed files with 2116 additions and 481 deletions
+13 -3
View File
@@ -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}`);
+65 -6
View File
@@ -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;
}
});
+12 -10
View File
@@ -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 {