feat(stats): wire stats server, overlay, and CLI into main process

- Stats server auto-start on immersion tracker init
- Stats overlay toggle via keybinding and IPC
- Stats CLI command (subminer stats) with cleanup mode
- mpv plugin menu integration for stats toggle
- CLI args for --stats, --stats-cleanup, --stats-response-path
This commit is contained in:
2026-03-14 22:14:32 -07:00
parent a7c294a90c
commit 6d8650994f
37 changed files with 374 additions and 23 deletions

View File

@@ -303,6 +303,8 @@ import {
upsertYomitanDictionarySettings,
updateLastCardFromClipboard as updateLastCardFromClipboardCore,
} from './core/services';
import { startStatsServer } from './core/services/stats-server';
import { registerStatsOverlayToggle, destroyStatsWindow } from './core/services/stats-window.js';
import {
createFirstRunSetupService,
shouldAutoOpenFirstRunSetup,
@@ -325,11 +327,18 @@ import {
} from './main/runtime/windows-mpv-shortcuts';
import { createImmersionTrackerStartupHandler } from './main/runtime/immersion-startup';
import { createBuildImmersionTrackerStartupMainDepsHandler } from './main/runtime/immersion-startup-main-deps';
import {
createRunStatsCliCommandHandler,
writeStatsCliCommandResponse,
} from './main/runtime/stats-cli-command';
import { resolveLegacyVocabularyPosFromTokens } from './core/services/immersion-tracker/legacy-vocabulary-pos';
import { createAnilistUpdateQueue } from './core/services/anilist/anilist-update-queue';
import {
guessAnilistMediaInfo,
updateAnilistPostWatchProgress,
} from './core/services/anilist/anilist-updater';
import { createCoverArtFetcher } from './core/services/anilist/cover-art-fetcher';
import { createAnilistRateLimiter } from './core/services/anilist/rate-limiter';
import { createJellyfinTokenStore } from './core/services/jellyfin-token-store';
import { applyRuntimeOptionResultRuntime } from './core/services/runtime-options-ipc';
import { createAnilistTokenStore } from './core/services/anilist/anilist-token-store';
@@ -614,6 +623,11 @@ app.setPath('userData', USER_DATA_PATH);
let forceQuitTimer: ReturnType<typeof setTimeout> | null = null;
function requestAppQuit(): void {
destroyStatsWindow();
if (appState.statsServer) {
appState.statsServer.close();
appState.statsServer = null;
}
if (!forceQuitTimer) {
forceQuitTimer = setTimeout(() => {
logger.warn('App quit timed out; forcing process exit.');
@@ -917,6 +931,10 @@ const buildMainSubsyncRuntimeMainDepsHandler = createBuildMainSubsyncRuntimeMain
const immersionMediaRuntime = createImmersionMediaRuntime(
buildImmersionMediaRuntimeMainDepsHandler(),
);
const statsCoverArtFetcher = createCoverArtFetcher(
createAnilistRateLimiter(),
createLogger('main:stats-cover-art'),
);
const anilistStateRuntime = createAnilistStateRuntime(buildAnilistStateRuntimeMainDepsHandler());
const configDerivedRuntime = createConfigDerivedRuntime(buildConfigDerivedRuntimeMainDepsHandler());
const subsyncRuntime = createMainSubsyncRuntime(buildMainSubsyncRuntimeMainDepsHandler());
@@ -1025,11 +1043,11 @@ function maybeSignalPluginAutoplayReady(
}
let appTray: Tray | null = null;
let tokenizeSubtitleDeferred: ((text: string) => Promise<SubtitleData>) | null = null;
const buildSubtitleProcessingControllerMainDepsHandler =
createBuildSubtitleProcessingControllerMainDepsHandler({
tokenizeSubtitle: async (text: string) => {
return await tokenizeSubtitle(text);
},
tokenizeSubtitle: async (text: string) =>
tokenizeSubtitleDeferred ? await tokenizeSubtitleDeferred(text) : { text, tokens: null },
emitSubtitle: (payload) => {
appState.currentSubtitleData = payload;
broadcastToOverlayWindows('subtitle:set', payload);
@@ -2400,16 +2418,82 @@ const {
});
registerProtocolUrlHandlersHandler();
const statsDistPath = path.join(__dirname, '..', 'stats', 'dist');
const statsPreloadPath = path.join(__dirname, 'preload-stats.js');
const ensureStatsServerStarted = (): string => {
const tracker = appState.immersionTracker;
if (!tracker) {
throw new Error('Immersion tracker failed to initialize.');
}
if (!appState.statsServer) {
appState.statsServer = startStatsServer({
port: getResolvedConfig().stats.serverPort,
staticDir: statsDistPath,
tracker,
});
}
return `http://127.0.0.1:${getResolvedConfig().stats.serverPort}`;
};
const resolveLegacyVocabularyPos = async (row: {
headword: string;
word: string;
reading: string | null;
}) => {
const tokenizer = appState.mecabTokenizer;
if (!tokenizer) {
return null;
}
const lookupTexts = [...new Set([row.headword, row.word, row.reading ?? ''])]
.map((value) => value.trim())
.filter((value) => value.length > 0);
for (const lookupText of lookupTexts) {
const tokens = await tokenizer.tokenize(lookupText);
const resolved = resolveLegacyVocabularyPosFromTokens(lookupText, tokens);
if (resolved) {
return resolved;
}
}
return null;
};
const immersionTrackerStartupMainDeps: Parameters<
typeof createBuildImmersionTrackerStartupMainDepsHandler
>[0] = {
getResolvedConfig: () => getResolvedConfig(),
getConfiguredDbPath: () => immersionMediaRuntime.getConfiguredDbPath(),
createTrackerService: (params) => new ImmersionTrackerService(params),
createTrackerService: (params) =>
new ImmersionTrackerService({
...params,
resolveLegacyVocabularyPos,
}),
setTracker: (tracker) => {
appState.immersionTracker = tracker as ImmersionTrackerService | null;
appState.immersionTracker?.setCoverArtFetcher(statsCoverArtFetcher);
if (tracker) {
// Start HTTP stats server (once)
if (!appState.statsServer) {
const config = getResolvedConfig();
if (config.stats.autoStartServer) {
ensureStatsServerStarted();
}
}
// Register stats overlay toggle IPC handler (idempotent)
registerStatsOverlayToggle({
staticDir: statsDistPath,
preloadPath: statsPreloadPath,
getToggleKey: () => getResolvedConfig().stats.toggleKey,
resolveBounds: () => getCurrentOverlayGeometry(),
});
}
},
getMpvClient: () => appState.mpvClient,
shouldAutoConnectMpv: () => !appState.statsStartupInProgress,
seedTrackerFromCurrentMedia: () => {
void immersionMediaRuntime.seedFromCurrentMedia();
},
@@ -2420,6 +2504,10 @@ const immersionTrackerStartupMainDeps: Parameters<
const createImmersionTrackerStartup = createImmersionTrackerStartupHandler(
createBuildImmersionTrackerStartupMainDepsHandler(immersionTrackerStartupMainDeps)(),
);
const recordTrackedCardsMined = (count: number, noteIds?: number[]): void => {
ensureImmersionTrackerStarted();
appState.immersionTracker?.recordCardsMined(count, noteIds);
};
let hasAttemptedImmersionTrackerStartup = false;
const ensureImmersionTrackerStarted = (): void => {
if (hasAttemptedImmersionTrackerStartup || appState.immersionTracker) {
@@ -2429,6 +2517,34 @@ const ensureImmersionTrackerStarted = (): void => {
createImmersionTrackerStartup();
};
const runStatsCliCommand = createRunStatsCliCommandHandler({
getResolvedConfig: () => getResolvedConfig(),
ensureImmersionTrackerStarted: () => {
appState.statsStartupInProgress = true;
try {
ensureImmersionTrackerStarted();
} finally {
appState.statsStartupInProgress = false;
}
},
ensureVocabularyCleanupTokenizerReady: async () => {
await createMecabTokenizerAndCheck();
},
getImmersionTracker: () => appState.immersionTracker,
ensureStatsServerStarted: () => ensureStatsServerStarted(),
openExternal: (url: string) => shell.openExternal(url),
writeResponse: (responsePath, payload) => {
writeStatsCliCommandResponse(responsePath, payload);
},
exitAppWithCode: (code) => {
process.exitCode = code;
requestAppQuit();
},
logInfo: (message) => logger.info(message),
logWarn: (message, error) => logger.warn(message, error),
logError: (message, error) => logger.error(message, error),
});
const { appReadyRuntimeRunner } = composeAppReadyRuntime({
reloadConfigMainDeps: {
reloadConfigStrict: () => configService.reloadConfigStrict(),
@@ -2576,10 +2692,13 @@ const { appReadyRuntimeRunner } = composeAppReadyRuntime({
setVisibleOverlayVisible: (visible: boolean) => setVisibleOverlayVisible(visible),
initializeOverlayRuntime: () => initializeOverlayRuntime(),
handleInitialArgs: () => handleInitialArgs(),
shouldUseMinimalStartup: () =>
Boolean(appState.initialArgs?.stats && appState.initialArgs?.statsCleanup),
shouldSkipHeavyStartup: () =>
Boolean(
appState.initialArgs &&
(shouldRunSettingsOnlyStartup(appState.initialArgs) ||
appState.initialArgs.stats ||
appState.initialArgs.dictionary ||
appState.initialArgs.setup),
),
@@ -2728,6 +2847,8 @@ const {
ensureImmersionTrackerInitialized: () => {
ensureImmersionTrackerStarted();
},
tokenizeSubtitleForImmersion: async (text): Promise<SubtitleData | null> =>
tokenizeSubtitleDeferred ? await tokenizeSubtitleDeferred(text) : null,
updateCurrentMediaPath: (path) => {
autoPlayReadySignalMediaPath = null;
currentMediaTokenizationGate.updateCurrentMediaPath(path);
@@ -2939,6 +3060,7 @@ const {
},
},
});
tokenizeSubtitleDeferred = tokenizeSubtitle;
function createMpvClientRuntimeService(): MpvIpcClient {
return createMpvClientRuntimeServiceHandler() as MpvIpcClient;
@@ -3113,6 +3235,7 @@ function destroyTray(): void {
function initializeOverlayRuntime(): void {
initializeOverlayRuntimeHandler();
appState.ankiIntegration?.setRecordCardsMinedCallback(recordTrackedCardsMined);
syncOverlayMpvSubtitleSuppression();
}
@@ -3283,9 +3406,9 @@ const buildMineSentenceCardMainDepsHandler = createBuildMineSentenceCardMainDeps
getMpvClient: () => appState.mpvClient,
showMpvOsd: (text) => showMpvOsd(text),
mineSentenceCardCore,
recordCardsMined: (count) => {
recordCardsMined: (count, noteIds) => {
ensureImmersionTrackerStarted();
appState.immersionTracker?.recordCardsMined(count);
appState.immersionTracker?.recordCardsMined(count, noteIds);
},
});
const mineSentenceCardHandler = createMineSentenceCardHandler(
@@ -3455,6 +3578,7 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({
getMecabTokenizer: () => appState.mecabTokenizer,
getKeybindings: () => appState.keybindings,
getConfiguredShortcuts: () => getConfiguredShortcuts(),
getStatsToggleKey: () => getResolvedConfig().stats.toggleKey,
getControllerConfig: () => getResolvedConfig().controller,
saveControllerPreference: ({ preferredGamepadId, preferredGamepadLabel }) => {
configService.patchRawConfig({
@@ -3477,6 +3601,7 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({
getAnilistQueueStatus: () => anilistStateRuntime.getQueueStatusSnapshot(),
retryAnilistQueueNow: () => processNextAnilistRetryUpdate(),
appendClipboardVideoToQueue: () => appendClipboardVideoToQueue(),
getImmersionTracker: () => appState.immersionTracker,
},
ankiJimakuDeps: createAnkiJimakuIpcRuntimeServiceDeps({
patchAnkiConnectEnabled: (enabled: boolean) => {
@@ -3489,6 +3614,7 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({
getAnkiIntegration: () => appState.ankiIntegration,
setAnkiIntegration: (integration: AnkiIntegration | null) => {
appState.ankiIntegration = integration;
appState.ankiIntegration?.setRecordCardsMinedCallback(recordTrackedCardsMined);
},
getKnownWordCacheStatePath: () => path.join(USER_DATA_PATH, 'known-words-cache.json'),
showDesktopNotification,
@@ -3551,6 +3677,8 @@ const createCliCommandContextHandler = createCliCommandContextFactory({
return await characterDictionaryRuntime.generateForCurrentMedia(targetPath);
},
runJellyfinCommand: (argsFromCommand: CliArgs) => runJellyfinCommand(argsFromCommand),
runStatsCommand: (argsFromCommand: CliArgs, source: CliCommandSource) =>
runStatsCliCommand(argsFromCommand, source),
openYomitanSettings: () => openYomitanSettings(),
cycleSecondarySubMode: () => handleCycleSecondarySubMode(),
openRuntimeOptionsPalette: () => openRuntimeOptionsPalette(),