mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-03-20 12:11:28 -07:00
feat(stats): add launcher stats command and build integration
- Launcher stats subcommand with cleanup mode - Stats frontend build integrated into Makefile - CI workflow updated for stats package - Config example updated with stats section - mpv plugin menu entry for stats toggle
This commit is contained in:
@@ -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;
|
||||
@@ -128,3 +129,98 @@ test('dictionary command throws if app handoff unexpectedly returns', () => {
|
||||
/unexpectedly returned/,
|
||||
);
|
||||
});
|
||||
|
||||
test('stats command launches attached app command with response path', async () => {
|
||||
const context = createContext();
|
||||
context.args.stats = true;
|
||||
context.args.logLevel = 'debug';
|
||||
const forwarded: string[][] = [];
|
||||
|
||||
const handled = await runStatsCommand(context, {
|
||||
createTempDir: () => '/tmp/subminer-stats-test',
|
||||
joinPath: (...parts) => parts.join('/'),
|
||||
runAppCommandAttached: async (_appPath, appArgs) => {
|
||||
forwarded.push(appArgs);
|
||||
return 0;
|
||||
},
|
||||
waitForStatsResponse: async () => ({ ok: true, url: 'http://127.0.0.1:5175' }),
|
||||
removeDir: () => {},
|
||||
});
|
||||
|
||||
assert.equal(handled, true);
|
||||
assert.deepEqual(forwarded, [
|
||||
['--stats', '--stats-response-path', '/tmp/subminer-stats-test/response.json', '--log-level', 'debug'],
|
||||
]);
|
||||
});
|
||||
|
||||
test('stats cleanup command forwards cleanup vocab flags to the app', async () => {
|
||||
const context = createContext();
|
||||
context.args.stats = true;
|
||||
context.args.statsCleanup = true;
|
||||
context.args.statsCleanupVocab = true;
|
||||
const forwarded: string[][] = [];
|
||||
|
||||
const handled = await runStatsCommand(context, {
|
||||
createTempDir: () => '/tmp/subminer-stats-test',
|
||||
joinPath: (...parts) => parts.join('/'),
|
||||
runAppCommandAttached: async (_appPath, appArgs) => {
|
||||
forwarded.push(appArgs);
|
||||
return 0;
|
||||
},
|
||||
waitForStatsResponse: async () => ({ ok: true }),
|
||||
removeDir: () => {},
|
||||
});
|
||||
|
||||
assert.equal(handled, true);
|
||||
assert.deepEqual(forwarded, [
|
||||
[
|
||||
'--stats',
|
||||
'--stats-response-path',
|
||||
'/tmp/subminer-stats-test/response.json',
|
||||
'--stats-cleanup',
|
||||
'--stats-cleanup-vocab',
|
||||
],
|
||||
]);
|
||||
});
|
||||
|
||||
test('stats command throws when stats response reports an error', async () => {
|
||||
const context = createContext();
|
||||
context.args.stats = true;
|
||||
|
||||
await assert.rejects(
|
||||
async () => {
|
||||
await runStatsCommand(context, {
|
||||
createTempDir: () => '/tmp/subminer-stats-test',
|
||||
joinPath: (...parts) => parts.join('/'),
|
||||
runAppCommandAttached: async () => 0,
|
||||
waitForStatsResponse: async () => ({
|
||||
ok: false,
|
||||
error: 'Immersion tracking is disabled in config.',
|
||||
}),
|
||||
removeDir: () => {},
|
||||
});
|
||||
},
|
||||
/Immersion tracking is disabled in config\./,
|
||||
);
|
||||
});
|
||||
|
||||
test('stats command fails if attached app exits before startup response', async () => {
|
||||
const context = createContext();
|
||||
context.args.stats = true;
|
||||
|
||||
await assert.rejects(
|
||||
async () => {
|
||||
await runStatsCommand(context, {
|
||||
createTempDir: () => '/tmp/subminer-stats-test',
|
||||
joinPath: (...parts) => parts.join('/'),
|
||||
runAppCommandAttached: async () => 2,
|
||||
waitForStatsResponse: async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 25));
|
||||
return { ok: true, url: 'http://127.0.0.1:5175' };
|
||||
},
|
||||
removeDir: () => {},
|
||||
});
|
||||
},
|
||||
/Stats app exited before startup response \(status 2\)\./,
|
||||
);
|
||||
});
|
||||
|
||||
108
launcher/commands/stats-command.ts
Normal file
108
launcher/commands/stats-command.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
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) => Promise<StatsCommandResponse>;
|
||||
removeDir: (targetPath: string) => void;
|
||||
};
|
||||
|
||||
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) => {
|
||||
const deadline = Date.now() + 8000;
|
||||
while (Date.now() < deadline) {
|
||||
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 });
|
||||
},
|
||||
};
|
||||
|
||||
export async function runStatsCommand(
|
||||
context: LauncherCommandContext,
|
||||
deps: StatsCommandDeps = defaultDeps,
|
||||
): Promise<boolean> {
|
||||
const { args, appPath } = context;
|
||||
if (!args.stats || !appPath) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const tempDir = deps.createTempDir('subminer-stats-');
|
||||
const responsePath = deps.joinPath(tempDir, 'response.json');
|
||||
|
||||
try {
|
||||
const forwarded = ['--stats', '--stats-response-path', responsePath];
|
||||
if (args.statsCleanup) {
|
||||
forwarded.push('--stats-cleanup');
|
||||
}
|
||||
if (args.statsCleanupVocab) {
|
||||
forwarded.push('--stats-cleanup-vocab');
|
||||
}
|
||||
if (args.logLevel !== 'info') {
|
||||
forwarded.push('--log-level', args.logLevel);
|
||||
}
|
||||
const attachedExitPromise = deps.runAppCommandAttached(
|
||||
appPath,
|
||||
forwarded,
|
||||
args.logLevel,
|
||||
'stats',
|
||||
);
|
||||
const startupResult = await Promise.race([
|
||||
deps.waitForStatsResponse(responsePath).then((response) => ({ kind: 'response' as const, response })),
|
||||
attachedExitPromise.then((status) => ({ kind: 'exit' as const, status })),
|
||||
]);
|
||||
if (startupResult.kind === 'exit') {
|
||||
if (startupResult.status !== 0) {
|
||||
throw new Error(`Stats app exited before startup response (status ${startupResult.status}).`);
|
||||
}
|
||||
const response = await deps.waitForStatsResponse(responsePath);
|
||||
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;
|
||||
} finally {
|
||||
deps.removeDir(tempDir);
|
||||
}
|
||||
}
|
||||
@@ -122,6 +122,9 @@ export function createDefaultArgs(launcherConfig: LauncherYoutubeSubgenConfig):
|
||||
jellyfinPlay: false,
|
||||
jellyfinDiscovery: false,
|
||||
dictionary: false,
|
||||
stats: false,
|
||||
statsCleanup: false,
|
||||
statsCleanupVocab: false,
|
||||
doctor: false,
|
||||
configPath: false,
|
||||
configShow: false,
|
||||
@@ -188,6 +191,9 @@ export function applyRootOptionsToArgs(
|
||||
|
||||
export function applyInvocationsToArgs(parsed: Args, invocations: CliInvocations): void {
|
||||
if (invocations.dictionaryTriggered) parsed.dictionary = true;
|
||||
if (invocations.statsTriggered) parsed.stats = true;
|
||||
if (invocations.statsCleanup) parsed.statsCleanup = true;
|
||||
if (invocations.statsCleanupVocab) parsed.statsCleanupVocab = true;
|
||||
if (invocations.dictionaryTarget) {
|
||||
parsed.dictionaryTarget = parseDictionaryTarget(invocations.dictionaryTarget);
|
||||
}
|
||||
@@ -256,6 +262,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,6 +40,10 @@ export interface CliInvocations {
|
||||
dictionaryTriggered: boolean;
|
||||
dictionaryTarget: string | null;
|
||||
dictionaryLogLevel: string | null;
|
||||
statsTriggered: boolean;
|
||||
statsCleanup: boolean;
|
||||
statsCleanupVocab: boolean;
|
||||
statsLogLevel: string | null;
|
||||
doctorTriggered: boolean;
|
||||
doctorLogLevel: string | null;
|
||||
texthookerTriggered: boolean;
|
||||
@@ -87,6 +91,7 @@ function getTopLevelCommand(argv: string[]): { name: string; index: number } | n
|
||||
'mpv',
|
||||
'dictionary',
|
||||
'dict',
|
||||
'stats',
|
||||
'texthooker',
|
||||
'app',
|
||||
'bin',
|
||||
@@ -137,6 +142,10 @@ export function parseCliPrograms(
|
||||
let dictionaryTriggered = false;
|
||||
let dictionaryTarget: string | null = null;
|
||||
let dictionaryLogLevel: string | null = null;
|
||||
let statsTriggered = false;
|
||||
let statsCleanup = false;
|
||||
let statsCleanupVocab = false;
|
||||
let statsLogLevel: string | null = null;
|
||||
let doctorLogLevel: string | null = null;
|
||||
let texthookerLogLevel: string | null = null;
|
||||
let doctorTriggered = false;
|
||||
@@ -241,6 +250,21 @@ export function parseCliPrograms(
|
||||
dictionaryLogLevel = typeof options.logLevel === 'string' ? options.logLevel : null;
|
||||
});
|
||||
|
||||
commandProgram
|
||||
.command('stats')
|
||||
.description('Launch the local immersion stats dashboard')
|
||||
.argument('[action]', 'cleanup')
|
||||
.option('-v, --vocab', 'Clean vocabulary rows in the stats database')
|
||||
.option('--log-level <level>', 'Log level')
|
||||
.action((action: string | undefined, options: Record<string, unknown>) => {
|
||||
statsTriggered = true;
|
||||
if ((action || '').toLowerCase() === 'cleanup') {
|
||||
statsCleanup = true;
|
||||
statsCleanupVocab = options.vocab === true;
|
||||
}
|
||||
statsLogLevel = typeof options.logLevel === 'string' ? options.logLevel : null;
|
||||
});
|
||||
|
||||
commandProgram
|
||||
.command('doctor')
|
||||
.description('Run dependency and environment checks')
|
||||
@@ -319,6 +343,10 @@ export function parseCliPrograms(
|
||||
dictionaryTriggered,
|
||||
dictionaryTarget,
|
||||
dictionaryLogLevel,
|
||||
statsTriggered,
|
||||
statsCleanup,
|
||||
statsCleanupVocab,
|
||||
statsLogLevel,
|
||||
doctorTriggered,
|
||||
doctorLogLevel,
|
||||
texthookerTriggered,
|
||||
|
||||
@@ -335,6 +335,55 @@ 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('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;
|
||||
}
|
||||
|
||||
@@ -133,6 +133,7 @@ function makeArgs(overrides: Partial<Args> = {}): Args {
|
||||
jellyfinPlay: false,
|
||||
jellyfinDiscovery: false,
|
||||
dictionary: false,
|
||||
stats: false,
|
||||
doctor: false,
|
||||
configPath: false,
|
||||
configShow: false,
|
||||
|
||||
@@ -756,6 +756,37 @@ 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) => {
|
||||
resolve(code ?? 0);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export function runAppCommandWithInheritLogged(
|
||||
appPath: string,
|
||||
appArgs: string[],
|
||||
@@ -786,10 +817,24 @@ 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,
|
||||
|
||||
@@ -58,3 +58,26 @@ 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 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);
|
||||
});
|
||||
|
||||
@@ -111,6 +111,9 @@ export interface Args {
|
||||
jellyfinPlay: boolean;
|
||||
jellyfinDiscovery: boolean;
|
||||
dictionary: boolean;
|
||||
stats: boolean;
|
||||
statsCleanup?: boolean;
|
||||
statsCleanupVocab?: boolean;
|
||||
dictionaryTarget?: string;
|
||||
doctor: boolean;
|
||||
configPath: boolean;
|
||||
|
||||
Reference in New Issue
Block a user