import fs from 'node:fs'; import path from 'node:path'; import type { CliArgs, CliCommandSource } from '../../cli/args'; import type { DuplicateSubtitleLineCleanupSummary } from '../../core/services/immersion-tracker/duplicate-line-cleanup'; import type { LifetimeRebuildSummary, VocabularyCleanupSummary, } from '../../core/services/immersion-tracker/types'; type StatsCliConfig = { immersionTracking?: { enabled?: boolean; }; stats: { serverPort: number; autoOpenBrowser?: boolean; }; }; export type StatsCliCommandResponse = { ok: boolean; url?: string; error?: string; }; type BackgroundStatsStartResult = { url: string; runningInCurrentProcess: boolean; }; type BackgroundStatsStopResult = { ok: boolean; stale: boolean; }; function formatLoggedNumber(value: number): string { return Number.isFinite(value) ? value.toString() : String(value); } export function writeStatsCliCommandResponse( responsePath: string, payload: StatsCliCommandResponse, ): void { fs.mkdirSync(path.dirname(responsePath), { recursive: true }); fs.writeFileSync(responsePath, JSON.stringify(payload, null, 2), 'utf8'); } export function createRunStatsCliCommandHandler(deps: { getResolvedConfig: () => StatsCliConfig; ensureImmersionTrackerStarted: () => void; ensureVocabularyCleanupTokenizerReady?: () => Promise | void; getImmersionTracker: () => { cleanupVocabularyStats?: () => Promise; cleanupDuplicateSubtitleLines?: (options: { dryRun?: boolean; lookbackDays?: number | null; }) => Promise; rebuildLifetimeSummaries?: () => Promise; } | null; ensureStatsServerStarted: () => string; ensureBackgroundStatsServerStarted: () => BackgroundStatsStartResult; stopBackgroundStatsServer: () => Promise | BackgroundStatsStopResult; openExternal: (url: string) => Promise; writeResponse: (responsePath: string, payload: StatsCliCommandResponse) => void; exitAppWithCode: (code: number) => void; logInfo: (message: string) => void; logWarn: (message: string, error: unknown) => void; logError: (message: string, error: unknown) => void; }) { const writeResponseSafe = ( responsePath: string | undefined, payload: StatsCliCommandResponse, ): void => { if (!responsePath) return; try { deps.writeResponse(responsePath, payload); } catch (error) { deps.logWarn(`Failed to write stats response: ${responsePath}`, error); } }; return async ( args: Pick< CliArgs, | 'statsResponsePath' | 'statsBackground' | 'statsStop' | 'statsCleanup' | 'statsCleanupVocab' | 'statsCleanupLifetime' | 'statsCleanupDuplicateLines' | 'statsCleanupDryRun' | 'statsCleanupLookbackDays' >, source: CliCommandSource, ): Promise => { try { if (args.statsStop) { const result = await deps.stopBackgroundStatsServer(); deps.logInfo( result.stale ? 'Background stats server is not running; cleaned stale state.' : 'Background stats server stopped.', ); writeResponseSafe(args.statsResponsePath, { ok: true }); if (source === 'initial') { deps.exitAppWithCode(0); } return; } const config = deps.getResolvedConfig(); if (config.immersionTracking?.enabled === false) { throw new Error('Immersion tracking is disabled in config.'); } if (args.statsBackground) { const result = deps.ensureBackgroundStatsServerStarted(); deps.logInfo(`Stats dashboard available at ${result.url}`); writeResponseSafe(args.statsResponsePath, { ok: true, url: result.url }); if (!result.runningInCurrentProcess && source === 'initial') { deps.exitAppWithCode(0); } return; } deps.ensureImmersionTrackerStarted(); const tracker = deps.getImmersionTracker(); if (!tracker) { throw new Error('Immersion tracker failed to initialize.'); } if (args.statsCleanup) { const cleanupModes = [ args.statsCleanupVocab ? 'vocab' : null, args.statsCleanupLifetime ? 'lifetime' : null, args.statsCleanupDuplicateLines ? 'duplicate-lines' : null, ].filter(Boolean); if (cleanupModes.length !== 1) { throw new Error('Choose exactly one stats cleanup mode.'); } if (args.statsCleanupVocab) { await deps.ensureVocabularyCleanupTokenizerReady?.(); } if (args.statsCleanupVocab && tracker.cleanupVocabularyStats) { const result = await tracker.cleanupVocabularyStats(); deps.logInfo( `Stats vocabulary cleanup complete: scanned=${result.scanned} kept=${result.kept} deleted=${result.deleted} repaired=${result.repaired}`, ); writeResponseSafe(args.statsResponsePath, { ok: true }); return; } if (args.statsCleanupDuplicateLines && tracker.cleanupDuplicateSubtitleLines) { const result = await tracker.cleanupDuplicateSubtitleLines({ dryRun: args.statsCleanupDryRun === true, lookbackDays: args.statsCleanupLookbackDays ?? null, }); const window = result.lookbackDays === null ? 'all history' : `last ${result.lookbackDays}d`; deps.logInfo( `Stats duplicate-line cleanup ${result.dryRun ? 'preview' : 'complete'} (${window}): ` + `scanned=${result.scannedLines} bursts=${result.burstGroups} ` + `removedLines=${result.removedLines} removedWordCounts=${result.removedWordOccurrences} ` + `removedKanjiCounts=${result.removedKanjiOccurrences}`, ); for (const sample of result.samples.slice(0, 5)) { deps.logInfo( ` ${sample.videoTitle ?? `video ${sample.videoId}`}: "${sample.text}" x${sample.frames}`, ); } writeResponseSafe(args.statsResponsePath, { ok: true }); return; } if (!args.statsCleanupLifetime || !tracker.rebuildLifetimeSummaries) { throw new Error('Stats cleanup mode is not available.'); } const result = await tracker.rebuildLifetimeSummaries(); deps.logInfo( `Stats lifetime rebuild complete: appliedSessions=${formatLoggedNumber(result.appliedSessions)} rebuiltAtMs=${formatLoggedNumber(result.rebuiltAtMs)}`, ); writeResponseSafe(args.statsResponsePath, { ok: true }); return; } const url = deps.ensureStatsServerStarted(); if (config.stats.autoOpenBrowser !== false) { await deps.openExternal(url); } deps.logInfo(`Stats dashboard available at ${url}`); writeResponseSafe(args.statsResponsePath, { ok: true, url }); } catch (error) { deps.logError('Stats command failed', error); const message = error instanceof Error ? error.message : String(error); writeResponseSafe(args.statsResponsePath, { ok: false, error: message }); if (source === 'initial') { deps.exitAppWithCode(1); } } }; }