Switch known-word cache to incremental sync and doctor refresh

- Load persisted known-word cache on startup; reconcile adds/deletes/edits on timed sync
- Add `knownWords.addMinedWordsImmediately` (default `true`) for immediate mined-word updates
- Route full rebuild to explicit `subminer doctor --refresh-known-words` and expand tests/docs
This commit is contained in:
2026-03-19 19:29:58 -07:00
parent 72d78ba1ca
commit 20f53c0b70
50 changed files with 1130 additions and 119 deletions

View File

@@ -77,11 +77,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;

View File

@@ -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;
}

View File

@@ -129,6 +129,7 @@ export function createDefaultArgs(launcherConfig: LauncherYoutubeSubgenConfig):
statsCleanupVocab: false,
statsCleanupLifetime: false,
doctor: false,
doctorRefreshKnownWords: false,
configPath: false,
configShow: false,
mpvIdle: false,
@@ -206,6 +207,7 @@ export function applyInvocationsToArgs(parsed: Args, invocations: CliInvocations
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) {

View File

@@ -49,6 +49,7 @@ export interface CliInvocations {
statsLogLevel: string | null;
doctorTriggered: boolean;
doctorLogLevel: string | null;
doctorRefreshKnownWords: boolean;
texthookerTriggered: boolean;
texthookerLogLevel: string | null;
}
@@ -156,6 +157,7 @@ export function parseCliPrograms(
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;
@@ -304,10 +306,12 @@ export function parseCliPrograms(
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
@@ -388,6 +392,7 @@ export function parseCliPrograms(
statsLogLevel,
doctorTriggered,
doctorLogLevel,
doctorRefreshKnownWords,
texthookerTriggered,
texthookerLogLevel,
},

View File

@@ -178,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');

View File

@@ -40,6 +40,26 @@ 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('waitForUnixSocketReady returns false when socket never appears', async () => {
const { dir, socketPath } = createTempSocketPath();
try {
@@ -137,6 +157,7 @@ function makeArgs(overrides: Partial<Args> = {}): Args {
dictionary: false,
stats: false,
doctor: false,
doctorRefreshKnownWords: false,
configPath: false,
configShow: false,
mpvIdle: false,

View File

@@ -661,7 +661,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;
@@ -688,7 +688,10 @@ 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(),
});
process.exit(result.status ?? 0);
}
@@ -702,7 +705,10 @@ 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' });
spawnSync(state.appPath, stopArgs, {
stdio: 'ignore',
env: buildAppEnv(),
});
if (state.overlayProc && !state.overlayProc.killed) {
try {
@@ -763,6 +769,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

View File

@@ -127,3 +127,10 @@ test('parseArgs maps stats rebuild action to cleanup lifetime mode', () => {
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);
});

View File

@@ -119,6 +119,7 @@ export interface Args {
statsCleanupLifetime?: boolean;
dictionaryTarget?: string;
doctor: boolean;
doctorRefreshKnownWords: boolean;
configPath: boolean;
configShow: boolean;
mpvIdle: boolean;