mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-06-12 15:13:32 -07:00
refactor(main): extract stats server runtime from main.ts
This commit is contained in:
+44
-194
@@ -351,10 +351,8 @@ import {
|
|||||||
runStartupBootstrapRuntime,
|
runStartupBootstrapRuntime,
|
||||||
saveJellyfinSubtitleDelay,
|
saveJellyfinSubtitleDelay,
|
||||||
saveSubtitlePosition as saveSubtitlePositionCore,
|
saveSubtitlePosition as saveSubtitlePositionCore,
|
||||||
addYomitanNoteViaSearch,
|
|
||||||
clearYomitanParserCachesForWindow,
|
clearYomitanParserCachesForWindow,
|
||||||
getYomitanCurrentAnkiDeckName as getYomitanCurrentAnkiDeckNameCore,
|
getYomitanCurrentAnkiDeckName as getYomitanCurrentAnkiDeckNameCore,
|
||||||
syncYomitanDefaultAnkiServer as syncYomitanDefaultAnkiServerCore,
|
|
||||||
sendMpvCommandRuntime,
|
sendMpvCommandRuntime,
|
||||||
setMpvSubVisibilityRuntime,
|
setMpvSubVisibilityRuntime,
|
||||||
setOverlayDebugVisualizationEnabledRuntime,
|
setOverlayDebugVisualizationEnabledRuntime,
|
||||||
@@ -375,7 +373,6 @@ import { hasHyprlandWindowPlacementBoundsMismatch } from './core/services/hyprla
|
|||||||
import { normalizeOverlayWindowBoundsForPlatform } from './core/services/overlay-window-bounds';
|
import { normalizeOverlayWindowBoundsForPlatform } from './core/services/overlay-window-bounds';
|
||||||
import { resolveYoutubePlaybackUrl } from './core/services/youtube/playback-resolve';
|
import { resolveYoutubePlaybackUrl } from './core/services/youtube/playback-resolve';
|
||||||
import { probeYoutubeTracks } from './core/services/youtube/track-probe';
|
import { probeYoutubeTracks } from './core/services/youtube/track-probe';
|
||||||
import { startStatsServer } from './core/services/stats-server';
|
|
||||||
import {
|
import {
|
||||||
destroyStatsWindow,
|
destroyStatsWindow,
|
||||||
promoteStatsOverlayAbovePlayback,
|
promoteStatsOverlayAbovePlayback,
|
||||||
@@ -453,14 +450,7 @@ import {
|
|||||||
createRunStatsCliCommandHandler,
|
createRunStatsCliCommandHandler,
|
||||||
writeStatsCliCommandResponse,
|
writeStatsCliCommandResponse,
|
||||||
} from './main/runtime/stats-cli-command';
|
} from './main/runtime/stats-cli-command';
|
||||||
import {
|
import { createStatsServerRuntime } from './main/runtime/stats-server-runtime';
|
||||||
isBackgroundStatsServerProcessAlive,
|
|
||||||
readBackgroundStatsServerState,
|
|
||||||
removeBackgroundStatsServerState,
|
|
||||||
resolveBackgroundStatsServerUrl,
|
|
||||||
writeBackgroundStatsServerState,
|
|
||||||
} from './main/runtime/stats-daemon';
|
|
||||||
import { createEnsureStatsServerUrlHandler } from './main/runtime/stats-server-routing';
|
|
||||||
import { resolveLegacyVocabularyPosFromTokens } from './core/services/immersion-tracker/legacy-vocabulary-pos';
|
import { resolveLegacyVocabularyPosFromTokens } from './core/services/immersion-tracker/legacy-vocabulary-pos';
|
||||||
import { createAnilistUpdateQueue } from './core/services/anilist/anilist-update-queue';
|
import { createAnilistUpdateQueue } from './core/services/anilist/anilist-update-queue';
|
||||||
import {
|
import {
|
||||||
@@ -594,7 +584,6 @@ import {
|
|||||||
import { createYomitanProfilePolicy } from './main/runtime/yomitan-profile-policy';
|
import { createYomitanProfilePolicy } from './main/runtime/yomitan-profile-policy';
|
||||||
import { reloadOverlayWindowsForYomitanContentScripts } from './main/runtime/yomitan-extension-overlay-reload';
|
import { reloadOverlayWindowsForYomitanContentScripts } from './main/runtime/yomitan-extension-overlay-reload';
|
||||||
import { formatSkippedYomitanWriteAction } from './main/runtime/yomitan-read-only-log';
|
import { formatSkippedYomitanWriteAction } from './main/runtime/yomitan-read-only-log';
|
||||||
import { shouldForceOverrideYomitanAnkiServer } from './main/runtime/yomitan-anki-server';
|
|
||||||
import { createYomitanAnkiServerSyncRuntime } from './main/runtime/yomitan-anki-server-sync';
|
import { createYomitanAnkiServerSyncRuntime } from './main/runtime/yomitan-anki-server-sync';
|
||||||
import {
|
import {
|
||||||
type AnilistMediaGuessRuntimeState,
|
type AnilistMediaGuessRuntimeState,
|
||||||
@@ -1010,45 +999,49 @@ const reportFatalError = createFatalErrorReporter({
|
|||||||
});
|
});
|
||||||
|
|
||||||
let forceQuitTimer: ReturnType<typeof setTimeout> | null = null;
|
let forceQuitTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
let statsServer: ReturnType<typeof startStatsServer> | null = null;
|
const statsDistPath = path.join(__dirname, '..', 'stats', 'dist');
|
||||||
const statsDaemonStatePath = path.join(USER_DATA_PATH, 'stats-daemon.json');
|
const statsPreloadPath = path.join(__dirname, 'preload-stats.js');
|
||||||
|
const statsServerRuntime = createStatsServerRuntime({
|
||||||
function readLiveBackgroundStatsDaemonState(): {
|
userDataPath: USER_DATA_PATH,
|
||||||
pid: number;
|
statsDistPath,
|
||||||
port: number;
|
getResolvedConfig: () => getResolvedConfig(),
|
||||||
startedAtMs: number;
|
getImmersionTracker: () => appState.immersionTracker,
|
||||||
} | null {
|
setAppStateStatsServer: (server) => {
|
||||||
const state = readBackgroundStatsServerState(statsDaemonStatePath);
|
appState.statsServer = server;
|
||||||
if (!state) {
|
},
|
||||||
removeBackgroundStatsServerState(statsDaemonStatePath);
|
getMpvSocketPath: () => appState.mpvSocketPath,
|
||||||
return null;
|
getYomitanExt: () => appState.yomitanExt,
|
||||||
}
|
getYomitanSession: () => appState.yomitanSession,
|
||||||
if (state.pid === process.pid && !statsServer) {
|
getYomitanParserWindow: () => appState.yomitanParserWindow,
|
||||||
removeBackgroundStatsServerState(statsDaemonStatePath);
|
setYomitanParserWindow: (w) => {
|
||||||
return null;
|
appState.yomitanParserWindow = w;
|
||||||
}
|
},
|
||||||
if (!isBackgroundStatsServerProcessAlive(state.pid)) {
|
getYomitanParserReadyPromise: () => appState.yomitanParserReadyPromise,
|
||||||
removeBackgroundStatsServerState(statsDaemonStatePath);
|
setYomitanParserReadyPromise: (p) => {
|
||||||
return null;
|
appState.yomitanParserReadyPromise = p;
|
||||||
}
|
},
|
||||||
return state;
|
getYomitanParserInitPromise: () => appState.yomitanParserInitPromise,
|
||||||
}
|
setYomitanParserInitPromise: (p) => {
|
||||||
|
appState.yomitanParserInitPromise = p;
|
||||||
function clearOwnedBackgroundStatsDaemonState(): void {
|
},
|
||||||
const state = readBackgroundStatsServerState(statsDaemonStatePath);
|
getYomitanAnkiDeckName: () => getCurrentYomitanAnkiDeckNameForRuntime(),
|
||||||
if (state?.pid === process.pid) {
|
getAnilistRateLimiter: () => anilistRateLimiter,
|
||||||
removeBackgroundStatsServerState(statsDaemonStatePath);
|
resolveAnkiNoteId: (noteId) => appState.ankiIntegration?.resolveCurrentNoteId(noteId) ?? noteId,
|
||||||
}
|
trackDuplicateNoteIdsForNote: (noteId, duplicateNoteIds) => {
|
||||||
}
|
appState.ankiIntegration?.trackDuplicateNoteIdsForNote(noteId, duplicateNoteIds);
|
||||||
|
},
|
||||||
function stopStatsServer(): void {
|
resolveSentenceSearchHeadwords: (term) => resolveSentenceSearchHeadwords(term),
|
||||||
if (!statsServer) {
|
ensureImmersionTrackerStarted: () => ensureImmersionTrackerStarted(),
|
||||||
return;
|
setStatsStartupInProgress: (inProgress) => {
|
||||||
}
|
appState.statsStartupInProgress = inProgress;
|
||||||
statsServer.close();
|
},
|
||||||
statsServer = null;
|
});
|
||||||
clearOwnedBackgroundStatsDaemonState();
|
const {
|
||||||
}
|
stopStatsServer,
|
||||||
|
ensureStatsServerStarted,
|
||||||
|
ensureBackgroundStatsServerStarted,
|
||||||
|
stopBackgroundStatsServer,
|
||||||
|
} = statsServerRuntime;
|
||||||
|
|
||||||
function requestAppQuit(): void {
|
function requestAppQuit(): void {
|
||||||
destroyYomitanSettingsWindow(appState.yomitanSettingsWindow);
|
destroyYomitanSettingsWindow(appState.yomitanSettingsWindow);
|
||||||
@@ -4618,149 +4611,6 @@ const {
|
|||||||
});
|
});
|
||||||
registerProtocolUrlHandlersHandler();
|
registerProtocolUrlHandlersHandler();
|
||||||
|
|
||||||
const statsDistPath = path.join(__dirname, '..', 'stats', 'dist');
|
|
||||||
const statsPreloadPath = path.join(__dirname, 'preload-stats.js');
|
|
||||||
|
|
||||||
const startLocalStatsServer = (): void => {
|
|
||||||
const tracker = appState.immersionTracker;
|
|
||||||
if (!tracker) {
|
|
||||||
throw new Error('Immersion tracker failed to initialize.');
|
|
||||||
}
|
|
||||||
if (!statsServer) {
|
|
||||||
const yomitanDeps = {
|
|
||||||
getYomitanExt: () => appState.yomitanExt,
|
|
||||||
getYomitanSession: () => appState.yomitanSession,
|
|
||||||
getYomitanParserWindow: () => appState.yomitanParserWindow,
|
|
||||||
setYomitanParserWindow: (w: BrowserWindow | null) => {
|
|
||||||
appState.yomitanParserWindow = w;
|
|
||||||
},
|
|
||||||
getYomitanParserReadyPromise: () => appState.yomitanParserReadyPromise,
|
|
||||||
setYomitanParserReadyPromise: (p: Promise<void> | null) => {
|
|
||||||
appState.yomitanParserReadyPromise = p;
|
|
||||||
},
|
|
||||||
getYomitanParserInitPromise: () => appState.yomitanParserInitPromise,
|
|
||||||
setYomitanParserInitPromise: (p: Promise<boolean> | null) => {
|
|
||||||
appState.yomitanParserInitPromise = p;
|
|
||||||
},
|
|
||||||
};
|
|
||||||
const yomitanLogger = createLogger('main:yomitan-stats');
|
|
||||||
statsServer = startStatsServer({
|
|
||||||
port: getResolvedConfig().stats.serverPort,
|
|
||||||
staticDir: statsDistPath,
|
|
||||||
tracker,
|
|
||||||
knownWordCachePath: path.join(USER_DATA_PATH, 'known-words-cache.json'),
|
|
||||||
mpvSocketPath: appState.mpvSocketPath,
|
|
||||||
getAnkiConnectConfig: () => getResolvedConfig().ankiConnect,
|
|
||||||
getYomitanAnkiDeckName: getCurrentYomitanAnkiDeckNameForRuntime,
|
|
||||||
getSecondarySubtitleLanguages: () => getResolvedConfig().secondarySub.secondarySubLanguages,
|
|
||||||
getStatsMiningAlassPath: () => getResolvedConfig().subsync.alass_path,
|
|
||||||
anilistRateLimiter,
|
|
||||||
resolveAnkiNoteId: (noteId: number) =>
|
|
||||||
appState.ankiIntegration?.resolveCurrentNoteId(noteId) ?? noteId,
|
|
||||||
resolveSentenceSearchHeadwords,
|
|
||||||
addYomitanNote: async (word: string) => {
|
|
||||||
const ankiConnectConfig = getResolvedConfig().ankiConnect;
|
|
||||||
const ankiUrl = ankiConnectConfig.url || 'http://127.0.0.1:8765';
|
|
||||||
await syncYomitanDefaultAnkiServerCore(ankiUrl, yomitanDeps, yomitanLogger, {
|
|
||||||
forceOverride: shouldForceOverrideYomitanAnkiServer(ankiConnectConfig),
|
|
||||||
deck: ankiConnectConfig.deck,
|
|
||||||
});
|
|
||||||
const result = await addYomitanNoteViaSearch(word, yomitanDeps, yomitanLogger);
|
|
||||||
if (result.noteId && result.duplicateNoteIds.length > 0) {
|
|
||||||
appState.ankiIntegration?.trackDuplicateNoteIdsForNote(
|
|
||||||
result.noteId,
|
|
||||||
result.duplicateNoteIds,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return result.noteId;
|
|
||||||
},
|
|
||||||
});
|
|
||||||
appState.statsServer = statsServer;
|
|
||||||
}
|
|
||||||
appState.statsServer = statsServer;
|
|
||||||
};
|
|
||||||
|
|
||||||
const ensureStatsServerStarted = createEnsureStatsServerUrlHandler({
|
|
||||||
currentPid: process.pid,
|
|
||||||
readBackgroundState: () => readBackgroundStatsServerState(statsDaemonStatePath),
|
|
||||||
removeBackgroundState: () => {
|
|
||||||
removeBackgroundStatsServerState(statsDaemonStatePath);
|
|
||||||
},
|
|
||||||
isProcessAlive: (pid) => isBackgroundStatsServerProcessAlive(pid),
|
|
||||||
hasLocalStatsServer: () => statsServer !== null,
|
|
||||||
startLocalStatsServer,
|
|
||||||
getConfiguredPort: () => getResolvedConfig().stats.serverPort,
|
|
||||||
});
|
|
||||||
|
|
||||||
const ensureBackgroundStatsServerStarted = (): {
|
|
||||||
url: string;
|
|
||||||
runningInCurrentProcess: boolean;
|
|
||||||
} => {
|
|
||||||
const liveDaemon = readLiveBackgroundStatsDaemonState();
|
|
||||||
if (liveDaemon && liveDaemon.pid !== process.pid) {
|
|
||||||
return {
|
|
||||||
url: resolveBackgroundStatsServerUrl(liveDaemon),
|
|
||||||
runningInCurrentProcess: false,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
appState.statsStartupInProgress = true;
|
|
||||||
try {
|
|
||||||
ensureImmersionTrackerStarted();
|
|
||||||
} finally {
|
|
||||||
appState.statsStartupInProgress = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
const port = getResolvedConfig().stats.serverPort;
|
|
||||||
const result = ensureStatsServerStarted();
|
|
||||||
if (result.source === 'local') {
|
|
||||||
writeBackgroundStatsServerState(statsDaemonStatePath, {
|
|
||||||
pid: process.pid,
|
|
||||||
port,
|
|
||||||
startedAtMs: Date.now(),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return { url: result.url, runningInCurrentProcess: result.source === 'local' };
|
|
||||||
};
|
|
||||||
|
|
||||||
const stopBackgroundStatsServer = async (): Promise<{ ok: boolean; stale: boolean }> => {
|
|
||||||
const state = readBackgroundStatsServerState(statsDaemonStatePath);
|
|
||||||
if (!state) {
|
|
||||||
removeBackgroundStatsServerState(statsDaemonStatePath);
|
|
||||||
return { ok: true, stale: true };
|
|
||||||
}
|
|
||||||
if (!isBackgroundStatsServerProcessAlive(state.pid)) {
|
|
||||||
removeBackgroundStatsServerState(statsDaemonStatePath);
|
|
||||||
return { ok: true, stale: true };
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
process.kill(state.pid, 'SIGTERM');
|
|
||||||
} catch (error) {
|
|
||||||
if ((error as NodeJS.ErrnoException)?.code === 'ESRCH') {
|
|
||||||
removeBackgroundStatsServerState(statsDaemonStatePath);
|
|
||||||
return { ok: true, stale: true };
|
|
||||||
}
|
|
||||||
if ((error as NodeJS.ErrnoException)?.code === 'EPERM') {
|
|
||||||
throw new Error(
|
|
||||||
`Insufficient permissions to stop background stats server (pid ${state.pid}).`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
|
|
||||||
const deadline = Date.now() + 2_000;
|
|
||||||
while (Date.now() < deadline) {
|
|
||||||
if (!isBackgroundStatsServerProcessAlive(state.pid)) {
|
|
||||||
removeBackgroundStatsServerState(statsDaemonStatePath);
|
|
||||||
return { ok: true, stale: false };
|
|
||||||
}
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
||||||
}
|
|
||||||
|
|
||||||
throw new Error('Timed out stopping background stats server.');
|
|
||||||
};
|
|
||||||
|
|
||||||
const resolveLegacyVocabularyPos = async (row: {
|
const resolveLegacyVocabularyPos = async (row: {
|
||||||
headword: string;
|
headword: string;
|
||||||
word: string;
|
word: string;
|
||||||
|
|||||||
@@ -7,6 +7,10 @@ function readMainSource(): string {
|
|||||||
return fs.readFileSync(path.join(process.cwd(), 'src/main.ts'), 'utf8');
|
return fs.readFileSync(path.join(process.cwd(), 'src/main.ts'), 'utf8');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function readSource(relPath: string): string {
|
||||||
|
return fs.readFileSync(path.join(process.cwd(), relPath), 'utf8');
|
||||||
|
}
|
||||||
|
|
||||||
test('manual watched session action starts immersion tracker before marking watched', () => {
|
test('manual watched session action starts immersion tracker before marking watched', () => {
|
||||||
const source = readMainSource();
|
const source = readMainSource();
|
||||||
const actionBlock = source.match(
|
const actionBlock = source.match(
|
||||||
@@ -346,18 +350,18 @@ test('warm tokenization release can signal readiness before the first subtitle a
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('stats server Yomitan note creation honors configured Anki server override policy', () => {
|
test('stats server Yomitan note creation honors configured Anki server override policy', () => {
|
||||||
const source = readMainSource();
|
const source = readSource('src/main/runtime/stats-server-runtime.ts');
|
||||||
const startStatsServerBlock = source.match(
|
const startStatsServerBlock = source.match(
|
||||||
/statsServer = startStatsServer\(\{(?<body>[\s\S]*?)\n \}\);/,
|
/statsServer = startStatsServer\(\{(?<body>[\s\S]*?)\n \}\);/,
|
||||||
)?.groups?.body;
|
)?.groups?.body;
|
||||||
const addYomitanNoteBlock = startStatsServerBlock?.match(
|
const addYomitanNoteBlock = startStatsServerBlock?.match(
|
||||||
/addYomitanNote:\s*async\s*\(word: string\)\s*=>\s*\{(?<body>[\s\S]*?)\n \},/,
|
/addYomitanNote:\s*async\s*\(word: string\)\s*=>\s*\{(?<body>[\s\S]*?)\n \},/,
|
||||||
)?.groups?.body;
|
)?.groups?.body;
|
||||||
|
|
||||||
assert.ok(addYomitanNoteBlock);
|
assert.ok(addYomitanNoteBlock);
|
||||||
assert.match(
|
assert.match(
|
||||||
addYomitanNoteBlock,
|
addYomitanNoteBlock,
|
||||||
/const ankiConnectConfig = getResolvedConfig\(\)\.ankiConnect;/,
|
/const ankiConnectConfig = deps\.getResolvedConfig\(\)\.ankiConnect;/,
|
||||||
);
|
);
|
||||||
assert.match(addYomitanNoteBlock, /shouldForceOverrideYomitanAnkiServer\(ankiConnectConfig\)/);
|
assert.match(addYomitanNoteBlock, /shouldForceOverrideYomitanAnkiServer\(ankiConnectConfig\)/);
|
||||||
assert.doesNotMatch(addYomitanNoteBlock, /forceOverride:\s*true/);
|
assert.doesNotMatch(addYomitanNoteBlock, /forceOverride:\s*true/);
|
||||||
|
|||||||
@@ -0,0 +1,239 @@
|
|||||||
|
import path from 'node:path';
|
||||||
|
import type { BrowserWindow } from 'electron';
|
||||||
|
import {
|
||||||
|
addYomitanNoteViaSearch,
|
||||||
|
syncYomitanDefaultAnkiServer as syncYomitanDefaultAnkiServerCore,
|
||||||
|
} from '../../core/services';
|
||||||
|
import { startStatsServer } from '../../core/services/stats-server';
|
||||||
|
import { createLogger } from '../../logger';
|
||||||
|
import type { ResolvedConfig } from '../../types/config';
|
||||||
|
import type { AppState } from '../state';
|
||||||
|
import {
|
||||||
|
isBackgroundStatsServerProcessAlive,
|
||||||
|
readBackgroundStatsServerState,
|
||||||
|
removeBackgroundStatsServerState,
|
||||||
|
resolveBackgroundStatsServerUrl,
|
||||||
|
writeBackgroundStatsServerState,
|
||||||
|
} from './stats-daemon';
|
||||||
|
import { createEnsureStatsServerUrlHandler } from './stats-server-routing';
|
||||||
|
import { shouldForceOverrideYomitanAnkiServer } from './yomitan-anki-server';
|
||||||
|
|
||||||
|
export interface StatsServerRuntimeDeps {
|
||||||
|
userDataPath: string;
|
||||||
|
statsDistPath: string;
|
||||||
|
getResolvedConfig: () => ResolvedConfig;
|
||||||
|
getImmersionTracker: () => AppState['immersionTracker'];
|
||||||
|
setAppStateStatsServer: (server: AppState['statsServer']) => void;
|
||||||
|
getMpvSocketPath: () => AppState['mpvSocketPath'];
|
||||||
|
getYomitanExt: () => AppState['yomitanExt'];
|
||||||
|
getYomitanSession: () => AppState['yomitanSession'];
|
||||||
|
getYomitanParserWindow: () => AppState['yomitanParserWindow'];
|
||||||
|
setYomitanParserWindow: (w: BrowserWindow | null) => void;
|
||||||
|
getYomitanParserReadyPromise: () => AppState['yomitanParserReadyPromise'];
|
||||||
|
setYomitanParserReadyPromise: (p: Promise<void> | null) => void;
|
||||||
|
getYomitanParserInitPromise: () => AppState['yomitanParserInitPromise'];
|
||||||
|
setYomitanParserInitPromise: (p: Promise<boolean> | null) => void;
|
||||||
|
getYomitanAnkiDeckName: () => Promise<string>;
|
||||||
|
getAnilistRateLimiter: () => NonNullable<
|
||||||
|
Parameters<typeof startStatsServer>[0]['anilistRateLimiter']
|
||||||
|
>;
|
||||||
|
resolveAnkiNoteId: (noteId: number) => number;
|
||||||
|
trackDuplicateNoteIdsForNote: (noteId: number, duplicateNoteIds: number[]) => void;
|
||||||
|
resolveSentenceSearchHeadwords: (term: string) => Promise<string[]>;
|
||||||
|
ensureImmersionTrackerStarted: () => void;
|
||||||
|
setStatsStartupInProgress: (inProgress: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createStatsServerRuntime(deps: StatsServerRuntimeDeps): {
|
||||||
|
stopStatsServer: () => void;
|
||||||
|
ensureStatsServerStarted: ReturnType<typeof createEnsureStatsServerUrlHandler>;
|
||||||
|
ensureBackgroundStatsServerStarted: () => {
|
||||||
|
url: string;
|
||||||
|
runningInCurrentProcess: boolean;
|
||||||
|
};
|
||||||
|
stopBackgroundStatsServer: () => Promise<{ ok: boolean; stale: boolean }>;
|
||||||
|
} {
|
||||||
|
let statsServer: ReturnType<typeof startStatsServer> | null = null;
|
||||||
|
const statsDaemonStatePath = path.join(deps.userDataPath, 'stats-daemon.json');
|
||||||
|
|
||||||
|
function readLiveBackgroundStatsDaemonState(): {
|
||||||
|
pid: number;
|
||||||
|
port: number;
|
||||||
|
startedAtMs: number;
|
||||||
|
} | null {
|
||||||
|
const state = readBackgroundStatsServerState(statsDaemonStatePath);
|
||||||
|
if (!state) {
|
||||||
|
removeBackgroundStatsServerState(statsDaemonStatePath);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (state.pid === process.pid && !statsServer) {
|
||||||
|
removeBackgroundStatsServerState(statsDaemonStatePath);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (!isBackgroundStatsServerProcessAlive(state.pid)) {
|
||||||
|
removeBackgroundStatsServerState(statsDaemonStatePath);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearOwnedBackgroundStatsDaemonState(): void {
|
||||||
|
const state = readBackgroundStatsServerState(statsDaemonStatePath);
|
||||||
|
if (state?.pid === process.pid) {
|
||||||
|
removeBackgroundStatsServerState(statsDaemonStatePath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopStatsServer(): void {
|
||||||
|
if (!statsServer) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
statsServer.close();
|
||||||
|
statsServer = null;
|
||||||
|
clearOwnedBackgroundStatsDaemonState();
|
||||||
|
}
|
||||||
|
|
||||||
|
const startLocalStatsServer = (): void => {
|
||||||
|
const tracker = deps.getImmersionTracker();
|
||||||
|
if (!tracker) {
|
||||||
|
throw new Error('Immersion tracker failed to initialize.');
|
||||||
|
}
|
||||||
|
if (!statsServer) {
|
||||||
|
const yomitanDeps = {
|
||||||
|
getYomitanExt: () => deps.getYomitanExt(),
|
||||||
|
getYomitanSession: () => deps.getYomitanSession(),
|
||||||
|
getYomitanParserWindow: () => deps.getYomitanParserWindow(),
|
||||||
|
setYomitanParserWindow: (w: BrowserWindow | null) => {
|
||||||
|
deps.setYomitanParserWindow(w);
|
||||||
|
},
|
||||||
|
getYomitanParserReadyPromise: () => deps.getYomitanParserReadyPromise(),
|
||||||
|
setYomitanParserReadyPromise: (p: Promise<void> | null) => {
|
||||||
|
deps.setYomitanParserReadyPromise(p);
|
||||||
|
},
|
||||||
|
getYomitanParserInitPromise: () => deps.getYomitanParserInitPromise(),
|
||||||
|
setYomitanParserInitPromise: (p: Promise<boolean> | null) => {
|
||||||
|
deps.setYomitanParserInitPromise(p);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const yomitanLogger = createLogger('main:yomitan-stats');
|
||||||
|
statsServer = startStatsServer({
|
||||||
|
port: deps.getResolvedConfig().stats.serverPort,
|
||||||
|
staticDir: deps.statsDistPath,
|
||||||
|
tracker,
|
||||||
|
knownWordCachePath: path.join(deps.userDataPath, 'known-words-cache.json'),
|
||||||
|
mpvSocketPath: deps.getMpvSocketPath(),
|
||||||
|
getAnkiConnectConfig: () => deps.getResolvedConfig().ankiConnect,
|
||||||
|
getYomitanAnkiDeckName: deps.getYomitanAnkiDeckName,
|
||||||
|
getSecondarySubtitleLanguages: () =>
|
||||||
|
deps.getResolvedConfig().secondarySub.secondarySubLanguages,
|
||||||
|
getStatsMiningAlassPath: () => deps.getResolvedConfig().subsync.alass_path,
|
||||||
|
anilistRateLimiter: deps.getAnilistRateLimiter(),
|
||||||
|
resolveAnkiNoteId: (noteId: number) => deps.resolveAnkiNoteId(noteId),
|
||||||
|
resolveSentenceSearchHeadwords: (term: string) => deps.resolveSentenceSearchHeadwords(term),
|
||||||
|
addYomitanNote: async (word: string) => {
|
||||||
|
const ankiConnectConfig = deps.getResolvedConfig().ankiConnect;
|
||||||
|
const ankiUrl = ankiConnectConfig.url || 'http://127.0.0.1:8765';
|
||||||
|
await syncYomitanDefaultAnkiServerCore(ankiUrl, yomitanDeps, yomitanLogger, {
|
||||||
|
forceOverride: shouldForceOverrideYomitanAnkiServer(ankiConnectConfig),
|
||||||
|
deck: ankiConnectConfig.deck,
|
||||||
|
});
|
||||||
|
const result = await addYomitanNoteViaSearch(word, yomitanDeps, yomitanLogger);
|
||||||
|
if (result.noteId && result.duplicateNoteIds.length > 0) {
|
||||||
|
deps.trackDuplicateNoteIdsForNote(result.noteId, result.duplicateNoteIds);
|
||||||
|
}
|
||||||
|
return result.noteId;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
deps.setAppStateStatsServer(statsServer);
|
||||||
|
}
|
||||||
|
deps.setAppStateStatsServer(statsServer);
|
||||||
|
};
|
||||||
|
|
||||||
|
const ensureStatsServerStarted = createEnsureStatsServerUrlHandler({
|
||||||
|
currentPid: process.pid,
|
||||||
|
readBackgroundState: () => readBackgroundStatsServerState(statsDaemonStatePath),
|
||||||
|
removeBackgroundState: () => {
|
||||||
|
removeBackgroundStatsServerState(statsDaemonStatePath);
|
||||||
|
},
|
||||||
|
isProcessAlive: (pid) => isBackgroundStatsServerProcessAlive(pid),
|
||||||
|
hasLocalStatsServer: () => statsServer !== null,
|
||||||
|
startLocalStatsServer,
|
||||||
|
getConfiguredPort: () => deps.getResolvedConfig().stats.serverPort,
|
||||||
|
});
|
||||||
|
|
||||||
|
const ensureBackgroundStatsServerStarted = (): {
|
||||||
|
url: string;
|
||||||
|
runningInCurrentProcess: boolean;
|
||||||
|
} => {
|
||||||
|
const liveDaemon = readLiveBackgroundStatsDaemonState();
|
||||||
|
if (liveDaemon && liveDaemon.pid !== process.pid) {
|
||||||
|
return {
|
||||||
|
url: resolveBackgroundStatsServerUrl(liveDaemon),
|
||||||
|
runningInCurrentProcess: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
deps.setStatsStartupInProgress(true);
|
||||||
|
try {
|
||||||
|
deps.ensureImmersionTrackerStarted();
|
||||||
|
} finally {
|
||||||
|
deps.setStatsStartupInProgress(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
const port = deps.getResolvedConfig().stats.serverPort;
|
||||||
|
const result = ensureStatsServerStarted();
|
||||||
|
if (result.source === 'local') {
|
||||||
|
writeBackgroundStatsServerState(statsDaemonStatePath, {
|
||||||
|
pid: process.pid,
|
||||||
|
port,
|
||||||
|
startedAtMs: Date.now(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return { url: result.url, runningInCurrentProcess: result.source === 'local' };
|
||||||
|
};
|
||||||
|
|
||||||
|
const stopBackgroundStatsServer = async (): Promise<{ ok: boolean; stale: boolean }> => {
|
||||||
|
const state = readBackgroundStatsServerState(statsDaemonStatePath);
|
||||||
|
if (!state) {
|
||||||
|
removeBackgroundStatsServerState(statsDaemonStatePath);
|
||||||
|
return { ok: true, stale: true };
|
||||||
|
}
|
||||||
|
if (!isBackgroundStatsServerProcessAlive(state.pid)) {
|
||||||
|
removeBackgroundStatsServerState(statsDaemonStatePath);
|
||||||
|
return { ok: true, stale: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
process.kill(state.pid, 'SIGTERM');
|
||||||
|
} catch (error) {
|
||||||
|
if ((error as NodeJS.ErrnoException)?.code === 'ESRCH') {
|
||||||
|
removeBackgroundStatsServerState(statsDaemonStatePath);
|
||||||
|
return { ok: true, stale: true };
|
||||||
|
}
|
||||||
|
if ((error as NodeJS.ErrnoException)?.code === 'EPERM') {
|
||||||
|
throw new Error(
|
||||||
|
`Insufficient permissions to stop background stats server (pid ${state.pid}).`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
const deadline = Date.now() + 2_000;
|
||||||
|
while (Date.now() < deadline) {
|
||||||
|
if (!isBackgroundStatsServerProcessAlive(state.pid)) {
|
||||||
|
removeBackgroundStatsServerState(statsDaemonStatePath);
|
||||||
|
return { ok: true, stale: false };
|
||||||
|
}
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error('Timed out stopping background stats server.');
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
stopStatsServer,
|
||||||
|
ensureStatsServerStarted,
|
||||||
|
ensureBackgroundStatsServerStarted,
|
||||||
|
stopBackgroundStatsServer,
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user