mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-03-20 12:11:28 -07:00
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:
140
src/main.ts
140
src/main.ts
@@ -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(),
|
||||
|
||||
Reference in New Issue
Block a user