mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-03-20 03:16:46 -07:00
feat(stats): add v1 immersion stats dashboard (#19)
This commit is contained in:
@@ -145,19 +145,25 @@ test('resolveAniSkipMetadataForFile emits missing_mal_id when MAL search misses'
|
||||
});
|
||||
|
||||
test('buildSubminerScriptOpts includes aniskip payload fields', () => {
|
||||
const opts = buildSubminerScriptOpts('/tmp/SubMiner.AppImage', '/tmp/subminer.sock', {
|
||||
title: "Frieren: Beyond Journey's End",
|
||||
season: 1,
|
||||
episode: 5,
|
||||
source: 'guessit',
|
||||
malId: 1234,
|
||||
introStart: 30.5,
|
||||
introEnd: 62,
|
||||
lookupStatus: 'ready',
|
||||
});
|
||||
const opts = buildSubminerScriptOpts(
|
||||
'/tmp/SubMiner.AppImage',
|
||||
'/tmp/subminer.sock',
|
||||
{
|
||||
title: "Frieren: Beyond Journey's End",
|
||||
season: 1,
|
||||
episode: 5,
|
||||
source: 'guessit',
|
||||
malId: 1234,
|
||||
introStart: 30.5,
|
||||
introEnd: 62,
|
||||
lookupStatus: 'ready',
|
||||
},
|
||||
'debug',
|
||||
);
|
||||
const payloadMatch = opts.match(/subminer-aniskip_payload=([^,]+)/);
|
||||
assert.match(opts, /subminer-binary_path=\/tmp\/SubMiner\.AppImage/);
|
||||
assert.match(opts, /subminer-socket_path=\/tmp\/subminer\.sock/);
|
||||
assert.match(opts, /subminer-log_level=debug/);
|
||||
assert.match(opts, /subminer-aniskip_title=Frieren: Beyond Journey's End/);
|
||||
assert.match(opts, /subminer-aniskip_season=1/);
|
||||
assert.match(opts, /subminer-aniskip_episode=5/);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import path from 'node:path';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import type { LogLevel } from './types.js';
|
||||
import { commandExists } from './util.js';
|
||||
|
||||
export type AniSkipLookupStatus =
|
||||
@@ -551,11 +552,15 @@ export function buildSubminerScriptOpts(
|
||||
appPath: string,
|
||||
socketPath: string,
|
||||
aniSkipMetadata: AniSkipMetadata | null,
|
||||
logLevel: LogLevel = 'info',
|
||||
): string {
|
||||
const parts = [
|
||||
`subminer-binary_path=${sanitizeScriptOptValue(appPath)}`,
|
||||
`subminer-socket_path=${sanitizeScriptOptValue(socketPath)}`,
|
||||
];
|
||||
if (logLevel !== 'info') {
|
||||
parts.push(`subminer-log_level=${sanitizeScriptOptValue(logLevel)}`);
|
||||
}
|
||||
if (aniSkipMetadata && aniSkipMetadata.title) {
|
||||
parts.push(`subminer-aniskip_title=${sanitizeScriptOptValue(aniSkipMetadata.title)}`);
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import { runConfigCommand } from './config-command.js';
|
||||
import { runDictionaryCommand } from './dictionary-command.js';
|
||||
import { runDoctorCommand } from './doctor-command.js';
|
||||
import { runMpvPreAppCommand } from './mpv-command.js';
|
||||
import { runStatsCommand } from './stats-command.js';
|
||||
|
||||
class ExitSignal extends Error {
|
||||
code: number;
|
||||
@@ -47,6 +48,64 @@ function createContext(overrides: Partial<LauncherCommandContext> = {}): Launche
|
||||
};
|
||||
}
|
||||
|
||||
type StatsTestArgOverrides = {
|
||||
stats?: boolean;
|
||||
statsBackground?: boolean;
|
||||
statsCleanup?: boolean;
|
||||
statsCleanupVocab?: boolean;
|
||||
statsCleanupLifetime?: boolean;
|
||||
statsStop?: boolean;
|
||||
logLevel?: LauncherCommandContext['args']['logLevel'];
|
||||
};
|
||||
|
||||
function createStatsTestHarness(overrides: StatsTestArgOverrides = {}) {
|
||||
const context = createContext();
|
||||
const forwarded: string[][] = [];
|
||||
const removedPaths: string[] = [];
|
||||
const createTempDir = (_prefix: string) => {
|
||||
const created = `/tmp/subminer-stats-test`;
|
||||
return created;
|
||||
};
|
||||
const joinPath = (...parts: string[]) => parts.join('/');
|
||||
const removeDir = (targetPath: string) => {
|
||||
removedPaths.push(targetPath);
|
||||
};
|
||||
const runAppCommandAttachedStub = async (
|
||||
_appPath: string,
|
||||
appArgs: string[],
|
||||
_logLevel: LauncherCommandContext['args']['logLevel'],
|
||||
_label: string,
|
||||
) => {
|
||||
forwarded.push(appArgs);
|
||||
return 0;
|
||||
};
|
||||
const waitForStatsResponseStub = async () => ({ ok: true, url: 'http://127.0.0.1:5175' });
|
||||
|
||||
context.args = {
|
||||
...context.args,
|
||||
stats: true,
|
||||
...overrides,
|
||||
};
|
||||
|
||||
return {
|
||||
context,
|
||||
forwarded,
|
||||
removedPaths,
|
||||
createTempDir,
|
||||
joinPath,
|
||||
removeDir,
|
||||
runAppCommandAttachedStub,
|
||||
waitForStatsResponseStub,
|
||||
commandDeps: {
|
||||
createTempDir,
|
||||
joinPath,
|
||||
runAppCommandAttached: runAppCommandAttachedStub,
|
||||
waitForStatsResponse: waitForStatsResponseStub,
|
||||
removeDir,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test('config command writes newline-terminated path via process adapter', () => {
|
||||
const writes: string[] = [];
|
||||
const context = createContext();
|
||||
@@ -76,11 +135,37 @@ test('doctor command exits non-zero for missing hard dependencies', () => {
|
||||
commandExists: () => false,
|
||||
configExists: () => true,
|
||||
resolveMainConfigPath: () => '/tmp/SubMiner/config.jsonc',
|
||||
runAppCommandWithInherit: () => {
|
||||
throw new Error('unexpected app handoff');
|
||||
},
|
||||
}),
|
||||
(error: unknown) => error instanceof ExitSignal && error.code === 1,
|
||||
);
|
||||
});
|
||||
|
||||
test('doctor command forwards refresh-known-words to app binary', () => {
|
||||
const context = createContext();
|
||||
context.args.doctor = true;
|
||||
context.args.doctorRefreshKnownWords = true;
|
||||
const forwarded: string[][] = [];
|
||||
|
||||
assert.throws(
|
||||
() =>
|
||||
runDoctorCommand(context, {
|
||||
commandExists: () => false,
|
||||
configExists: () => true,
|
||||
resolveMainConfigPath: () => '/tmp/SubMiner/config.jsonc',
|
||||
runAppCommandWithInherit: (_appPath, appArgs) => {
|
||||
forwarded.push(appArgs);
|
||||
throw new ExitSignal(0);
|
||||
},
|
||||
}),
|
||||
(error: unknown) => error instanceof ExitSignal && error.code === 0,
|
||||
);
|
||||
|
||||
assert.deepEqual(forwarded, [['--refresh-known-words']]);
|
||||
});
|
||||
|
||||
test('mpv pre-app command exits non-zero when socket is not ready', async () => {
|
||||
const context = createContext();
|
||||
context.args.mpvStatus = true;
|
||||
@@ -128,3 +213,309 @@ test('dictionary command throws if app handoff unexpectedly returns', () => {
|
||||
/unexpectedly returned/,
|
||||
);
|
||||
});
|
||||
|
||||
test('stats command launches attached app command with response path', async () => {
|
||||
const harness = createStatsTestHarness({ stats: true, logLevel: 'debug' });
|
||||
const handled = await runStatsCommand(harness.context, harness.commandDeps);
|
||||
|
||||
assert.equal(handled, true);
|
||||
assert.deepEqual(harness.forwarded, [
|
||||
[
|
||||
'--stats',
|
||||
'--stats-response-path',
|
||||
'/tmp/subminer-stats-test/response.json',
|
||||
'--log-level',
|
||||
'debug',
|
||||
],
|
||||
]);
|
||||
assert.equal(harness.removedPaths.length, 1);
|
||||
});
|
||||
|
||||
test('stats background command launches attached daemon control command with response path', async () => {
|
||||
const harness = createStatsTestHarness({ stats: true, statsBackground: true });
|
||||
const handled = await runStatsCommand(harness.context, harness.commandDeps);
|
||||
|
||||
assert.equal(handled, true);
|
||||
assert.deepEqual(harness.forwarded, [
|
||||
[
|
||||
'--stats-daemon-start',
|
||||
'--stats-response-path',
|
||||
'/tmp/subminer-stats-test/response.json',
|
||||
],
|
||||
]);
|
||||
assert.equal(harness.removedPaths.length, 1);
|
||||
});
|
||||
|
||||
test('stats command waits for attached app exit after startup response', async () => {
|
||||
const harness = createStatsTestHarness({ stats: true });
|
||||
const started = new Promise<number>((resolve) => setTimeout(() => resolve(0), 20));
|
||||
|
||||
const statsCommand = runStatsCommand(harness.context, {
|
||||
...harness.commandDeps,
|
||||
runAppCommandAttached: async (...args) => {
|
||||
await harness.runAppCommandAttachedStub(...args);
|
||||
return started;
|
||||
},
|
||||
});
|
||||
const result = await Promise.race([
|
||||
statsCommand.then(() => 'resolved'),
|
||||
new Promise<'timeout'>((resolve) => setTimeout(() => resolve('timeout'), 5)),
|
||||
]);
|
||||
|
||||
assert.equal(result, 'timeout');
|
||||
|
||||
const final = await statsCommand;
|
||||
assert.equal(final, true);
|
||||
assert.deepEqual(harness.forwarded, [
|
||||
[
|
||||
'--stats',
|
||||
'--stats-response-path',
|
||||
'/tmp/subminer-stats-test/response.json',
|
||||
],
|
||||
]);
|
||||
assert.equal(harness.removedPaths.length, 1);
|
||||
});
|
||||
|
||||
test('stats command throws when attached app exits non-zero after startup response', async () => {
|
||||
const harness = createStatsTestHarness({ stats: true });
|
||||
|
||||
await assert.rejects(async () => {
|
||||
await runStatsCommand(harness.context, {
|
||||
...harness.commandDeps,
|
||||
runAppCommandAttached: async (...args) => {
|
||||
await harness.runAppCommandAttachedStub(...args);
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
return 3;
|
||||
},
|
||||
});
|
||||
}, /Stats app exited with status 3\./);
|
||||
|
||||
assert.equal(harness.removedPaths.length, 1);
|
||||
});
|
||||
|
||||
test('stats cleanup command forwards cleanup vocab flags to the app', async () => {
|
||||
const harness = createStatsTestHarness({
|
||||
stats: true,
|
||||
statsCleanup: true,
|
||||
statsCleanupVocab: true,
|
||||
});
|
||||
const handled = await runStatsCommand(harness.context, {
|
||||
...harness.commandDeps,
|
||||
waitForStatsResponse: async () => ({ ok: true }),
|
||||
});
|
||||
|
||||
assert.equal(handled, true);
|
||||
assert.deepEqual(harness.forwarded, [
|
||||
[
|
||||
'--stats',
|
||||
'--stats-response-path',
|
||||
'/tmp/subminer-stats-test/response.json',
|
||||
'--stats-cleanup',
|
||||
'--stats-cleanup-vocab',
|
||||
],
|
||||
]);
|
||||
assert.equal(harness.removedPaths.length, 1);
|
||||
});
|
||||
|
||||
test('stats stop command forwards stop flag to the app', async () => {
|
||||
const harness = createStatsTestHarness({ stats: true, statsStop: true });
|
||||
|
||||
const handled = await runStatsCommand(harness.context, {
|
||||
...harness.commandDeps,
|
||||
waitForStatsResponse: async () => ({ ok: true }),
|
||||
});
|
||||
|
||||
assert.equal(handled, true);
|
||||
assert.deepEqual(harness.forwarded, [
|
||||
[
|
||||
'--stats-daemon-stop',
|
||||
'--stats-response-path',
|
||||
'/tmp/subminer-stats-test/response.json',
|
||||
],
|
||||
]);
|
||||
assert.equal(harness.removedPaths.length, 1);
|
||||
});
|
||||
|
||||
test('stats stop command exits on process exit without waiting for startup response', async () => {
|
||||
const harness = createStatsTestHarness({ stats: true, statsStop: true });
|
||||
let waitedForResponse = false;
|
||||
|
||||
const handled = await runStatsCommand(harness.context, {
|
||||
...harness.commandDeps,
|
||||
runAppCommandAttached: async (...args) => {
|
||||
await harness.runAppCommandAttachedStub(...args);
|
||||
return 0;
|
||||
},
|
||||
waitForStatsResponse: async () => {
|
||||
waitedForResponse = true;
|
||||
return { ok: true };
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(handled, true);
|
||||
assert.equal(waitedForResponse, false);
|
||||
assert.equal(harness.removedPaths.length, 1);
|
||||
});
|
||||
|
||||
test('stats cleanup command forwards lifetime rebuild flag to the app', async () => {
|
||||
const harness = createStatsTestHarness({
|
||||
stats: true,
|
||||
statsCleanup: true,
|
||||
statsCleanupLifetime: true,
|
||||
});
|
||||
const handled = await runStatsCommand(harness.context, {
|
||||
...harness.commandDeps,
|
||||
waitForStatsResponse: async () => ({ ok: true }),
|
||||
});
|
||||
|
||||
assert.equal(handled, true);
|
||||
assert.deepEqual(harness.forwarded, [
|
||||
[
|
||||
'--stats',
|
||||
'--stats-response-path',
|
||||
'/tmp/subminer-stats-test/response.json',
|
||||
'--stats-cleanup',
|
||||
'--stats-cleanup-lifetime',
|
||||
],
|
||||
]);
|
||||
assert.equal(harness.removedPaths.length, 1);
|
||||
});
|
||||
|
||||
test('stats command throws when stats response reports an error', async () => {
|
||||
const harness = createStatsTestHarness({ stats: true });
|
||||
|
||||
await assert.rejects(async () => {
|
||||
await runStatsCommand(harness.context, {
|
||||
...harness.commandDeps,
|
||||
runAppCommandAttached: async (...args) => {
|
||||
await harness.runAppCommandAttachedStub(...args);
|
||||
return 0;
|
||||
},
|
||||
waitForStatsResponse: async () => ({
|
||||
ok: false,
|
||||
error: 'Immersion tracking is disabled in config.',
|
||||
}),
|
||||
});
|
||||
}, /Immersion tracking is disabled in config\./);
|
||||
|
||||
assert.equal(harness.removedPaths.length, 1);
|
||||
});
|
||||
|
||||
test('stats cleanup command fails if attached app exits before startup response', async () => {
|
||||
const harness = createStatsTestHarness({
|
||||
stats: true,
|
||||
statsCleanup: true,
|
||||
statsCleanupVocab: true,
|
||||
});
|
||||
|
||||
await assert.rejects(async () => {
|
||||
await runStatsCommand(harness.context, {
|
||||
...harness.commandDeps,
|
||||
runAppCommandAttached: async (...args) => {
|
||||
await harness.runAppCommandAttachedStub(...args);
|
||||
return 2;
|
||||
},
|
||||
waitForStatsResponse: async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 25));
|
||||
return { ok: true, url: 'http://127.0.0.1:5175' };
|
||||
},
|
||||
});
|
||||
}, /Stats app exited before startup response \(status 2\)\./);
|
||||
|
||||
assert.equal(harness.removedPaths.length, 1);
|
||||
});
|
||||
|
||||
test('stats command aborts pending response wait when app exits before startup response', async () => {
|
||||
const harness = createStatsTestHarness({ stats: true });
|
||||
let aborted = false;
|
||||
|
||||
await assert.rejects(async () => {
|
||||
await runStatsCommand(harness.context, {
|
||||
...harness.commandDeps,
|
||||
runAppCommandAttached: async (...args) => {
|
||||
await harness.runAppCommandAttachedStub(...args);
|
||||
return 2;
|
||||
},
|
||||
waitForStatsResponse: async (_responsePath, signal) =>
|
||||
await new Promise((resolve) => {
|
||||
signal?.addEventListener(
|
||||
'abort',
|
||||
() => {
|
||||
aborted = true;
|
||||
resolve({ ok: false, error: 'aborted' });
|
||||
},
|
||||
{ once: true },
|
||||
);
|
||||
}),
|
||||
});
|
||||
}, /Stats app exited before startup response \(status 2\)\./);
|
||||
|
||||
assert.equal(aborted, true);
|
||||
assert.equal(harness.removedPaths.length, 1);
|
||||
});
|
||||
|
||||
test('stats command aborts pending response wait when attached app fails to spawn', async () => {
|
||||
const harness = createStatsTestHarness({ stats: true });
|
||||
const spawnError = new Error('spawn failed');
|
||||
let aborted = false;
|
||||
|
||||
await assert.rejects(
|
||||
async () => {
|
||||
await runStatsCommand(harness.context, {
|
||||
...harness.commandDeps,
|
||||
runAppCommandAttached: async (...args) => {
|
||||
await harness.runAppCommandAttachedStub(...args);
|
||||
throw spawnError;
|
||||
},
|
||||
waitForStatsResponse: async (_responsePath, signal) =>
|
||||
await new Promise((resolve) => {
|
||||
signal?.addEventListener(
|
||||
'abort',
|
||||
() => {
|
||||
aborted = true;
|
||||
resolve({ ok: false, error: 'aborted' });
|
||||
},
|
||||
{ once: true },
|
||||
);
|
||||
}),
|
||||
});
|
||||
},
|
||||
(error: unknown) => error === spawnError,
|
||||
);
|
||||
|
||||
assert.equal(aborted, true);
|
||||
assert.equal(harness.removedPaths.length, 1);
|
||||
});
|
||||
|
||||
test('stats cleanup command aborts pending response wait when app exits before startup response', async () => {
|
||||
const harness = createStatsTestHarness({
|
||||
stats: true,
|
||||
statsCleanup: true,
|
||||
statsCleanupVocab: true,
|
||||
});
|
||||
let aborted = false;
|
||||
|
||||
await assert.rejects(async () => {
|
||||
await runStatsCommand(harness.context, {
|
||||
...harness.commandDeps,
|
||||
runAppCommandAttached: async (...args) => {
|
||||
await harness.runAppCommandAttachedStub(...args);
|
||||
return 2;
|
||||
},
|
||||
waitForStatsResponse: async (_responsePath, signal) =>
|
||||
await new Promise((resolve) => {
|
||||
signal?.addEventListener(
|
||||
'abort',
|
||||
() => {
|
||||
aborted = true;
|
||||
resolve({ ok: false, error: 'aborted' });
|
||||
},
|
||||
{ once: true },
|
||||
);
|
||||
}),
|
||||
});
|
||||
}, /Stats app exited before startup response \(status 2\)\./);
|
||||
|
||||
assert.equal(aborted, true);
|
||||
assert.equal(harness.removedPaths.length, 1);
|
||||
});
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import fs from 'node:fs';
|
||||
import { log } from '../log.js';
|
||||
import { runAppCommandWithInherit } from '../mpv.js';
|
||||
import { commandExists } from '../util.js';
|
||||
import { resolveMainConfigPath } from '../config-path.js';
|
||||
import type { LauncherCommandContext } from './context.js';
|
||||
@@ -8,12 +9,14 @@ interface DoctorCommandDeps {
|
||||
commandExists(command: string): boolean;
|
||||
configExists(path: string): boolean;
|
||||
resolveMainConfigPath(): string;
|
||||
runAppCommandWithInherit(appPath: string, appArgs: string[]): never;
|
||||
}
|
||||
|
||||
const defaultDeps: DoctorCommandDeps = {
|
||||
commandExists,
|
||||
configExists: fs.existsSync,
|
||||
resolveMainConfigPath,
|
||||
runAppCommandWithInherit,
|
||||
};
|
||||
|
||||
export function runDoctorCommand(
|
||||
@@ -72,14 +75,21 @@ export function runDoctorCommand(
|
||||
},
|
||||
];
|
||||
|
||||
const hasHardFailure = checks.some((entry) =>
|
||||
entry.label === 'app binary' || entry.label === 'mpv' ? !entry.ok : false,
|
||||
);
|
||||
|
||||
for (const check of checks) {
|
||||
log(check.ok ? 'info' : 'warn', args.logLevel, `[doctor] ${check.label}: ${check.detail}`);
|
||||
}
|
||||
|
||||
if (args.doctorRefreshKnownWords) {
|
||||
if (!appPath) {
|
||||
processAdapter.exit(1);
|
||||
return true;
|
||||
}
|
||||
deps.runAppCommandWithInherit(appPath, ['--refresh-known-words']);
|
||||
}
|
||||
|
||||
const hasHardFailure = checks.some((entry) =>
|
||||
entry.label === 'app binary' || entry.label === 'mpv' ? !entry.ok : false,
|
||||
);
|
||||
processAdapter.exit(hasHardFailure ? 1 : 0);
|
||||
return true;
|
||||
}
|
||||
|
||||
180
launcher/commands/stats-command.ts
Normal file
180
launcher/commands/stats-command.ts
Normal file
@@ -0,0 +1,180 @@
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { runAppCommandAttached } from '../mpv.js';
|
||||
import { sleep } from '../util.js';
|
||||
import type { LauncherCommandContext } from './context.js';
|
||||
|
||||
type StatsCommandResponse = {
|
||||
ok: boolean;
|
||||
url?: string;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
type StatsCommandDeps = {
|
||||
createTempDir: (prefix: string) => string;
|
||||
joinPath: (...parts: string[]) => string;
|
||||
runAppCommandAttached: (
|
||||
appPath: string,
|
||||
appArgs: string[],
|
||||
logLevel: LauncherCommandContext['args']['logLevel'],
|
||||
label: string,
|
||||
) => Promise<number>;
|
||||
waitForStatsResponse: (
|
||||
responsePath: string,
|
||||
signal?: AbortSignal,
|
||||
) => Promise<StatsCommandResponse>;
|
||||
removeDir: (targetPath: string) => void;
|
||||
};
|
||||
|
||||
const STATS_STARTUP_RESPONSE_TIMEOUT_MS = 12_000;
|
||||
|
||||
type StatsResponseWait = {
|
||||
controller: AbortController;
|
||||
promise: Promise<{ kind: 'response'; response: StatsCommandResponse }>;
|
||||
};
|
||||
|
||||
type StatsStartupResult =
|
||||
| { kind: 'response'; response: StatsCommandResponse }
|
||||
| { kind: 'exit'; status: number }
|
||||
| { kind: 'spawn-error'; error: unknown };
|
||||
|
||||
const defaultDeps: StatsCommandDeps = {
|
||||
createTempDir: (prefix) => fs.mkdtempSync(path.join(os.tmpdir(), prefix)),
|
||||
joinPath: (...parts) => path.join(...parts),
|
||||
runAppCommandAttached: (appPath, appArgs, logLevel, label) =>
|
||||
runAppCommandAttached(appPath, appArgs, logLevel, label),
|
||||
waitForStatsResponse: async (responsePath, signal) => {
|
||||
const deadline = Date.now() + STATS_STARTUP_RESPONSE_TIMEOUT_MS;
|
||||
while (Date.now() < deadline) {
|
||||
if (signal?.aborted) {
|
||||
return {
|
||||
ok: false,
|
||||
error: 'Cancelled waiting for stats dashboard startup response.',
|
||||
};
|
||||
}
|
||||
try {
|
||||
if (fs.existsSync(responsePath)) {
|
||||
return JSON.parse(fs.readFileSync(responsePath, 'utf8')) as StatsCommandResponse;
|
||||
}
|
||||
} catch {
|
||||
// retry until timeout
|
||||
}
|
||||
await sleep(100);
|
||||
}
|
||||
return {
|
||||
ok: false,
|
||||
error: 'Timed out waiting for stats dashboard startup response.',
|
||||
};
|
||||
},
|
||||
removeDir: (targetPath) => {
|
||||
fs.rmSync(targetPath, { recursive: true, force: true });
|
||||
},
|
||||
};
|
||||
|
||||
async function performStartupHandshake(
|
||||
createResponseWait: () => StatsResponseWait,
|
||||
attachedExitPromise: Promise<number>,
|
||||
): Promise<boolean> {
|
||||
const responseWait = createResponseWait();
|
||||
const startupResult = await Promise.race<StatsStartupResult>([
|
||||
responseWait.promise,
|
||||
attachedExitPromise.then(
|
||||
(status) => ({ kind: 'exit' as const, status }),
|
||||
(error) => ({ kind: 'spawn-error' as const, error }),
|
||||
),
|
||||
]);
|
||||
|
||||
if (startupResult.kind === 'spawn-error') {
|
||||
responseWait.controller.abort();
|
||||
throw startupResult.error;
|
||||
}
|
||||
|
||||
if (startupResult.kind === 'exit') {
|
||||
if (startupResult.status !== 0) {
|
||||
responseWait.controller.abort();
|
||||
throw new Error(`Stats app exited before startup response (status ${startupResult.status}).`);
|
||||
}
|
||||
|
||||
const response = await responseWait.promise.then((result) => result.response);
|
||||
if (!response.ok) {
|
||||
throw new Error(response.error || 'Stats dashboard failed to start.');
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!startupResult.response.ok) {
|
||||
throw new Error(startupResult.response.error || 'Stats dashboard failed to start.');
|
||||
}
|
||||
|
||||
const exitStatus = await attachedExitPromise;
|
||||
if (exitStatus !== 0) {
|
||||
throw new Error(`Stats app exited with status ${exitStatus}.`);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
export async function runStatsCommand(
|
||||
context: LauncherCommandContext,
|
||||
deps: Partial<StatsCommandDeps> = {},
|
||||
): Promise<boolean> {
|
||||
const resolvedDeps: StatsCommandDeps = { ...defaultDeps, ...deps };
|
||||
const { args, appPath } = context;
|
||||
if (!args.stats || !appPath) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const tempDir = resolvedDeps.createTempDir('subminer-stats-');
|
||||
const responsePath = resolvedDeps.joinPath(tempDir, 'response.json');
|
||||
|
||||
const createResponseWait = () => {
|
||||
const controller = new AbortController();
|
||||
return {
|
||||
controller,
|
||||
promise: resolvedDeps
|
||||
.waitForStatsResponse(responsePath, controller.signal)
|
||||
.then((response) => ({ kind: 'response' as const, response })),
|
||||
};
|
||||
};
|
||||
|
||||
try {
|
||||
const forwarded = args.statsCleanup
|
||||
? ['--stats', '--stats-response-path', responsePath]
|
||||
: args.statsStop
|
||||
? ['--stats-daemon-stop', '--stats-response-path', responsePath]
|
||||
: args.statsBackground
|
||||
? ['--stats-daemon-start', '--stats-response-path', responsePath]
|
||||
: ['--stats', '--stats-response-path', responsePath];
|
||||
if (args.statsCleanup) {
|
||||
forwarded.push('--stats-cleanup');
|
||||
}
|
||||
if (args.statsCleanupVocab) {
|
||||
forwarded.push('--stats-cleanup-vocab');
|
||||
}
|
||||
if (args.statsCleanupLifetime) {
|
||||
forwarded.push('--stats-cleanup-lifetime');
|
||||
}
|
||||
if (args.logLevel !== 'info') {
|
||||
forwarded.push('--log-level', args.logLevel);
|
||||
}
|
||||
const attachedExitPromise = resolvedDeps.runAppCommandAttached(
|
||||
appPath,
|
||||
forwarded,
|
||||
args.logLevel,
|
||||
'stats',
|
||||
);
|
||||
|
||||
if (args.statsStop) {
|
||||
const status = await attachedExitPromise;
|
||||
if (status !== 0) {
|
||||
throw new Error(`Stats app exited with status ${status}.`);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
return await performStartupHandshake(createResponseWait, attachedExitPromise);
|
||||
} finally {
|
||||
resolvedDeps.removeDir(tempDir);
|
||||
}
|
||||
}
|
||||
@@ -122,12 +122,20 @@ export function createDefaultArgs(launcherConfig: LauncherYoutubeSubgenConfig):
|
||||
jellyfinPlay: false,
|
||||
jellyfinDiscovery: false,
|
||||
dictionary: false,
|
||||
stats: false,
|
||||
statsBackground: false,
|
||||
statsStop: false,
|
||||
statsCleanup: false,
|
||||
statsCleanupVocab: false,
|
||||
statsCleanupLifetime: false,
|
||||
doctor: false,
|
||||
doctorRefreshKnownWords: false,
|
||||
configPath: false,
|
||||
configShow: false,
|
||||
mpvIdle: false,
|
||||
mpvSocket: false,
|
||||
mpvStatus: false,
|
||||
mpvArgs: '',
|
||||
appPassthrough: false,
|
||||
appArgs: [],
|
||||
jellyfinServer: '',
|
||||
@@ -183,15 +191,23 @@ export function applyRootOptionsToArgs(
|
||||
if (options.rofi === true) parsed.useRofi = true;
|
||||
if (options.startOverlay === true) parsed.autoStartOverlay = true;
|
||||
if (options.texthooker === false) parsed.useTexthooker = false;
|
||||
if (typeof options.args === 'string') parsed.mpvArgs = options.args;
|
||||
if (typeof rootTarget === 'string' && rootTarget) ensureTarget(rootTarget, parsed);
|
||||
}
|
||||
|
||||
export function applyInvocationsToArgs(parsed: Args, invocations: CliInvocations): void {
|
||||
if (invocations.dictionaryTriggered) parsed.dictionary = true;
|
||||
if (invocations.statsTriggered) parsed.stats = true;
|
||||
if (invocations.statsBackground) parsed.statsBackground = true;
|
||||
if (invocations.statsStop) parsed.statsStop = true;
|
||||
if (invocations.statsCleanup) parsed.statsCleanup = true;
|
||||
if (invocations.statsCleanupVocab) parsed.statsCleanupVocab = true;
|
||||
if (invocations.statsCleanupLifetime) parsed.statsCleanupLifetime = true;
|
||||
if (invocations.dictionaryTarget) {
|
||||
parsed.dictionaryTarget = parseDictionaryTarget(invocations.dictionaryTarget);
|
||||
}
|
||||
if (invocations.doctorTriggered) parsed.doctor = true;
|
||||
if (invocations.doctorRefreshKnownWords) parsed.doctorRefreshKnownWords = true;
|
||||
if (invocations.texthookerTriggered) parsed.texthookerOnly = true;
|
||||
|
||||
if (invocations.jellyfinInvocation) {
|
||||
@@ -256,6 +272,9 @@ export function applyInvocationsToArgs(parsed: Args, invocations: CliInvocations
|
||||
if (invocations.dictionaryLogLevel) {
|
||||
parsed.logLevel = parseLogLevel(invocations.dictionaryLogLevel);
|
||||
}
|
||||
if (invocations.statsLogLevel) {
|
||||
parsed.logLevel = parseLogLevel(invocations.statsLogLevel);
|
||||
}
|
||||
|
||||
if (invocations.doctorLogLevel) parsed.logLevel = parseLogLevel(invocations.doctorLogLevel);
|
||||
if (invocations.texthookerLogLevel)
|
||||
|
||||
@@ -40,8 +40,16 @@ export interface CliInvocations {
|
||||
dictionaryTriggered: boolean;
|
||||
dictionaryTarget: string | null;
|
||||
dictionaryLogLevel: string | null;
|
||||
statsTriggered: boolean;
|
||||
statsBackground: boolean;
|
||||
statsStop: boolean;
|
||||
statsCleanup: boolean;
|
||||
statsCleanupVocab: boolean;
|
||||
statsCleanupLifetime: boolean;
|
||||
statsLogLevel: string | null;
|
||||
doctorTriggered: boolean;
|
||||
doctorLogLevel: string | null;
|
||||
doctorRefreshKnownWords: boolean;
|
||||
texthookerTriggered: boolean;
|
||||
texthookerLogLevel: string | null;
|
||||
}
|
||||
@@ -50,6 +58,7 @@ function applyRootOptions(program: Command): void {
|
||||
program
|
||||
.option('-b, --backend <backend>', 'Display backend')
|
||||
.option('-d, --directory <dir>', 'Directory to browse')
|
||||
.option('-a, --args <args>', 'Pass arguments to MPV')
|
||||
.option('-r, --recursive', 'Search directories recursively')
|
||||
.option('-p, --profile <profile>', 'MPV profile')
|
||||
.option('--start', 'Explicitly start overlay')
|
||||
@@ -87,6 +96,7 @@ function getTopLevelCommand(argv: string[]): { name: string; index: number } | n
|
||||
'mpv',
|
||||
'dictionary',
|
||||
'dict',
|
||||
'stats',
|
||||
'texthooker',
|
||||
'app',
|
||||
'bin',
|
||||
@@ -95,6 +105,8 @@ function getTopLevelCommand(argv: string[]): { name: string; index: number } | n
|
||||
const optionsWithValue = new Set([
|
||||
'-b',
|
||||
'--backend',
|
||||
'-a',
|
||||
'--args',
|
||||
'-d',
|
||||
'--directory',
|
||||
'-p',
|
||||
@@ -137,7 +149,15 @@ export function parseCliPrograms(
|
||||
let dictionaryTriggered = false;
|
||||
let dictionaryTarget: string | null = null;
|
||||
let dictionaryLogLevel: string | null = null;
|
||||
let statsTriggered = false;
|
||||
let statsBackground = false;
|
||||
let statsStop = false;
|
||||
let statsCleanup = false;
|
||||
let statsCleanupVocab = false;
|
||||
let statsCleanupLifetime = false;
|
||||
let statsLogLevel: string | null = null;
|
||||
let doctorLogLevel: string | null = null;
|
||||
let doctorRefreshKnownWords = false;
|
||||
let texthookerLogLevel: string | null = null;
|
||||
let doctorTriggered = false;
|
||||
let texthookerTriggered = false;
|
||||
@@ -241,13 +261,63 @@ export function parseCliPrograms(
|
||||
dictionaryLogLevel = typeof options.logLevel === 'string' ? options.logLevel : null;
|
||||
});
|
||||
|
||||
commandProgram
|
||||
.command('stats')
|
||||
.description('Launch the local immersion stats dashboard')
|
||||
.argument('[action]', 'cleanup|rebuild|backfill')
|
||||
.option('-b, --background', 'Start the stats server in the background')
|
||||
.option('-s, --stop', 'Stop the background stats server')
|
||||
.option('-v, --vocab', 'Clean vocabulary rows in the stats database')
|
||||
.option('-l, --lifetime', 'Rebuild lifetime summary rows from retained data')
|
||||
.option('--log-level <level>', 'Log level')
|
||||
.action((action: string | undefined, options: Record<string, unknown>) => {
|
||||
statsTriggered = true;
|
||||
const normalizedAction = (action || '').toLowerCase();
|
||||
statsBackground = options.background === true;
|
||||
statsStop = options.stop === true;
|
||||
if (statsBackground && statsStop) {
|
||||
throw new Error('Stats background and stop flags cannot be combined.');
|
||||
}
|
||||
if (
|
||||
normalizedAction &&
|
||||
normalizedAction !== 'cleanup' &&
|
||||
normalizedAction !== 'rebuild' &&
|
||||
normalizedAction !== 'backfill'
|
||||
) {
|
||||
throw new Error(
|
||||
'Invalid stats action. Valid values are cleanup, rebuild, or backfill.',
|
||||
);
|
||||
}
|
||||
if (normalizedAction && (statsBackground || statsStop)) {
|
||||
throw new Error('Stats background and stop flags cannot be combined with stats actions.');
|
||||
}
|
||||
if (
|
||||
normalizedAction !== 'cleanup' &&
|
||||
(options.vocab === true || options.lifetime === true)
|
||||
) {
|
||||
throw new Error('Stats --vocab and --lifetime flags require the cleanup action.');
|
||||
}
|
||||
if (normalizedAction === 'cleanup') {
|
||||
statsCleanup = true;
|
||||
statsCleanupLifetime = options.lifetime === true;
|
||||
statsCleanupVocab = statsCleanupLifetime ? false : options.vocab !== false;
|
||||
} else if (normalizedAction === 'rebuild' || normalizedAction === 'backfill') {
|
||||
statsCleanup = true;
|
||||
statsCleanupLifetime = true;
|
||||
statsCleanupVocab = false;
|
||||
}
|
||||
statsLogLevel = typeof options.logLevel === 'string' ? options.logLevel : null;
|
||||
});
|
||||
|
||||
commandProgram
|
||||
.command('doctor')
|
||||
.description('Run dependency and environment checks')
|
||||
.option('--refresh-known-words', 'Refresh known words cache')
|
||||
.option('--log-level <level>', 'Log level')
|
||||
.action((options: Record<string, unknown>) => {
|
||||
doctorTriggered = true;
|
||||
doctorLogLevel = typeof options.logLevel === 'string' ? options.logLevel : null;
|
||||
doctorRefreshKnownWords = options.refreshKnownWords === true;
|
||||
});
|
||||
|
||||
commandProgram
|
||||
@@ -319,8 +389,16 @@ export function parseCliPrograms(
|
||||
dictionaryTriggered,
|
||||
dictionaryTarget,
|
||||
dictionaryLogLevel,
|
||||
statsTriggered,
|
||||
statsBackground,
|
||||
statsStop,
|
||||
statsCleanup,
|
||||
statsCleanupVocab,
|
||||
statsCleanupLifetime,
|
||||
statsLogLevel,
|
||||
doctorTriggered,
|
||||
doctorLogLevel,
|
||||
doctorRefreshKnownWords,
|
||||
texthookerTriggered,
|
||||
texthookerLogLevel,
|
||||
},
|
||||
|
||||
@@ -26,7 +26,9 @@ type RunResult = {
|
||||
};
|
||||
|
||||
function withTempDir<T>(fn: (dir: string) => T): T {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-launcher-test-'));
|
||||
// Keep paths short on macOS/Linux: Unix domain sockets have small path-length limits.
|
||||
const tmpBase = process.platform === 'win32' ? os.tmpdir() : '/tmp';
|
||||
const dir = fs.mkdtempSync(path.join(tmpBase, 'subminer-launcher-test-'));
|
||||
try {
|
||||
return fn(dir);
|
||||
} finally {
|
||||
@@ -176,6 +178,33 @@ test('doctor reports checks and exits non-zero without hard dependencies', () =>
|
||||
});
|
||||
});
|
||||
|
||||
test('doctor refresh-known-words forwards app refresh command without requiring mpv', () => {
|
||||
withTempDir((root) => {
|
||||
const homeDir = path.join(root, 'home');
|
||||
const xdgConfigHome = path.join(root, 'xdg');
|
||||
const appPath = path.join(root, 'fake-subminer.sh');
|
||||
const capturePath = path.join(root, 'captured-args.txt');
|
||||
fs.writeFileSync(
|
||||
appPath,
|
||||
'#!/bin/sh\nif [ -n "$SUBMINER_TEST_CAPTURE" ]; then printf "%s\\n" "$@" > "$SUBMINER_TEST_CAPTURE"; fi\nexit 0\n',
|
||||
);
|
||||
fs.chmodSync(appPath, 0o755);
|
||||
|
||||
const env = {
|
||||
...makeTestEnv(homeDir, xdgConfigHome),
|
||||
PATH: '',
|
||||
Path: '',
|
||||
SUBMINER_APPIMAGE_PATH: appPath,
|
||||
SUBMINER_TEST_CAPTURE: capturePath,
|
||||
};
|
||||
const result = runLauncher(['doctor', '--refresh-known-words'], env);
|
||||
|
||||
assert.equal(result.status, 0);
|
||||
assert.equal(fs.readFileSync(capturePath, 'utf8'), '--refresh-known-words\n');
|
||||
assert.match(result.stdout, /\[doctor\] mpv: missing/);
|
||||
});
|
||||
});
|
||||
|
||||
test('youtube command rejects removed --mode option', () => {
|
||||
withTempDir((root) => {
|
||||
const homeDir = path.join(root, 'home');
|
||||
@@ -279,8 +308,8 @@ for arg in "$@"; do
|
||||
;;
|
||||
esac
|
||||
done
|
||||
${bunBinary} -e "const net=require('node:net'); const fs=require('node:fs'); const socket=process.argv[1]; try { fs.rmSync(socket,{force:true}); } catch {} const server=net.createServer((conn)=>conn.end()); server.listen(socket,()=>setTimeout(()=>server.close(()=>process.exit(0)),250));" "$socket_path"
|
||||
`,
|
||||
${bunBinary} -e "const net=require('node:net'); const fs=require('node:fs'); const path=require('node:path'); const socket=process.argv[1]||''; try{ if(socket) fs.mkdirSync(path.dirname(socket),{recursive:true}); }catch{} try{ if(socket) fs.rmSync(socket,{force:true}); }catch{} const server=net.createServer((c)=>c.end()); server.on('error',()=>process.exit(0)); if(!socket) process.exit(0); try{ server.listen(socket,()=>setTimeout(()=>server.close(()=>process.exit(0)),250)); } catch { process.exit(0); }" "$socket_path"
|
||||
`,
|
||||
'utf8',
|
||||
);
|
||||
fs.chmodSync(path.join(binDir, 'mpv'), 0o755);
|
||||
@@ -306,6 +335,155 @@ ${bunBinary} -e "const net=require('node:net'); const fs=require('node:fs'); con
|
||||
});
|
||||
});
|
||||
|
||||
test('launcher forwards --args to mpv as parsed tokens', { timeout: 15000 }, () => {
|
||||
withTempDir((root) => {
|
||||
const homeDir = path.join(root, 'home');
|
||||
const xdgConfigHome = path.join(root, 'xdg');
|
||||
const binDir = path.join(root, 'bin');
|
||||
const appPath = path.join(root, 'fake-subminer.sh');
|
||||
const videoPath = path.join(root, 'movie.mkv');
|
||||
const mpvArgsPath = path.join(root, 'mpv-args.txt');
|
||||
const socketPath = path.join(root, 'mpv.sock');
|
||||
const bunBinary = JSON.stringify(process.execPath.replace(/\\/g, '/'));
|
||||
|
||||
fs.mkdirSync(binDir, { recursive: true });
|
||||
fs.mkdirSync(path.join(xdgConfigHome, 'SubMiner'), { recursive: true });
|
||||
fs.mkdirSync(path.join(xdgConfigHome, 'mpv', 'script-opts'), { recursive: true });
|
||||
fs.writeFileSync(videoPath, 'fake video content');
|
||||
fs.writeFileSync(
|
||||
path.join(xdgConfigHome, 'SubMiner', 'setup-state.json'),
|
||||
JSON.stringify({
|
||||
version: 1,
|
||||
status: 'completed',
|
||||
completedAt: '2026-03-08T00:00:00.000Z',
|
||||
completionSource: 'user',
|
||||
lastSeenYomitanDictionaryCount: 0,
|
||||
pluginInstallStatus: 'installed',
|
||||
pluginInstallPathSummary: null,
|
||||
}),
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(xdgConfigHome, 'mpv', 'script-opts', 'subminer.conf'),
|
||||
`socket_path=${socketPath}\nauto_start=no\nauto_start_visible_overlay=no\nauto_start_pause_until_ready=no\n`,
|
||||
);
|
||||
fs.writeFileSync(appPath, '#!/bin/sh\nexit 0\n');
|
||||
fs.chmodSync(appPath, 0o755);
|
||||
|
||||
fs.writeFileSync(
|
||||
path.join(binDir, 'mpv'),
|
||||
`#!/bin/sh
|
||||
set -eu
|
||||
printf '%s\\n' "$@" > "$SUBMINER_TEST_MPV_ARGS"
|
||||
socket_path=""
|
||||
for arg in "$@"; do
|
||||
case "$arg" in
|
||||
--input-ipc-server=*)
|
||||
socket_path="\${arg#--input-ipc-server=}"
|
||||
;;
|
||||
esac
|
||||
done
|
||||
${bunBinary} -e "const net=require('node:net'); const fs=require('node:fs'); const path=require('node:path'); const socket=process.argv[1]||''; try{ if (socket) fs.mkdirSync(path.dirname(socket),{recursive:true}); }catch{} try{ if (socket) fs.rmSync(socket,{force:true}); }catch{} if(!socket) process.exit(0); const server=net.createServer((c)=>c.end()); server.on('error',()=>process.exit(0)); try{ server.listen(socket,()=>setTimeout(()=>server.close(()=>process.exit(0)),250)); } catch { process.exit(0); }" "$socket_path"
|
||||
`,
|
||||
'utf8',
|
||||
);
|
||||
fs.chmodSync(path.join(binDir, 'mpv'), 0o755);
|
||||
|
||||
const env = {
|
||||
...makeTestEnv(homeDir, xdgConfigHome),
|
||||
PATH: `${binDir}${path.delimiter}${process.env.Path || process.env.PATH || ''}`,
|
||||
Path: `${binDir}${path.delimiter}${process.env.Path || process.env.PATH || ''}`,
|
||||
SUBMINER_APPIMAGE_PATH: appPath,
|
||||
SUBMINER_TEST_MPV_ARGS: mpvArgsPath,
|
||||
};
|
||||
const result = runLauncher(
|
||||
['--args', '--pause=yes --title="movie night"', videoPath],
|
||||
env,
|
||||
);
|
||||
|
||||
assert.equal(result.status, 0, `stdout:\n${result.stdout}\nstderr:\n${result.stderr}`);
|
||||
const argsFile = fs.readFileSync(mpvArgsPath, 'utf8');
|
||||
const forwardedArgs = argsFile
|
||||
.trim()
|
||||
.split('\n')
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
assert.equal(forwardedArgs.includes('--pause=yes'), true);
|
||||
assert.equal(forwardedArgs.includes('--title=movie night'), true);
|
||||
assert.equal(forwardedArgs.includes(videoPath), true);
|
||||
});
|
||||
});
|
||||
|
||||
test('launcher forwards non-info log level into mpv plugin script opts', { timeout: 15000 }, () => {
|
||||
withTempDir((root) => {
|
||||
const homeDir = path.join(root, 'home');
|
||||
const xdgConfigHome = path.join(root, 'xdg');
|
||||
const binDir = path.join(root, 'bin');
|
||||
const appPath = path.join(root, 'fake-subminer.sh');
|
||||
const videoPath = path.join(root, 'movie.mkv');
|
||||
const mpvArgsPath = path.join(root, 'mpv-args.txt');
|
||||
const socketPath = path.join(root, 'mpv.sock');
|
||||
const bunBinary = JSON.stringify(process.execPath.replace(/\\/g, '/'));
|
||||
|
||||
fs.mkdirSync(binDir, { recursive: true });
|
||||
fs.mkdirSync(path.join(xdgConfigHome, 'SubMiner'), { recursive: true });
|
||||
fs.mkdirSync(path.join(xdgConfigHome, 'mpv', 'script-opts'), { recursive: true });
|
||||
fs.writeFileSync(videoPath, 'fake video content');
|
||||
fs.writeFileSync(
|
||||
path.join(xdgConfigHome, 'SubMiner', 'setup-state.json'),
|
||||
JSON.stringify({
|
||||
version: 1,
|
||||
status: 'completed',
|
||||
completedAt: '2026-03-08T00:00:00.000Z',
|
||||
completionSource: 'user',
|
||||
lastSeenYomitanDictionaryCount: 0,
|
||||
pluginInstallStatus: 'installed',
|
||||
pluginInstallPathSummary: null,
|
||||
}),
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(xdgConfigHome, 'mpv', 'script-opts', 'subminer.conf'),
|
||||
`socket_path=${socketPath}\nauto_start=yes\nauto_start_visible_overlay=yes\nauto_start_pause_until_ready=yes\n`,
|
||||
);
|
||||
fs.writeFileSync(appPath, '#!/bin/sh\nexit 0\n');
|
||||
fs.chmodSync(appPath, 0o755);
|
||||
|
||||
fs.writeFileSync(
|
||||
path.join(binDir, 'mpv'),
|
||||
`#!/bin/sh
|
||||
set -eu
|
||||
printf '%s\\n' "$@" > "$SUBMINER_TEST_MPV_ARGS"
|
||||
socket_path=""
|
||||
for arg in "$@"; do
|
||||
case "$arg" in
|
||||
--input-ipc-server=*)
|
||||
socket_path="\${arg#--input-ipc-server=}"
|
||||
;;
|
||||
esac
|
||||
done
|
||||
${bunBinary} -e "const net=require('node:net'); const fs=require('node:fs'); const path=require('node:path'); const socket=process.argv[1]||''; try{ if (socket) fs.mkdirSync(path.dirname(socket),{recursive:true}); }catch{} try{ if (socket) fs.rmSync(socket,{force:true}); }catch{} if(!socket) process.exit(0); const server=net.createServer((c)=>c.end()); server.on('error',()=>process.exit(0)); try{ server.listen(socket,()=>setTimeout(()=>server.close(()=>process.exit(0)),250)); } catch { process.exit(0); }" "$socket_path"
|
||||
`,
|
||||
'utf8',
|
||||
);
|
||||
fs.chmodSync(path.join(binDir, 'mpv'), 0o755);
|
||||
|
||||
const env = {
|
||||
...makeTestEnv(homeDir, xdgConfigHome),
|
||||
PATH: `${binDir}${path.delimiter}${process.env.Path || process.env.PATH || ''}`,
|
||||
Path: `${binDir}${path.delimiter}${process.env.Path || process.env.PATH || ''}`,
|
||||
SUBMINER_APPIMAGE_PATH: appPath,
|
||||
SUBMINER_TEST_MPV_ARGS: mpvArgsPath,
|
||||
};
|
||||
const result = runLauncher(['--log-level', 'debug', videoPath], env);
|
||||
|
||||
assert.equal(result.status, 0, `stdout:\n${result.stdout}\nstderr:\n${result.stderr}`);
|
||||
assert.match(
|
||||
fs.readFileSync(mpvArgsPath, 'utf8'),
|
||||
/--script-opts=.*subminer-log_level=debug/,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test('dictionary command forwards --dictionary and --dictionary-target to app command path', () => {
|
||||
withTempDir((root) => {
|
||||
const homeDir = path.join(root, 'home');
|
||||
@@ -335,6 +513,110 @@ test('dictionary command forwards --dictionary and --dictionary-target to app co
|
||||
});
|
||||
});
|
||||
|
||||
test(
|
||||
'stats command launches attached app flow and waits for response file',
|
||||
{ timeout: 15000 },
|
||||
() => {
|
||||
withTempDir((root) => {
|
||||
const homeDir = path.join(root, 'home');
|
||||
const xdgConfigHome = path.join(root, 'xdg');
|
||||
const appPath = path.join(root, 'fake-subminer.sh');
|
||||
const capturePath = path.join(root, 'captured-args.txt');
|
||||
fs.writeFileSync(
|
||||
appPath,
|
||||
`#!/bin/sh
|
||||
set -eu
|
||||
response_path=""
|
||||
prev=""
|
||||
for arg in "$@"; do
|
||||
if [ "$prev" = "--stats-response-path" ]; then
|
||||
response_path="$arg"
|
||||
prev=""
|
||||
continue
|
||||
fi
|
||||
case "$arg" in
|
||||
--stats-response-path=*)
|
||||
response_path="\${arg#--stats-response-path=}"
|
||||
;;
|
||||
--stats-response-path)
|
||||
prev="--stats-response-path"
|
||||
;;
|
||||
esac
|
||||
done
|
||||
if [ -n "$SUBMINER_TEST_STATS_CAPTURE" ]; then
|
||||
printf '%s\\n' "$@" > "$SUBMINER_TEST_STATS_CAPTURE"
|
||||
fi
|
||||
mkdir -p "$(dirname "$response_path")"
|
||||
printf '%s' '{"ok":true,"url":"http://127.0.0.1:5175"}' > "$response_path"
|
||||
exit 0
|
||||
`,
|
||||
);
|
||||
fs.chmodSync(appPath, 0o755);
|
||||
|
||||
const env = {
|
||||
...makeTestEnv(homeDir, xdgConfigHome),
|
||||
SUBMINER_APPIMAGE_PATH: appPath,
|
||||
SUBMINER_TEST_STATS_CAPTURE: capturePath,
|
||||
};
|
||||
const result = runLauncher(['stats', '--log-level', 'debug'], env);
|
||||
|
||||
assert.equal(result.status, 0, `stdout:\n${result.stdout}\nstderr:\n${result.stderr}`);
|
||||
assert.match(
|
||||
fs.readFileSync(capturePath, 'utf8'),
|
||||
/^--stats\n--stats-response-path\n.+\n--log-level\ndebug\n$/,
|
||||
);
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'stats command tolerates slower dashboard startup before timing out',
|
||||
{ timeout: 20000 },
|
||||
() => {
|
||||
withTempDir((root) => {
|
||||
const homeDir = path.join(root, 'home');
|
||||
const xdgConfigHome = path.join(root, 'xdg');
|
||||
const appPath = path.join(root, 'fake-subminer-slow.sh');
|
||||
fs.writeFileSync(
|
||||
appPath,
|
||||
`#!/bin/sh
|
||||
set -eu
|
||||
response_path=""
|
||||
prev=""
|
||||
for arg in "$@"; do
|
||||
if [ "$prev" = "--stats-response-path" ]; then
|
||||
response_path="$arg"
|
||||
prev=""
|
||||
continue
|
||||
fi
|
||||
case "$arg" in
|
||||
--stats-response-path=*)
|
||||
response_path="\${arg#--stats-response-path=}"
|
||||
;;
|
||||
--stats-response-path)
|
||||
prev="--stats-response-path"
|
||||
;;
|
||||
esac
|
||||
done
|
||||
sleep 9
|
||||
mkdir -p "$(dirname "$response_path")"
|
||||
printf '%s' '{"ok":true,"url":"http://127.0.0.1:5175"}' > "$response_path"
|
||||
exit 0
|
||||
`,
|
||||
);
|
||||
fs.chmodSync(appPath, 0o755);
|
||||
|
||||
const env = {
|
||||
...makeTestEnv(homeDir, xdgConfigHome),
|
||||
SUBMINER_APPIMAGE_PATH: appPath,
|
||||
};
|
||||
const result = runLauncher(['stats'], env);
|
||||
|
||||
assert.equal(result.status, 0, `stdout:\n${result.stdout}\nstderr:\n${result.stderr}`);
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
test('jellyfin discovery routes to app --background and remote announce with log-level forwarding', () => {
|
||||
withTempDir((root) => {
|
||||
const homeDir = path.join(root, 'home');
|
||||
|
||||
@@ -14,6 +14,7 @@ import { runConfigCommand } from './commands/config-command.js';
|
||||
import { runMpvPostAppCommand, runMpvPreAppCommand } from './commands/mpv-command.js';
|
||||
import { runAppPassthroughCommand, runTexthookerCommand } from './commands/app-command.js';
|
||||
import { runDictionaryCommand } from './commands/dictionary-command.js';
|
||||
import { runStatsCommand } from './commands/stats-command.js';
|
||||
import { runJellyfinCommand } from './commands/jellyfin-command.js';
|
||||
import { runPlaybackCommand } from './commands/playback-command.js';
|
||||
|
||||
@@ -95,6 +96,10 @@ async function main(): Promise<void> {
|
||||
return;
|
||||
}
|
||||
|
||||
if (await runStatsCommand(appContext)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (await runJellyfinCommand(appContext)) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -2,19 +2,53 @@ import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import os from 'node:os';
|
||||
import net from 'node:net';
|
||||
import { EventEmitter } from 'node:events';
|
||||
import type { Args } from './types';
|
||||
import {
|
||||
cleanupPlaybackSession,
|
||||
findAppBinary,
|
||||
launchAppCommandDetached,
|
||||
launchTexthookerOnly,
|
||||
parseMpvArgString,
|
||||
runAppCommandCaptureOutput,
|
||||
shouldResolveAniSkipMetadata,
|
||||
stopOverlay,
|
||||
startOverlay,
|
||||
state,
|
||||
waitForUnixSocketReady,
|
||||
} from './mpv';
|
||||
import * as mpvModule from './mpv';
|
||||
|
||||
class ExitSignal extends Error {
|
||||
code: number;
|
||||
|
||||
constructor(code: number) {
|
||||
super(`exit:${code}`);
|
||||
this.code = code;
|
||||
}
|
||||
}
|
||||
|
||||
function withProcessExitIntercept(callback: () => void): ExitSignal {
|
||||
const originalExit = process.exit;
|
||||
try {
|
||||
process.exit = ((code?: number) => {
|
||||
throw new ExitSignal(code ?? 0);
|
||||
}) as typeof process.exit;
|
||||
callback();
|
||||
} catch (error) {
|
||||
if (error instanceof ExitSignal) {
|
||||
return error;
|
||||
}
|
||||
throw error;
|
||||
} finally {
|
||||
process.exit = originalExit;
|
||||
}
|
||||
|
||||
throw new Error('expected process.exit');
|
||||
}
|
||||
|
||||
function createTempSocketPath(): { dir: string; socketPath: string } {
|
||||
const baseDir = path.join(process.cwd(), '.tmp', 'launcher-mpv-tests');
|
||||
fs.mkdirSync(baseDir, { recursive: true });
|
||||
@@ -38,6 +72,94 @@ test('runAppCommandCaptureOutput captures status and stdio', () => {
|
||||
assert.equal(result.error, undefined);
|
||||
});
|
||||
|
||||
test('runAppCommandCaptureOutput strips ELECTRON_RUN_AS_NODE from app child env', () => {
|
||||
const original = process.env.ELECTRON_RUN_AS_NODE;
|
||||
try {
|
||||
process.env.ELECTRON_RUN_AS_NODE = '1';
|
||||
const result = runAppCommandCaptureOutput(process.execPath, [
|
||||
'-e',
|
||||
'process.stdout.write(String(process.env.ELECTRON_RUN_AS_NODE ?? ""));',
|
||||
]);
|
||||
|
||||
assert.equal(result.status, 0);
|
||||
assert.equal(result.stdout, '');
|
||||
} finally {
|
||||
if (original === undefined) {
|
||||
delete process.env.ELECTRON_RUN_AS_NODE;
|
||||
} else {
|
||||
process.env.ELECTRON_RUN_AS_NODE = original;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('parseMpvArgString preserves empty quoted tokens', () => {
|
||||
assert.deepEqual(parseMpvArgString('--title "" --force-media-title \'\' --pause'), [
|
||||
'--title',
|
||||
'',
|
||||
'--force-media-title',
|
||||
'',
|
||||
'--pause',
|
||||
]);
|
||||
});
|
||||
|
||||
test('launchTexthookerOnly exits non-zero when app binary cannot be spawned', () => {
|
||||
const error = withProcessExitIntercept(() => {
|
||||
launchTexthookerOnly('/definitely-missing-subminer-binary', makeArgs());
|
||||
});
|
||||
|
||||
assert.equal(error.code, 1);
|
||||
});
|
||||
|
||||
test('launchAppCommandDetached handles child process spawn errors', async () => {
|
||||
let uncaughtError: Error | null = null;
|
||||
const onUncaughtException = (error: Error) => {
|
||||
uncaughtError = error;
|
||||
};
|
||||
process.once('uncaughtException', onUncaughtException);
|
||||
try {
|
||||
launchAppCommandDetached(
|
||||
'/definitely-missing-subminer-binary',
|
||||
[],
|
||||
makeArgs({ logLevel: 'warn' }).logLevel,
|
||||
'test',
|
||||
);
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
assert.equal(uncaughtError, null);
|
||||
} finally {
|
||||
process.removeListener('uncaughtException', onUncaughtException);
|
||||
}
|
||||
});
|
||||
|
||||
test('stopOverlay logs a warning when stop command cannot be spawned', () => {
|
||||
const originalWrite = process.stdout.write;
|
||||
const writes: string[] = [];
|
||||
const overlayProc = {
|
||||
killed: false,
|
||||
kill: () => true,
|
||||
} as unknown as NonNullable<typeof state.overlayProc>;
|
||||
|
||||
try {
|
||||
process.stdout.write = ((chunk: string | Uint8Array) => {
|
||||
writes.push(Buffer.isBuffer(chunk) ? chunk.toString('utf8') : String(chunk));
|
||||
return true;
|
||||
}) as typeof process.stdout.write;
|
||||
state.stopRequested = false;
|
||||
state.overlayManagedByLauncher = true;
|
||||
state.appPath = '/definitely-missing-subminer-binary';
|
||||
state.overlayProc = overlayProc;
|
||||
|
||||
stopOverlay(makeArgs({ logLevel: 'warn' }));
|
||||
|
||||
assert.ok(writes.some((text) => text.includes('Failed to stop SubMiner overlay')));
|
||||
} finally {
|
||||
process.stdout.write = originalWrite;
|
||||
state.stopRequested = false;
|
||||
state.overlayManagedByLauncher = false;
|
||||
state.appPath = '';
|
||||
state.overlayProc = null;
|
||||
}
|
||||
});
|
||||
|
||||
test('waitForUnixSocketReady returns false when socket never appears', async () => {
|
||||
const { dir, socketPath } = createTempSocketPath();
|
||||
try {
|
||||
@@ -133,12 +255,15 @@ function makeArgs(overrides: Partial<Args> = {}): Args {
|
||||
jellyfinPlay: false,
|
||||
jellyfinDiscovery: false,
|
||||
dictionary: false,
|
||||
stats: false,
|
||||
doctor: false,
|
||||
doctorRefreshKnownWords: false,
|
||||
configPath: false,
|
||||
configShow: false,
|
||||
mpvIdle: false,
|
||||
mpvSocket: false,
|
||||
mpvStatus: false,
|
||||
mpvArgs: '',
|
||||
appPassthrough: false,
|
||||
appArgs: [],
|
||||
jellyfinServer: '',
|
||||
@@ -232,3 +357,110 @@ test('cleanupPlaybackSession preserves background app while stopping mpv-owned c
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
// ── findAppBinary: Linux packaged path discovery ──────────────────────────────
|
||||
|
||||
function makeExecutable(filePath: string): void {
|
||||
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
||||
fs.writeFileSync(filePath, '#!/bin/sh\nexit 0\n');
|
||||
fs.chmodSync(filePath, 0o755);
|
||||
}
|
||||
|
||||
function withFindAppBinaryEnvSandbox(run: () => void): void {
|
||||
const originalAppImagePath = process.env.SUBMINER_APPIMAGE_PATH;
|
||||
const originalBinaryPath = process.env.SUBMINER_BINARY_PATH;
|
||||
try {
|
||||
delete process.env.SUBMINER_APPIMAGE_PATH;
|
||||
delete process.env.SUBMINER_BINARY_PATH;
|
||||
run();
|
||||
} finally {
|
||||
if (originalAppImagePath === undefined) {
|
||||
delete process.env.SUBMINER_APPIMAGE_PATH;
|
||||
} else {
|
||||
process.env.SUBMINER_APPIMAGE_PATH = originalAppImagePath;
|
||||
}
|
||||
if (originalBinaryPath === undefined) {
|
||||
delete process.env.SUBMINER_BINARY_PATH;
|
||||
} else {
|
||||
process.env.SUBMINER_BINARY_PATH = originalBinaryPath;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function withAccessSyncStub(isExecutablePath: (filePath: string) => boolean, run: () => void): void {
|
||||
const originalAccessSync = fs.accessSync;
|
||||
try {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(fs as any).accessSync = (filePath: string): void => {
|
||||
if (isExecutablePath(filePath)) {
|
||||
return;
|
||||
}
|
||||
throw Object.assign(new Error(`EACCES: ${filePath}`), { code: 'EACCES' });
|
||||
};
|
||||
run();
|
||||
} finally {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(fs as any).accessSync = originalAccessSync;
|
||||
}
|
||||
}
|
||||
|
||||
test('findAppBinary resolves ~/.local/bin/SubMiner.AppImage when it exists', () => {
|
||||
const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-test-home-'));
|
||||
const originalHomedir = os.homedir;
|
||||
try {
|
||||
os.homedir = () => baseDir;
|
||||
const appImage = path.join(baseDir, '.local/bin/SubMiner.AppImage');
|
||||
makeExecutable(appImage);
|
||||
|
||||
withFindAppBinaryEnvSandbox(() => {
|
||||
const result = findAppBinary('/some/other/path/subminer');
|
||||
assert.equal(result, appImage);
|
||||
});
|
||||
} finally {
|
||||
os.homedir = originalHomedir;
|
||||
fs.rmSync(baseDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('findAppBinary resolves /opt/SubMiner/SubMiner.AppImage when ~/.local/bin candidate does not exist', () => {
|
||||
const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-test-home-'));
|
||||
const originalHomedir = os.homedir;
|
||||
try {
|
||||
os.homedir = () => baseDir;
|
||||
withFindAppBinaryEnvSandbox(() => {
|
||||
withAccessSyncStub((filePath) => filePath === '/opt/SubMiner/SubMiner.AppImage', () => {
|
||||
const result = findAppBinary('/some/other/path/subminer');
|
||||
assert.equal(result, '/opt/SubMiner/SubMiner.AppImage');
|
||||
});
|
||||
});
|
||||
} finally {
|
||||
os.homedir = originalHomedir;
|
||||
fs.rmSync(baseDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('findAppBinary finds subminer on PATH when AppImage candidates do not exist', () => {
|
||||
const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-test-path-'));
|
||||
const originalHomedir = os.homedir;
|
||||
const originalPath = process.env.PATH;
|
||||
try {
|
||||
os.homedir = () => baseDir;
|
||||
// No AppImage candidates in empty home dir; place subminer wrapper on PATH
|
||||
const binDir = path.join(baseDir, 'bin');
|
||||
const wrapperPath = path.join(binDir, 'subminer');
|
||||
makeExecutable(wrapperPath);
|
||||
process.env.PATH = `${binDir}${path.delimiter}${originalPath ?? ''}`;
|
||||
|
||||
withFindAppBinaryEnvSandbox(() => {
|
||||
withAccessSyncStub((filePath) => filePath === wrapperPath, () => {
|
||||
// selfPath must differ from wrapperPath so the self-check does not exclude it
|
||||
const result = findAppBinary(path.join(baseDir, 'launcher', 'subminer'));
|
||||
assert.equal(result, wrapperPath);
|
||||
});
|
||||
});
|
||||
} finally {
|
||||
os.homedir = originalHomedir;
|
||||
process.env.PATH = originalPath;
|
||||
fs.rmSync(baseDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
185
launcher/mpv.ts
185
launcher/mpv.ts
@@ -38,6 +38,100 @@ 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 function parseMpvArgString(input: string): string[] {
|
||||
const chars = input;
|
||||
const args: string[] = [];
|
||||
let current = '';
|
||||
let tokenStarted = false;
|
||||
let inSingleQuote = false;
|
||||
let inDoubleQuote = false;
|
||||
let escaping = false;
|
||||
const canEscape = (nextChar: string | undefined): boolean =>
|
||||
nextChar === undefined || nextChar === '"' || nextChar === "'" || nextChar === '\\' || /\s/.test(nextChar);
|
||||
|
||||
for (let i = 0; i < chars.length; i += 1) {
|
||||
const ch = chars[i] || '';
|
||||
if (escaping) {
|
||||
current += ch;
|
||||
tokenStarted = true;
|
||||
escaping = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (inSingleQuote) {
|
||||
if (ch === "'") {
|
||||
inSingleQuote = false;
|
||||
} else {
|
||||
current += ch;
|
||||
tokenStarted = true;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (inDoubleQuote) {
|
||||
if (ch === '\\') {
|
||||
if (canEscape(chars[i + 1])) {
|
||||
escaping = true;
|
||||
} else {
|
||||
current += ch;
|
||||
tokenStarted = true;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (ch === '"') {
|
||||
inDoubleQuote = false;
|
||||
continue;
|
||||
}
|
||||
current += ch;
|
||||
tokenStarted = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (ch === '\\') {
|
||||
if (canEscape(chars[i + 1])) {
|
||||
escaping = true;
|
||||
tokenStarted = true;
|
||||
} else {
|
||||
current += ch;
|
||||
tokenStarted = true;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (ch === "'") {
|
||||
tokenStarted = true;
|
||||
inSingleQuote = true;
|
||||
continue;
|
||||
}
|
||||
if (ch === '"') {
|
||||
tokenStarted = true;
|
||||
inDoubleQuote = true;
|
||||
continue;
|
||||
}
|
||||
if (/\s/.test(ch)) {
|
||||
if (tokenStarted) {
|
||||
args.push(current);
|
||||
current = '';
|
||||
tokenStarted = false;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
current += ch;
|
||||
tokenStarted = true;
|
||||
}
|
||||
|
||||
if (escaping) {
|
||||
fail('Could not parse mpv args: trailing backslash');
|
||||
}
|
||||
if (inSingleQuote || inDoubleQuote) {
|
||||
fail('Could not parse mpv args: unmatched quote');
|
||||
}
|
||||
if (tokenStarted) {
|
||||
args.push(current);
|
||||
}
|
||||
|
||||
return args;
|
||||
}
|
||||
|
||||
function readTrackedDetachedMpvPid(): number | null {
|
||||
try {
|
||||
const raw = fs.readFileSync(DETACHED_IDLE_MPV_PID_FILE, 'utf8').trim();
|
||||
@@ -463,6 +557,9 @@ export async function startMpv(
|
||||
const mpvArgs: string[] = [];
|
||||
if (args.profile) mpvArgs.push(`--profile=${args.profile}`);
|
||||
mpvArgs.push(...DEFAULT_MPV_SUBMINER_ARGS);
|
||||
if (args.mpvArgs) {
|
||||
mpvArgs.push(...parseMpvArgString(args.mpvArgs));
|
||||
}
|
||||
|
||||
if (targetKind === 'url' && isYoutubeTarget(target)) {
|
||||
log('info', args.logLevel, 'Applying URL playback options');
|
||||
@@ -500,7 +597,7 @@ export async function startMpv(
|
||||
const aniSkipMetadata = shouldResolveAniSkipMetadata(target, targetKind, preloadedSubtitles)
|
||||
? await resolveAniSkipMetadataForFile(target)
|
||||
: null;
|
||||
const scriptOpts = buildSubminerScriptOpts(appPath, socketPath, aniSkipMetadata);
|
||||
const scriptOpts = buildSubminerScriptOpts(appPath, socketPath, aniSkipMetadata, args.logLevel);
|
||||
if (aniSkipMetadata) {
|
||||
log(
|
||||
'debug',
|
||||
@@ -575,7 +672,7 @@ export async function startOverlay(appPath: string, args: Args, socketPath: stri
|
||||
const target = resolveAppSpawnTarget(appPath, overlayArgs);
|
||||
state.overlayProc = spawn(target.command, target.args, {
|
||||
stdio: 'inherit',
|
||||
env: { ...process.env, SUBMINER_MPV_LOG: getMpvLogPath() },
|
||||
env: buildAppEnv(),
|
||||
});
|
||||
state.overlayManagedByLauncher = true;
|
||||
|
||||
@@ -602,7 +699,13 @@ export function launchTexthookerOnly(appPath: string, args: Args): never {
|
||||
if (args.logLevel !== 'info') overlayArgs.push('--log-level', args.logLevel);
|
||||
|
||||
log('info', args.logLevel, 'Launching texthooker mode...');
|
||||
const result = spawnSync(appPath, overlayArgs, { stdio: 'inherit' });
|
||||
const result = spawnSync(appPath, overlayArgs, {
|
||||
stdio: 'inherit',
|
||||
env: buildAppEnv(),
|
||||
});
|
||||
if (result.error) {
|
||||
fail(`Failed to launch texthooker mode: ${result.error.message}`);
|
||||
}
|
||||
process.exit(result.status ?? 0);
|
||||
}
|
||||
|
||||
@@ -616,7 +719,15 @@ export function stopOverlay(args: Args): void {
|
||||
const stopArgs = ['--stop'];
|
||||
if (args.logLevel !== 'info') stopArgs.push('--log-level', args.logLevel);
|
||||
|
||||
spawnSync(state.appPath, stopArgs, { stdio: 'ignore' });
|
||||
const result = spawnSync(state.appPath, stopArgs, {
|
||||
stdio: 'ignore',
|
||||
env: buildAppEnv(),
|
||||
});
|
||||
if (result.error) {
|
||||
log('warn', args.logLevel, `Failed to stop SubMiner overlay: ${result.error.message}`);
|
||||
} else if (typeof result.status === 'number' && result.status !== 0) {
|
||||
log('warn', args.logLevel, `SubMiner overlay stop command exited with status ${result.status}`);
|
||||
}
|
||||
|
||||
if (state.overlayProc && !state.overlayProc.killed) {
|
||||
try {
|
||||
@@ -677,6 +788,7 @@ function buildAppEnv(): NodeJS.ProcessEnv {
|
||||
...process.env,
|
||||
SUBMINER_MPV_LOG: getMpvLogPath(),
|
||||
};
|
||||
delete env.ELECTRON_RUN_AS_NODE;
|
||||
const layers = env.VK_INSTANCE_LAYERS;
|
||||
if (typeof layers === 'string' && layers.trim().length > 0) {
|
||||
const filtered = layers
|
||||
@@ -756,6 +868,43 @@ export function runAppCommandCaptureOutput(
|
||||
};
|
||||
}
|
||||
|
||||
export function runAppCommandAttached(
|
||||
appPath: string,
|
||||
appArgs: string[],
|
||||
logLevel: LogLevel,
|
||||
label: string,
|
||||
): Promise<number> {
|
||||
if (maybeCaptureAppArgs(appArgs)) {
|
||||
return Promise.resolve(0);
|
||||
}
|
||||
|
||||
const target = resolveAppSpawnTarget(appPath, appArgs);
|
||||
log(
|
||||
'debug',
|
||||
logLevel,
|
||||
`${label}: launching attached app with args: ${[target.command, ...target.args].join(' ')}`,
|
||||
);
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const proc = spawn(target.command, target.args, {
|
||||
stdio: 'inherit',
|
||||
env: buildAppEnv(),
|
||||
});
|
||||
proc.once('error', (error) => {
|
||||
reject(error);
|
||||
});
|
||||
proc.once('exit', (code, signal) => {
|
||||
if (code !== null) {
|
||||
resolve(code);
|
||||
} else if (signal) {
|
||||
resolve(128);
|
||||
} else {
|
||||
resolve(0);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export function runAppCommandWithInheritLogged(
|
||||
appPath: string,
|
||||
appArgs: string[],
|
||||
@@ -786,15 +935,32 @@ export function runAppCommandWithInheritLogged(
|
||||
export function launchAppStartDetached(appPath: string, logLevel: LogLevel): void {
|
||||
const startArgs = ['--start'];
|
||||
if (logLevel !== 'info') startArgs.push('--log-level', logLevel);
|
||||
if (maybeCaptureAppArgs(startArgs)) {
|
||||
launchAppCommandDetached(appPath, startArgs, logLevel, 'start');
|
||||
}
|
||||
|
||||
export function launchAppCommandDetached(
|
||||
appPath: string,
|
||||
appArgs: string[],
|
||||
logLevel: LogLevel,
|
||||
label: string,
|
||||
): void {
|
||||
if (maybeCaptureAppArgs(appArgs)) {
|
||||
return;
|
||||
}
|
||||
const target = resolveAppSpawnTarget(appPath, startArgs);
|
||||
const target = resolveAppSpawnTarget(appPath, appArgs);
|
||||
log(
|
||||
'debug',
|
||||
logLevel,
|
||||
`${label}: launching detached app with args: ${[target.command, ...target.args].join(' ')}`,
|
||||
);
|
||||
const proc = spawn(target.command, target.args, {
|
||||
stdio: 'ignore',
|
||||
detached: true,
|
||||
env: buildAppEnv(),
|
||||
});
|
||||
proc.once('error', (error) => {
|
||||
log('warn', logLevel, `${label}: failed to launch detached app: ${error.message}`);
|
||||
});
|
||||
proc.unref();
|
||||
}
|
||||
|
||||
@@ -814,10 +980,11 @@ export function launchMpvIdleDetached(
|
||||
const mpvArgs: string[] = [];
|
||||
if (args.profile) mpvArgs.push(`--profile=${args.profile}`);
|
||||
mpvArgs.push(...DEFAULT_MPV_SUBMINER_ARGS);
|
||||
if (args.mpvArgs) {
|
||||
mpvArgs.push(...parseMpvArgString(args.mpvArgs));
|
||||
}
|
||||
mpvArgs.push('--idle=yes');
|
||||
mpvArgs.push(
|
||||
`--script-opts=subminer-binary_path=${appPath},subminer-socket_path=${socketPath}`,
|
||||
);
|
||||
mpvArgs.push(`--script-opts=${buildSubminerScriptOpts(appPath, socketPath, null, args.logLevel)}`);
|
||||
mpvArgs.push(`--log-file=${getMpvLogPath()}`);
|
||||
mpvArgs.push(`--input-ipc-server=${socketPath}`);
|
||||
const mpvTarget = resolveCommandInvocation('mpv', mpvArgs);
|
||||
|
||||
@@ -2,6 +2,34 @@ import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { parseArgs } from './config';
|
||||
|
||||
class ExitSignal extends Error {
|
||||
code: number;
|
||||
|
||||
constructor(code: number) {
|
||||
super(`exit:${code}`);
|
||||
this.code = code;
|
||||
}
|
||||
}
|
||||
|
||||
function withProcessExitIntercept(callback: () => void): ExitSignal {
|
||||
const originalExit = process.exit;
|
||||
try {
|
||||
process.exit = ((code?: number) => {
|
||||
throw new ExitSignal(code ?? 0);
|
||||
}) as typeof process.exit;
|
||||
callback();
|
||||
} catch (error) {
|
||||
if (error instanceof ExitSignal) {
|
||||
return error;
|
||||
}
|
||||
throw error;
|
||||
} finally {
|
||||
process.exit = originalExit;
|
||||
}
|
||||
|
||||
throw new Error('expected parseArgs to exit');
|
||||
}
|
||||
|
||||
test('parseArgs captures passthrough args for app subcommand', () => {
|
||||
const parsed = parseArgs(['app', '--anilist', '--log-level', 'debug'], 'subminer', {});
|
||||
|
||||
@@ -23,6 +51,12 @@ test('parseArgs keeps all args after app verbatim', () => {
|
||||
assert.deepEqual(parsed.appArgs, ['--start', '--anilist-setup', '-h']);
|
||||
});
|
||||
|
||||
test('parseArgs captures mpv args string', () => {
|
||||
const parsed = parseArgs(['--args', '--pause=yes --title="movie night"'], 'subminer', {});
|
||||
|
||||
assert.equal(parsed.mpvArgs, '--pause=yes --title="movie night"');
|
||||
});
|
||||
|
||||
test('parseArgs maps jellyfin play action and log-level override', () => {
|
||||
const parsed = parseArgs(['jellyfin', 'play', '--log-level', 'debug'], 'subminer', {});
|
||||
|
||||
@@ -58,3 +92,82 @@ test('parseArgs maps dictionary command and log-level override', () => {
|
||||
assert.equal(parsed.dictionaryTarget, process.cwd());
|
||||
assert.equal(parsed.logLevel, 'debug');
|
||||
});
|
||||
|
||||
test('parseArgs maps stats command and log-level override', () => {
|
||||
const parsed = parseArgs(['stats', '--log-level', 'debug'], 'subminer', {});
|
||||
|
||||
assert.equal(parsed.stats, true);
|
||||
assert.equal(parsed.logLevel, 'debug');
|
||||
});
|
||||
|
||||
test('parseArgs maps stats background flag', () => {
|
||||
const parsed = parseArgs(['stats', '-b'], 'subminer', {}) as ReturnType<typeof parseArgs> & {
|
||||
statsBackground?: boolean;
|
||||
statsStop?: boolean;
|
||||
};
|
||||
|
||||
assert.equal(parsed.stats, true);
|
||||
assert.equal(parsed.statsBackground, true);
|
||||
assert.equal(parsed.statsStop, false);
|
||||
});
|
||||
|
||||
test('parseArgs maps stats stop flag', () => {
|
||||
const parsed = parseArgs(['stats', '-s'], 'subminer', {}) as ReturnType<typeof parseArgs> & {
|
||||
statsBackground?: boolean;
|
||||
statsStop?: boolean;
|
||||
};
|
||||
|
||||
assert.equal(parsed.stats, true);
|
||||
assert.equal(parsed.statsStop, true);
|
||||
assert.equal(parsed.statsBackground, false);
|
||||
});
|
||||
|
||||
test('parseArgs maps stats cleanup to vocab mode by default', () => {
|
||||
const parsed = parseArgs(['stats', 'cleanup'], 'subminer', {});
|
||||
|
||||
assert.equal(parsed.stats, true);
|
||||
assert.equal(parsed.statsCleanup, true);
|
||||
assert.equal(parsed.statsCleanupVocab, true);
|
||||
});
|
||||
|
||||
test('parseArgs maps explicit stats cleanup vocab flag', () => {
|
||||
const parsed = parseArgs(['stats', 'cleanup', '-v'], 'subminer', {});
|
||||
|
||||
assert.equal(parsed.stats, true);
|
||||
assert.equal(parsed.statsCleanup, true);
|
||||
assert.equal(parsed.statsCleanupVocab, true);
|
||||
});
|
||||
|
||||
test('parseArgs maps lifetime stats cleanup flag', () => {
|
||||
const parsed = parseArgs(['stats', 'cleanup', '--lifetime'], 'subminer', {});
|
||||
|
||||
assert.equal(parsed.stats, true);
|
||||
assert.equal(parsed.statsCleanup, true);
|
||||
assert.equal(parsed.statsCleanupVocab, false);
|
||||
assert.equal(parsed.statsCleanupLifetime, true);
|
||||
});
|
||||
|
||||
test('parseArgs rejects cleanup-only stats flags without cleanup action', () => {
|
||||
const error = withProcessExitIntercept(() => {
|
||||
parseArgs(['stats', '--vocab'], 'subminer', {});
|
||||
});
|
||||
|
||||
assert.equal(error.code, 1);
|
||||
assert.match(error.message, /exit:1/);
|
||||
});
|
||||
|
||||
test('parseArgs maps stats rebuild action to cleanup lifetime mode', () => {
|
||||
const parsed = parseArgs(['stats', 'rebuild'], 'subminer', {});
|
||||
|
||||
assert.equal(parsed.stats, true);
|
||||
assert.equal(parsed.statsCleanup, true);
|
||||
assert.equal(parsed.statsCleanupVocab, false);
|
||||
assert.equal(parsed.statsCleanupLifetime, true);
|
||||
});
|
||||
|
||||
test('parseArgs maps doctor refresh-known-words flag', () => {
|
||||
const parsed = parseArgs(['doctor', '--refresh-known-words'], 'subminer', {});
|
||||
|
||||
assert.equal(parsed.doctor, true);
|
||||
assert.equal(parsed.doctorRefreshKnownWords, true);
|
||||
});
|
||||
|
||||
108
launcher/picker.test.ts
Normal file
108
launcher/picker.test.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import os from 'node:os';
|
||||
import { findRofiTheme } from './picker';
|
||||
|
||||
// ── findRofiTheme: Linux packaged path discovery ──────────────────────────────
|
||||
|
||||
const ROFI_THEME_FILE = 'subminer.rasi';
|
||||
|
||||
function makeFile(filePath: string): void {
|
||||
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
||||
fs.writeFileSync(filePath, '/* theme */');
|
||||
}
|
||||
|
||||
function withPlatform<T>(platform: NodeJS.Platform, callback: () => T): T {
|
||||
const originalDescriptor = Object.getOwnPropertyDescriptor(process, 'platform');
|
||||
Object.defineProperty(process, 'platform', {
|
||||
value: platform,
|
||||
});
|
||||
try {
|
||||
return callback();
|
||||
} finally {
|
||||
if (originalDescriptor) {
|
||||
Object.defineProperty(process, 'platform', originalDescriptor);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
test('findRofiTheme resolves /usr/local/share/SubMiner/themes/subminer.rasi when it exists', () => {
|
||||
const originalExistsSync = fs.existsSync;
|
||||
const targetPath = `/usr/local/share/SubMiner/themes/${ROFI_THEME_FILE}`;
|
||||
try {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(fs as any).existsSync = (filePath: unknown): boolean => {
|
||||
if (filePath === targetPath) return true;
|
||||
return false;
|
||||
};
|
||||
|
||||
const result = withPlatform('linux', () => findRofiTheme('/usr/local/bin/subminer'));
|
||||
assert.equal(result, targetPath);
|
||||
} finally {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(fs as any).existsSync = originalExistsSync;
|
||||
}
|
||||
});
|
||||
|
||||
test('findRofiTheme resolves /usr/share/SubMiner/themes/subminer.rasi when /usr/local/share one does not exist', () => {
|
||||
const originalExistsSync = fs.existsSync;
|
||||
const localSharePath = `/usr/local/share/SubMiner/themes/${ROFI_THEME_FILE}`;
|
||||
const sharePath = `/usr/share/SubMiner/themes/${ROFI_THEME_FILE}`;
|
||||
try {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(fs as any).existsSync = (filePath: unknown): boolean => {
|
||||
if (filePath === sharePath) return true;
|
||||
if (filePath === localSharePath) return false;
|
||||
return false;
|
||||
};
|
||||
|
||||
const result = withPlatform('linux', () => findRofiTheme('/usr/bin/subminer'));
|
||||
assert.equal(result, sharePath);
|
||||
} finally {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(fs as any).existsSync = originalExistsSync;
|
||||
}
|
||||
});
|
||||
|
||||
test('findRofiTheme resolves XDG_DATA_HOME/SubMiner/themes/subminer.rasi when set and file exists', () => {
|
||||
const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-test-xdg-'));
|
||||
const originalXdgDataHome = process.env.XDG_DATA_HOME;
|
||||
try {
|
||||
process.env.XDG_DATA_HOME = baseDir;
|
||||
const themePath = path.join(baseDir, `SubMiner/themes/${ROFI_THEME_FILE}`);
|
||||
makeFile(themePath);
|
||||
|
||||
const result = withPlatform('linux', () => findRofiTheme('/usr/bin/subminer'));
|
||||
assert.equal(result, themePath);
|
||||
} finally {
|
||||
if (originalXdgDataHome !== undefined) {
|
||||
process.env.XDG_DATA_HOME = originalXdgDataHome;
|
||||
} else {
|
||||
delete process.env.XDG_DATA_HOME;
|
||||
}
|
||||
fs.rmSync(baseDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('findRofiTheme resolves ~/.local/share/SubMiner/themes/subminer.rasi when XDG_DATA_HOME unset', () => {
|
||||
const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-test-home-'));
|
||||
const originalHomedir = os.homedir;
|
||||
const originalXdgDataHome = process.env.XDG_DATA_HOME;
|
||||
try {
|
||||
os.homedir = () => baseDir;
|
||||
delete process.env.XDG_DATA_HOME;
|
||||
const themePath = path.join(baseDir, `.local/share/SubMiner/themes/${ROFI_THEME_FILE}`);
|
||||
makeFile(themePath);
|
||||
|
||||
const result = withPlatform('linux', () => findRofiTheme('/usr/bin/subminer'));
|
||||
assert.equal(result, themePath);
|
||||
} finally {
|
||||
os.homedir = originalHomedir;
|
||||
if (originalXdgDataHome !== undefined) {
|
||||
process.env.XDG_DATA_HOME = originalXdgDataHome;
|
||||
}
|
||||
fs.rmSync(baseDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
@@ -111,13 +111,21 @@ export interface Args {
|
||||
jellyfinPlay: boolean;
|
||||
jellyfinDiscovery: boolean;
|
||||
dictionary: boolean;
|
||||
stats: boolean;
|
||||
statsBackground?: boolean;
|
||||
statsStop?: boolean;
|
||||
statsCleanup?: boolean;
|
||||
statsCleanupVocab?: boolean;
|
||||
statsCleanupLifetime?: boolean;
|
||||
dictionaryTarget?: string;
|
||||
doctor: boolean;
|
||||
doctorRefreshKnownWords: boolean;
|
||||
configPath: boolean;
|
||||
configShow: boolean;
|
||||
mpvIdle: boolean;
|
||||
mpvSocket: boolean;
|
||||
mpvStatus: boolean;
|
||||
mpvArgs: string;
|
||||
appPassthrough: boolean;
|
||||
appArgs: string[];
|
||||
jellyfinServer: string;
|
||||
|
||||
Reference in New Issue
Block a user