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
+235 -54
View File
@@ -1,15 +1,30 @@
import fs from 'node:fs';
import path from 'node:path';
import { resolveDefaultMpvInstallPaths, type MpvInstallPaths } from '../../shared/setup-state';
import type { PluginInstallResult } from './first-run-setup-service';
function timestamp(): string {
return new Date().toISOString().replaceAll(':', '-');
export interface InstalledFirstRunPluginCandidate {
path: string;
kind: 'directory' | 'file';
}
function backupExistingPath(targetPath: string): void {
if (!fs.existsSync(targetPath)) return;
fs.renameSync(targetPath, `${targetPath}.bak.${timestamp()}`);
export type InstalledMpvPluginSource =
| 'default-config'
| 'xdg-config'
| 'portable-config'
| 'legacy-file';
export interface InstalledMpvPluginDetection {
installed: boolean;
path: string | null;
version: string | null;
source: InstalledMpvPluginSource | null;
message: string | null;
}
export interface LegacyMpvPluginRemovalResult {
ok: boolean;
removedPaths: string[];
failedPaths: Array<{ path: string; message: string }>;
}
function rewriteInstalledWindowsPluginConfig(configPath: string): void {
@@ -89,6 +104,30 @@ export function resolvePackagedFirstRunPluginAssets(deps: {
return null;
}
export function resolvePackagedRuntimePluginPath(deps: {
dirname: string;
appPath: string;
resourcesPath: string;
joinPath?: (...parts: string[]) => string;
existsSync?: (candidate: string) => boolean;
}): string | null {
const joinPath = deps.joinPath ?? path.join;
const existsSync = deps.existsSync ?? fs.existsSync;
const assets = resolvePackagedFirstRunPluginAssets({
dirname: deps.dirname,
appPath: deps.appPath,
resourcesPath: deps.resourcesPath,
joinPath,
existsSync,
});
if (!assets) {
return null;
}
const entrypoint = joinPath(assets.pluginDirSource, 'main.lua');
return existsSync(entrypoint) ? entrypoint : null;
}
export function detectInstalledFirstRunPlugin(
installPaths: MpvInstallPaths,
deps?: {
@@ -100,61 +139,203 @@ export function detectInstalledFirstRunPlugin(
return existsSync(pluginEntrypointPath);
}
export function installFirstRunPluginToDefaultLocation(options: {
function getPlatformPath(platform: NodeJS.Platform): typeof path.posix | typeof path.win32 {
return platform === 'win32' ? path.win32 : path.posix;
}
interface MpvConfigRootCandidate {
root: string;
source: Exclude<InstalledMpvPluginSource, 'legacy-file'>;
}
function collectMpvConfigRootCandidates(options: {
platform: NodeJS.Platform;
homeDir: string;
xdgConfigHome?: string;
dirname: string;
appPath: string;
resourcesPath: string;
binaryPath: string;
}): PluginInstallResult {
const installPaths = resolveDefaultMpvInstallPaths(
options.platform,
options.homeDir,
options.xdgConfigHome,
);
if (!installPaths.supported) {
return {
ok: false,
pluginInstallStatus: 'failed',
pluginInstallPathSummary: installPaths.mpvConfigDir,
message: 'Automatic mpv plugin install is not supported on this platform yet.',
};
}
const assets = resolvePackagedFirstRunPluginAssets({
dirname: options.dirname,
appPath: options.appPath,
resourcesPath: options.resourcesPath,
});
if (!assets) {
return {
ok: false,
pluginInstallStatus: 'failed',
pluginInstallPathSummary: installPaths.mpvConfigDir,
message: 'Packaged mpv plugin assets were not found.',
};
}
fs.mkdirSync(installPaths.scriptsDir, { recursive: true });
fs.mkdirSync(installPaths.scriptOptsDir, { recursive: true });
backupExistingPath(path.join(installPaths.scriptsDir, 'subminer.lua'));
backupExistingPath(path.join(installPaths.scriptsDir, 'subminer-loader.lua'));
backupExistingPath(installPaths.pluginDir);
backupExistingPath(installPaths.pluginConfigPath);
fs.cpSync(assets.pluginDirSource, installPaths.pluginDir, { recursive: true });
fs.copyFileSync(assets.pluginConfigSource, installPaths.pluginConfigPath);
rewriteInstalledPluginBinaryPath(installPaths.pluginConfigPath, options.binaryPath);
appDataDir?: string;
mpvExecutablePath?: string;
}): MpvConfigRootCandidate[] {
const platformPath = getPlatformPath(options.platform);
if (options.platform === 'win32') {
rewriteInstalledWindowsPluginConfig(installPaths.pluginConfigPath);
const roots: MpvConfigRootCandidate[] = [];
if (options.mpvExecutablePath?.trim()) {
roots.push({
root: platformPath.join(
platformPath.dirname(options.mpvExecutablePath.trim()),
'portable_config',
),
source: 'portable-config',
});
}
roots.push({
root: platformPath.join(
options.appDataDir?.trim() || platformPath.join(options.homeDir, 'AppData', 'Roaming'),
'mpv',
),
source: 'default-config',
});
return roots;
}
const xdgRoot = options.xdgConfigHome?.trim()
? platformPath.join(options.xdgConfigHome.trim(), 'mpv')
: null;
const homeRoot = platformPath.join(options.homeDir, '.config', 'mpv');
const roots: MpvConfigRootCandidate[] = [];
if (xdgRoot) {
roots.push({ root: xdgRoot, source: 'xdg-config' });
}
if (!xdgRoot || xdgRoot !== homeRoot) {
roots.push({ root: homeRoot, source: 'default-config' });
}
return roots;
}
export function detectInstalledFirstRunPluginCandidates(options: {
platform: NodeJS.Platform;
homeDir: string;
xdgConfigHome?: string;
appDataDir?: string;
mpvExecutablePath?: string;
existsSync?: (candidate: string) => boolean;
}): InstalledFirstRunPluginCandidate[] {
const platformPath = getPlatformPath(options.platform);
const existsSync = options.existsSync ?? fs.existsSync;
const roots = collectMpvConfigRootCandidates(options);
const candidates: InstalledFirstRunPluginCandidate[] = [];
const seen = new Set<string>();
const pushIfExists = (
candidate: InstalledFirstRunPluginCandidate,
verifyPath = candidate.path,
) => {
if (seen.has(candidate.path) || !existsSync(verifyPath)) return;
seen.add(candidate.path);
candidates.push(candidate);
};
for (const root of roots) {
const scriptsDir = platformPath.join(root.root, 'scripts');
const pluginDir = platformPath.join(scriptsDir, 'subminer');
pushIfExists({ path: pluginDir, kind: 'directory' });
pushIfExists({ path: platformPath.join(scriptsDir, 'subminer.lua'), kind: 'file' });
pushIfExists({ path: platformPath.join(scriptsDir, 'subminer-loader.lua'), kind: 'file' });
}
return candidates;
}
function parseInstalledPluginVersion(content: string): string | null {
const match = content.match(/\bversion\s*=\s*["']([^"']+)["']/);
return match?.[1] ?? null;
}
function readInstalledPluginVersion(options: {
pluginEntrypointPath: string;
platformPath: typeof path.posix | typeof path.win32;
existsSync: (candidate: string) => boolean;
readFileSync: (candidate: string, encoding: BufferEncoding) => string;
}): string | null {
const versionPath = options.platformPath.join(
options.platformPath.dirname(options.pluginEntrypointPath),
'version.lua',
);
if (!options.existsSync(versionPath)) {
return null;
}
try {
return parseInstalledPluginVersion(options.readFileSync(versionPath, 'utf8'));
} catch {
return null;
}
}
export function detectInstalledMpvPlugin(options: {
platform: NodeJS.Platform;
homeDir: string;
xdgConfigHome?: string;
appDataDir?: string;
mpvExecutablePath?: string;
existsSync?: (candidate: string) => boolean;
readFileSync?: (candidate: string, encoding: BufferEncoding) => string;
}): InstalledMpvPluginDetection {
const platformPath = getPlatformPath(options.platform);
const existsSync = options.existsSync ?? fs.existsSync;
const readFileSync =
options.readFileSync ?? ((candidate, encoding) => fs.readFileSync(candidate, encoding));
const roots = collectMpvConfigRootCandidates(options);
for (const root of roots) {
const scriptsDir = platformPath.join(root.root, 'scripts');
const directoryEntrypoint = platformPath.join(scriptsDir, 'subminer', 'main.lua');
if (existsSync(directoryEntrypoint)) {
const version = readInstalledPluginVersion({
pluginEntrypointPath: directoryEntrypoint,
platformPath,
existsSync,
readFileSync,
});
return {
installed: true,
path: directoryEntrypoint,
version,
source: root.source,
message: `SubMiner detected an installed mpv plugin at: ${directoryEntrypoint}`,
};
}
for (const legacyPath of [
platformPath.join(scriptsDir, 'subminer.lua'),
platformPath.join(scriptsDir, 'subminer-loader.lua'),
]) {
if (existsSync(legacyPath)) {
return {
installed: true,
path: legacyPath,
version: null,
source: root.source === 'portable-config' ? 'portable-config' : 'legacy-file',
message: `SubMiner detected an installed mpv plugin at: ${legacyPath}`,
};
}
}
}
return {
ok: true,
pluginInstallStatus: 'installed',
pluginInstallPathSummary: installPaths.mpvConfigDir,
message: `Installed mpv plugin to ${installPaths.mpvConfigDir}.`,
installed: false,
path: null,
version: null,
source: null,
message: null,
};
}
function errorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
export async function removeLegacyMpvPluginCandidates(options: {
candidates: InstalledFirstRunPluginCandidate[];
trashItem: (path: string) => Promise<void>;
}): Promise<LegacyMpvPluginRemovalResult> {
const removedPaths: string[] = [];
const failedPaths: Array<{ path: string; message: string }> = [];
const seen = new Set<string>();
for (const candidate of options.candidates) {
if (seen.has(candidate.path)) continue;
seen.add(candidate.path);
try {
await options.trashItem(candidate.path);
removedPaths.push(candidate.path);
} catch (error) {
failedPaths.push({ path: candidate.path, message: errorMessage(error) });
}
}
return {
ok: failedPaths.length === 0,
removedPaths,
failedPaths,
};
}