From a7c294a90cc818f72eb52ab951a8f0058a4aa72f Mon Sep 17 00:00:00 2001 From: sudacode Date: Sat, 14 Mar 2026 22:14:09 -0700 Subject: [PATCH] feat(stats): add stats server, API endpoints, config, and Anki integration - Hono HTTP server with 20+ REST endpoints for stats data - Stats overlay BrowserWindow with toggle keybinding - IPC channel definitions and preload bridge - Stats config section (toggleKey, serverPort, autoStartServer, autoOpenBrowser) - Config resolver for stats section - AnkiConnect proxy endpoints (guiBrowse, notesInfo) - Note ID passthrough in card mining callback chain - Stats CLI command with autoOpenBrowser respect --- src/anki-integration.ts | 13 + .../anki-connect-proxy.test.ts | 15 + src/anki-integration/anki-connect-proxy.ts | 4 + src/anki-integration/polling.test.ts | 35 + src/anki-integration/polling.ts | 2 + src/config/definitions.ts | 5 + src/config/definitions/defaults-stats.ts | 10 + src/config/definitions/options-stats.ts | 33 + src/config/definitions/template-sections.ts | 8 + src/config/resolve.ts | 2 + src/config/resolve/stats.ts | 36 + .../services/__tests__/stats-server.test.ts | 773 ++++++++++++++++++ src/core/services/app-lifecycle.test.ts | 1 + src/core/services/cli-command.test.ts | 19 + src/core/services/cli-command.ts | 5 + src/core/services/ipc.test.ts | 175 +++- src/core/services/ipc.ts | 139 ++++ src/core/services/mpv-properties.ts | 1 + src/core/services/mpv-protocol.test.ts | 1 + src/core/services/mpv-protocol.ts | 6 + src/core/services/mpv.ts | 4 + src/core/services/startup-bootstrap.test.ts | 1 + src/core/services/startup.test.ts | 95 +++ src/core/services/startup.ts | 7 + src/core/services/stats-server.ts | 372 +++++++++ src/core/services/stats-window-runtime.ts | 64 ++ src/core/services/stats-window.test.ts | 90 ++ src/core/services/stats-window.ts | 98 +++ src/main/runtime/anki-actions-main-deps.ts | 4 +- src/main/runtime/anki-actions.ts | 2 +- src/main/runtime/stats-cli-command.test.ts | 111 +++ src/main/runtime/stats-cli-command.ts | 99 +++ src/preload-stats.ts | 49 ++ src/preload.ts | 6 + src/shared/ipc/contracts.ts | 15 + src/types.ts | 16 + 36 files changed, 2312 insertions(+), 4 deletions(-) create mode 100644 src/anki-integration/polling.test.ts create mode 100644 src/config/definitions/defaults-stats.ts create mode 100644 src/config/definitions/options-stats.ts create mode 100644 src/config/resolve/stats.ts create mode 100644 src/core/services/__tests__/stats-server.test.ts create mode 100644 src/core/services/startup.test.ts create mode 100644 src/core/services/stats-server.ts create mode 100644 src/core/services/stats-window-runtime.ts create mode 100644 src/core/services/stats-window.test.ts create mode 100644 src/core/services/stats-window.ts create mode 100644 src/main/runtime/stats-cli-command.test.ts create mode 100644 src/main/runtime/stats-cli-command.ts create mode 100644 src/preload-stats.ts diff --git a/src/anki-integration.ts b/src/anki-integration.ts index 86f47ff..f79d158 100644 --- a/src/anki-integration.ts +++ b/src/anki-integration.ts @@ -137,6 +137,7 @@ export class AnkiIntegration { private fieldGroupingWorkflow: FieldGroupingWorkflow; private runtime: AnkiIntegrationRuntime; private aiConfig: AiConfig; + private recordCardsMinedCallback: ((count: number, noteIds?: number[]) => void) | null = null; constructor( config: AnkiConnectConfig, @@ -150,6 +151,7 @@ export class AnkiIntegration { }) => Promise, knownWordCacheStatePath?: string, aiConfig: AiConfig = {}, + recordCardsMined?: (count: number, noteIds?: number[]) => void, ) { this.config = normalizeAnkiIntegrationConfig(config); this.aiConfig = { ...aiConfig }; @@ -160,6 +162,7 @@ export class AnkiIntegration { this.osdCallback = osdCallback || null; this.notificationCallback = notificationCallback || null; this.fieldGroupingCallback = fieldGroupingCallback || null; + this.recordCardsMinedCallback = recordCardsMined ?? null; this.knownWordCache = this.createKnownWordCache(knownWordCacheStatePath); this.pollingRunner = this.createPollingRunner(); this.cardCreationService = this.createCardCreationService(); @@ -208,6 +211,9 @@ export class AnkiIntegration { (await this.client.findNotes(query, options)) as number[], shouldAutoUpdateNewCards: () => this.config.behavior?.autoUpdateNewCards !== false, processNewCard: (noteId) => this.processNewCard(noteId), + recordCardsAdded: (count, noteIds) => { + this.recordCardsMinedCallback?.(count, noteIds); + }, isUpdateInProgress: () => this.updateInProgress, setUpdateInProgress: (value) => { this.updateInProgress = value; @@ -229,6 +235,9 @@ export class AnkiIntegration { return new AnkiConnectProxyServer({ shouldAutoUpdateNewCards: () => this.config.behavior?.autoUpdateNewCards !== false, processNewCard: (noteId: number) => this.processNewCard(noteId), + recordCardsAdded: (count, noteIds) => { + this.recordCardsMinedCallback?.(count, noteIds); + }, getDeck: () => this.config.deck, findNotes: async (query, options) => (await this.client.findNotes(query, options)) as number[], @@ -1112,4 +1121,8 @@ export class AnkiIntegration { this.stop(); this.mediaGenerator.cleanup(); } + + setRecordCardsMinedCallback(callback: ((count: number, noteIds?: number[]) => void) | null): void { + this.recordCardsMinedCallback = callback; + } } diff --git a/src/anki-integration/anki-connect-proxy.test.ts b/src/anki-integration/anki-connect-proxy.test.ts index 6508019..1b5a065 100644 --- a/src/anki-integration/anki-connect-proxy.test.ts +++ b/src/anki-integration/anki-connect-proxy.test.ts @@ -17,11 +17,15 @@ async function waitForCondition( test('proxy enqueues addNote result for enrichment', async () => { const processed: number[] = []; + const recordedCards: number[] = []; const proxy = new AnkiConnectProxyServer({ shouldAutoUpdateNewCards: () => true, processNewCard: async (noteId) => { processed.push(noteId); }, + recordCardsAdded: (count) => { + recordedCards.push(count); + }, logInfo: () => undefined, logWarn: () => undefined, logError: () => undefined, @@ -38,6 +42,7 @@ test('proxy enqueues addNote result for enrichment', async () => { await waitForCondition(() => processed.length === 1); assert.deepEqual(processed, [42]); + assert.deepEqual(recordedCards, [1]); }); test('proxy enqueues addNote bare numeric response for enrichment', async () => { @@ -64,12 +69,16 @@ test('proxy enqueues addNote bare numeric response for enrichment', async () => test('proxy de-duplicates addNotes IDs within the same response', async () => { const processed: number[] = []; + const recordedCards: number[] = []; const proxy = new AnkiConnectProxyServer({ shouldAutoUpdateNewCards: () => true, processNewCard: async (noteId) => { processed.push(noteId); await new Promise((resolve) => setTimeout(resolve, 5)); }, + recordCardsAdded: (count) => { + recordedCards.push(count); + }, logInfo: () => undefined, logWarn: () => undefined, logError: () => undefined, @@ -86,6 +95,7 @@ test('proxy de-duplicates addNotes IDs within the same response', async () => { await waitForCondition(() => processed.length === 2); assert.deepEqual(processed, [101, 102]); + assert.deepEqual(recordedCards, [2]); }); test('proxy enqueues note IDs from multi action addNote/addNotes results', async () => { @@ -277,12 +287,16 @@ test('proxy does not fallback-enqueue latest note for multi requests without add test('proxy fallback-enqueues latest note for addNote responses without note IDs and escapes deck quotes', async () => { const processed: number[] = []; + const recordedCards: number[] = []; const findNotesQueries: string[] = []; const proxy = new AnkiConnectProxyServer({ shouldAutoUpdateNewCards: () => true, processNewCard: async (noteId) => { processed.push(noteId); }, + recordCardsAdded: (count) => { + recordedCards.push(count); + }, getDeck: () => 'My "Japanese" Deck', findNotes: async (query) => { findNotesQueries.push(query); @@ -305,6 +319,7 @@ test('proxy fallback-enqueues latest note for addNote responses without note IDs await waitForCondition(() => processed.length === 1); assert.deepEqual(findNotesQueries, ['"deck:My \\"Japanese\\" Deck" added:1']); assert.deepEqual(processed, [501]); + assert.deepEqual(recordedCards, [1]); }); test('proxy detects self-referential loop configuration', () => { diff --git a/src/anki-integration/anki-connect-proxy.ts b/src/anki-integration/anki-connect-proxy.ts index a39e8f2..4ba236c 100644 --- a/src/anki-integration/anki-connect-proxy.ts +++ b/src/anki-integration/anki-connect-proxy.ts @@ -15,6 +15,7 @@ interface AnkiConnectEnvelope { export interface AnkiConnectProxyServerDeps { shouldAutoUpdateNewCards: () => boolean; processNewCard: (noteId: number) => Promise; + recordCardsAdded?: (count: number, noteIds: number[]) => void; getDeck?: () => string | undefined; findNotes?: ( query: string, @@ -332,12 +333,14 @@ export class AnkiConnectProxyServer { private enqueueNotes(noteIds: number[]): void { let enqueuedCount = 0; + const acceptedIds: number[] = []; for (const noteId of noteIds) { if (this.pendingNoteIdSet.has(noteId) || this.inFlightNoteIds.has(noteId)) { continue; } this.pendingNoteIds.push(noteId); this.pendingNoteIdSet.add(noteId); + acceptedIds.push(noteId); enqueuedCount += 1; } @@ -345,6 +348,7 @@ export class AnkiConnectProxyServer { return; } + this.deps.recordCardsAdded?.(enqueuedCount, acceptedIds); this.deps.logInfo(`[anki-proxy] Enqueued ${enqueuedCount} note(s) for enrichment`); this.processQueue(); } diff --git a/src/anki-integration/polling.test.ts b/src/anki-integration/polling.test.ts new file mode 100644 index 0000000..d62d1ba --- /dev/null +++ b/src/anki-integration/polling.test.ts @@ -0,0 +1,35 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { PollingRunner } from './polling'; + +test('polling runner records newly added cards after initialization', async () => { + const recordedCards: number[] = []; + let tracked = new Set(); + const responses = [[10, 11], [10, 11, 12, 13]]; + const runner = new PollingRunner({ + getDeck: () => 'Mining', + getPollingRate: () => 250, + findNotes: async () => responses.shift() ?? [], + shouldAutoUpdateNewCards: () => true, + processNewCard: async () => undefined, + recordCardsAdded: (count) => { + recordedCards.push(count); + }, + isUpdateInProgress: () => false, + setUpdateInProgress: () => undefined, + getTrackedNoteIds: () => tracked, + setTrackedNoteIds: (noteIds) => { + tracked = noteIds; + }, + showStatusNotification: () => undefined, + logDebug: () => undefined, + logInfo: () => undefined, + logWarn: () => undefined, + }); + + await runner.pollOnce(); + await runner.pollOnce(); + + assert.deepEqual(recordedCards, [2]); +}); diff --git a/src/anki-integration/polling.ts b/src/anki-integration/polling.ts index 372b40a..32e14e3 100644 --- a/src/anki-integration/polling.ts +++ b/src/anki-integration/polling.ts @@ -9,6 +9,7 @@ export interface PollingRunnerDeps { ) => Promise; shouldAutoUpdateNewCards: () => boolean; processNewCard: (noteId: number) => Promise; + recordCardsAdded?: (count: number, noteIds: number[]) => void; isUpdateInProgress: () => boolean; setUpdateInProgress: (value: boolean) => void; getTrackedNoteIds: () => Set; @@ -80,6 +81,7 @@ export class PollingRunner { previousNoteIds.add(noteId); } this.deps.setTrackedNoteIds(previousNoteIds); + this.deps.recordCardsAdded?.(newNoteIds.length, newNoteIds); if (this.deps.shouldAutoUpdateNewCards()) { for (const noteId of newNoteIds) { diff --git a/src/config/definitions.ts b/src/config/definitions.ts index d8a8e55..396bada 100644 --- a/src/config/definitions.ts +++ b/src/config/definitions.ts @@ -2,10 +2,12 @@ import { RawConfig, ResolvedConfig } from '../types'; import { CORE_DEFAULT_CONFIG } from './definitions/defaults-core'; import { IMMERSION_DEFAULT_CONFIG } from './definitions/defaults-immersion'; import { INTEGRATIONS_DEFAULT_CONFIG } from './definitions/defaults-integrations'; +import { STATS_DEFAULT_CONFIG } from './definitions/defaults-stats'; import { SUBTITLE_DEFAULT_CONFIG } from './definitions/defaults-subtitle'; import { buildCoreConfigOptionRegistry } from './definitions/options-core'; import { buildImmersionConfigOptionRegistry } from './definitions/options-immersion'; import { buildIntegrationConfigOptionRegistry } from './definitions/options-integrations'; +import { buildStatsConfigOptionRegistry } from './definitions/options-stats'; import { buildSubtitleConfigOptionRegistry } from './definitions/options-subtitle'; import { buildRuntimeOptionRegistry } from './definitions/runtime-options'; import { CONFIG_TEMPLATE_SECTIONS } from './definitions/template-sections'; @@ -36,6 +38,7 @@ const { ankiConnect, jimaku, anilist, yomitan, jellyfin, discordPresence, ai, yo INTEGRATIONS_DEFAULT_CONFIG; const { subtitleStyle } = SUBTITLE_DEFAULT_CONFIG; const { immersionTracking } = IMMERSION_DEFAULT_CONFIG; +const { stats } = STATS_DEFAULT_CONFIG; export const DEFAULT_CONFIG: ResolvedConfig = { subtitlePosition, @@ -60,6 +63,7 @@ export const DEFAULT_CONFIG: ResolvedConfig = { ai, youtubeSubgen, immersionTracking, + stats, }; export const DEFAULT_ANKI_CONNECT_CONFIG = DEFAULT_CONFIG.ankiConnect; @@ -71,6 +75,7 @@ export const CONFIG_OPTION_REGISTRY = [ ...buildSubtitleConfigOptionRegistry(DEFAULT_CONFIG), ...buildIntegrationConfigOptionRegistry(DEFAULT_CONFIG, RUNTIME_OPTION_REGISTRY), ...buildImmersionConfigOptionRegistry(DEFAULT_CONFIG), + ...buildStatsConfigOptionRegistry(DEFAULT_CONFIG), ]; export { CONFIG_TEMPLATE_SECTIONS }; diff --git a/src/config/definitions/defaults-stats.ts b/src/config/definitions/defaults-stats.ts new file mode 100644 index 0000000..c0e5169 --- /dev/null +++ b/src/config/definitions/defaults-stats.ts @@ -0,0 +1,10 @@ +import { ResolvedConfig } from '../../types.js'; + +export const STATS_DEFAULT_CONFIG: Pick = { + stats: { + toggleKey: 'Backquote', + serverPort: 5175, + autoStartServer: true, + autoOpenBrowser: true, + }, +}; diff --git a/src/config/definitions/options-stats.ts b/src/config/definitions/options-stats.ts new file mode 100644 index 0000000..e1f63ff --- /dev/null +++ b/src/config/definitions/options-stats.ts @@ -0,0 +1,33 @@ +import { ResolvedConfig } from '../../types.js'; +import { ConfigOptionRegistryEntry } from './shared.js'; + +export function buildStatsConfigOptionRegistry( + defaultConfig: ResolvedConfig, +): ConfigOptionRegistryEntry[] { + return [ + { + path: 'stats.toggleKey', + kind: 'string', + defaultValue: defaultConfig.stats.toggleKey, + description: 'Key code to toggle the stats overlay.', + }, + { + path: 'stats.serverPort', + kind: 'number', + defaultValue: defaultConfig.stats.serverPort, + description: 'Port for the stats HTTP server.', + }, + { + path: 'stats.autoStartServer', + kind: 'boolean', + defaultValue: defaultConfig.stats.autoStartServer, + description: 'Automatically start the stats server on launch.', + }, + { + path: 'stats.autoOpenBrowser', + kind: 'boolean', + defaultValue: defaultConfig.stats.autoOpenBrowser, + description: 'Automatically open the stats dashboard in a browser when the server starts.', + }, + ]; +} diff --git a/src/config/definitions/template-sections.ts b/src/config/definitions/template-sections.ts index d25e8d1..8224602 100644 --- a/src/config/definitions/template-sections.ts +++ b/src/config/definitions/template-sections.ts @@ -176,6 +176,14 @@ const IMMERSION_TEMPLATE_SECTIONS: ConfigTemplateSection[] = [ ], key: 'immersionTracking', }, + { + title: 'Stats Dashboard', + description: [ + 'Local immersion stats dashboard served on localhost and available as an in-app overlay.', + 'Uses the immersion tracking database for overview, trends, sessions, and vocabulary views.', + ], + key: 'stats', + }, ]; export const CONFIG_TEMPLATE_SECTIONS: ConfigTemplateSection[] = [ diff --git a/src/config/resolve.ts b/src/config/resolve.ts index d8eed5a..c520e7c 100644 --- a/src/config/resolve.ts +++ b/src/config/resolve.ts @@ -4,6 +4,7 @@ import { createResolveContext } from './resolve/context'; import { applyCoreDomainConfig } from './resolve/core-domains'; import { applyImmersionTrackingConfig } from './resolve/immersion-tracking'; import { applyIntegrationConfig } from './resolve/integrations'; +import { applyStatsConfig } from './resolve/stats'; import { applySubtitleDomainConfig } from './resolve/subtitle-domains'; import { applyTopLevelConfig } from './resolve/top-level'; @@ -13,6 +14,7 @@ const APPLY_RESOLVE_STEPS = [ applySubtitleDomainConfig, applyIntegrationConfig, applyImmersionTrackingConfig, + applyStatsConfig, applyAnkiConnectResolution, ] as const; diff --git a/src/config/resolve/stats.ts b/src/config/resolve/stats.ts new file mode 100644 index 0000000..f7e8dec --- /dev/null +++ b/src/config/resolve/stats.ts @@ -0,0 +1,36 @@ +import { ResolveContext } from './context'; +import { asBoolean, asNumber, asString, isObject } from './shared'; + +export function applyStatsConfig(context: ResolveContext): void { + const { src, resolved, warn } = context; + + if (!isObject(src.stats)) return; + + const toggleKey = asString(src.stats.toggleKey); + if (toggleKey !== undefined) { + resolved.stats.toggleKey = toggleKey; + } else if (src.stats.toggleKey !== undefined) { + warn('stats.toggleKey', src.stats.toggleKey, resolved.stats.toggleKey, 'Expected string.'); + } + + const serverPort = asNumber(src.stats.serverPort); + if (serverPort !== undefined) { + resolved.stats.serverPort = serverPort; + } else if (src.stats.serverPort !== undefined) { + warn('stats.serverPort', src.stats.serverPort, resolved.stats.serverPort, 'Expected number.'); + } + + const autoStartServer = asBoolean(src.stats.autoStartServer); + if (autoStartServer !== undefined) { + resolved.stats.autoStartServer = autoStartServer; + } else if (src.stats.autoStartServer !== undefined) { + warn('stats.autoStartServer', src.stats.autoStartServer, resolved.stats.autoStartServer, 'Expected boolean.'); + } + + const autoOpenBrowser = asBoolean(src.stats.autoOpenBrowser); + if (autoOpenBrowser !== undefined) { + resolved.stats.autoOpenBrowser = autoOpenBrowser; + } else if (src.stats.autoOpenBrowser !== undefined) { + warn('stats.autoOpenBrowser', src.stats.autoOpenBrowser, resolved.stats.autoOpenBrowser, 'Expected boolean.'); + } +} diff --git a/src/core/services/__tests__/stats-server.test.ts b/src/core/services/__tests__/stats-server.test.ts new file mode 100644 index 0000000..8048188 --- /dev/null +++ b/src/core/services/__tests__/stats-server.test.ts @@ -0,0 +1,773 @@ +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { createStatsApp } from '../stats-server.js'; +import type { ImmersionTrackerService } from '../immersion-tracker-service.js'; + +const SESSION_SUMMARIES = [ + { + sessionId: 1, + canonicalTitle: 'Test', + videoId: 1, + animeId: null, + animeTitle: null, + startedAtMs: Date.now(), + endedAtMs: null, + totalWatchedMs: 60_000, + activeWatchedMs: 50_000, + linesSeen: 10, + wordsSeen: 100, + tokensSeen: 80, + cardsMined: 2, + lookupCount: 5, + lookupHits: 4, + }, +]; + +const DAILY_ROLLUPS = [ + { + rollupDayOrMonth: Math.floor(Date.now() / 86_400_000), + videoId: 1, + totalSessions: 1, + totalActiveMin: 10, + totalLinesSeen: 10, + totalWordsSeen: 100, + totalTokensSeen: 80, + totalCards: 2, + cardsPerHour: 12, + wordsPerMin: 10, + lookupHitRate: 0.8, + }, +]; + +const VOCABULARY_STATS = [ + { + wordId: 1, + headword: 'する', + word: 'する', + reading: 'する', + partOfSpeech: 'verb', + pos1: '動詞', + pos2: '自立', + pos3: null, + frequency: 100, + firstSeen: Date.now(), + lastSeen: Date.now(), + }, +]; + +const KANJI_STATS = [ + { + kanjiId: 1, + kanji: '日', + frequency: 50, + firstSeen: Date.now(), + lastSeen: Date.now(), + }, +]; + +const OCCURRENCES = [ + { + animeId: 1, + animeTitle: 'Little Witch Academia', + videoId: 2, + videoTitle: 'Episode 4', + sessionId: 3, + lineIndex: 7, + segmentStartMs: 12_000, + segmentEndMs: 14_500, + text: '猫 猫 日 日 は 知っている', + occurrenceCount: 2, + }, +]; + +const ANIME_LIBRARY = [ + { + animeId: 1, + canonicalTitle: 'Little Witch Academia', + anilistId: 21858, + totalSessions: 3, + totalActiveMs: 180_000, + totalCards: 5, + totalWordsSeen: 300, + episodeCount: 2, + episodesTotal: 25, + lastWatchedMs: Date.now(), + }, +]; + +const ANIME_DETAIL = { + animeId: 1, + canonicalTitle: 'Little Witch Academia', + anilistId: 21858, + titleRomaji: 'Little Witch Academia', + titleEnglish: 'Little Witch Academia', + titleNative: 'リトルウィッチアカデミア', + totalSessions: 3, + totalActiveMs: 180_000, + totalCards: 5, + totalWordsSeen: 300, + totalLinesSeen: 50, + totalLookupCount: 20, + totalLookupHits: 15, + episodeCount: 2, + lastWatchedMs: Date.now(), +}; + +const ANIME_WORDS = [ + { + wordId: 1, + headword: '魔法', + word: '魔法', + reading: 'まほう', + partOfSpeech: 'noun', + frequency: 42, + }, +]; + +const EPISODES_PER_DAY = [ + { epochDay: Math.floor(Date.now() / 86_400_000) - 1, episodeCount: 3 }, + { epochDay: Math.floor(Date.now() / 86_400_000), episodeCount: 1 }, +]; + +const NEW_ANIME_PER_DAY = [ + { epochDay: Math.floor(Date.now() / 86_400_000) - 2, newAnimeCount: 2 }, +]; + +const WATCH_TIME_PER_ANIME = [ + { + epochDay: Math.floor(Date.now() / 86_400_000) - 1, + animeId: 1, + animeTitle: 'Little Witch Academia', + totalActiveMin: 25, + }, +]; + +const ANIME_EPISODES = [ + { + animeId: 1, + videoId: 1, + canonicalTitle: 'Episode 1', + parsedTitle: 'Little Witch Academia', + season: 1, + episode: 1, + totalSessions: 1, + totalActiveMs: 90_000, + totalCards: 3, + totalWordsSeen: 150, + lastWatchedMs: Date.now(), + }, +]; + +const WORD_DETAIL = { + wordId: 1, + headword: '猫', + word: '猫', + reading: 'ねこ', + partOfSpeech: 'noun', + pos1: '名詞', + pos2: '一般', + pos3: null, + frequency: 42, + firstSeen: Date.now() - 100_000, + lastSeen: Date.now(), +}; + +const WORD_ANIME_APPEARANCES = [ + { animeId: 1, animeTitle: 'Little Witch Academia', occurrenceCount: 12 }, +]; + +const SIMILAR_WORDS = [ + { wordId: 2, headword: '猫耳', word: '猫耳', reading: 'ねこみみ', frequency: 5 }, +]; + +const KANJI_DETAIL = { + kanjiId: 1, + kanji: '日', + frequency: 50, + firstSeen: Date.now() - 100_000, + lastSeen: Date.now(), +}; + +const KANJI_ANIME_APPEARANCES = [ + { animeId: 1, animeTitle: 'Little Witch Academia', occurrenceCount: 30 }, +]; + +const KANJI_WORDS = [ + { wordId: 3, headword: '日本', word: '日本', reading: 'にほん', frequency: 20 }, +]; + +const EPISODE_CARD_EVENTS = [ + { eventId: 1, sessionId: 1, tsMs: Date.now(), cardsDelta: 1, noteIds: [12345] }, +]; + +function createMockTracker( + overrides: Partial = {}, +): ImmersionTrackerService { + return { + getSessionSummaries: async () => SESSION_SUMMARIES, + getDailyRollups: async () => DAILY_ROLLUPS, + getMonthlyRollups: async () => [], + getQueryHints: async () => ({ totalSessions: 5, activeSessions: 1, episodesToday: 2, activeAnimeCount: 3 }), + getSessionTimeline: async () => [], + getSessionEvents: async () => [], + getVocabularyStats: async () => VOCABULARY_STATS, + getKanjiStats: async () => KANJI_STATS, + getWordOccurrences: async () => OCCURRENCES, + getKanjiOccurrences: async () => OCCURRENCES, + getAnimeLibrary: async () => ANIME_LIBRARY, + getAnimeDetail: async (animeId: number) => (animeId === 1 ? ANIME_DETAIL : null), + getAnimeEpisodes: async () => ANIME_EPISODES, + getAnimeAnilistEntries: async () => [], + getAnimeWords: async () => ANIME_WORDS, + getAnimeDailyRollups: async () => DAILY_ROLLUPS, + getEpisodesPerDay: async () => EPISODES_PER_DAY, + getNewAnimePerDay: async () => NEW_ANIME_PER_DAY, + getWatchTimePerAnime: async () => WATCH_TIME_PER_ANIME, + getStreakCalendar: async () => [ + { epochDay: Math.floor(Date.now() / 86_400_000) - 1, totalActiveMin: 30 }, + { epochDay: Math.floor(Date.now() / 86_400_000), totalActiveMin: 45 }, + ], + getAnimeCoverArt: async (animeId: number) => + animeId === 1 + ? { + videoId: 1, + anilistId: 21858, + coverUrl: 'https://example.com/cover.jpg', + coverBlob: Buffer.from([0xff, 0xd8, 0xff, 0xd9]), + titleRomaji: 'Little Witch Academia', + titleEnglish: 'Little Witch Academia', + episodesTotal: 25, + fetchedAtMs: Date.now(), + } + : null, + getWordDetail: async (wordId: number) => (wordId === 1 ? WORD_DETAIL : null), + getWordAnimeAppearances: async () => WORD_ANIME_APPEARANCES, + getSimilarWords: async () => SIMILAR_WORDS, + getKanjiDetail: async (kanjiId: number) => (kanjiId === 1 ? KANJI_DETAIL : null), + getKanjiAnimeAppearances: async () => KANJI_ANIME_APPEARANCES, + getKanjiWords: async () => KANJI_WORDS, + getEpisodeWords: async () => ANIME_WORDS, + getEpisodeSessions: async () => SESSION_SUMMARIES, + getEpisodeCardEvents: async () => EPISODE_CARD_EVENTS, + ...overrides, + } as unknown as ImmersionTrackerService; +} + +function withTempDir(fn: (dir: string) => Promise | T): Promise | T { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-stats-server-test-')); + const result = fn(dir); + if (result instanceof Promise) { + return result.finally(() => { + fs.rmSync(dir, { recursive: true, force: true }); + }); + } + fs.rmSync(dir, { recursive: true, force: true }); + return result; +} + +describe('stats server API routes', () => { + it('GET /api/stats/overview returns overview data', async () => { + const app = createStatsApp(createMockTracker()); + const res = await app.request('/api/stats/overview'); + assert.equal(res.status, 200); + assert.equal(res.headers.get('access-control-allow-origin'), null); + const body = await res.json(); + assert.ok(body.sessions); + assert.ok(body.rollups); + assert.ok(body.hints); + assert.equal(body.hints.totalSessions, 5); + assert.equal(body.hints.activeSessions, 1); + assert.equal(body.hints.episodesToday, 2); + assert.equal(body.hints.activeAnimeCount, 3); + }); + + it('GET /api/stats/sessions returns session list', async () => { + const app = createStatsApp(createMockTracker()); + const res = await app.request('/api/stats/sessions?limit=5'); + assert.equal(res.status, 200); + const body = await res.json(); + assert.ok(Array.isArray(body)); + }); + + it('GET /api/stats/vocabulary returns word frequency data', async () => { + const app = createStatsApp(createMockTracker()); + const res = await app.request('/api/stats/vocabulary'); + assert.equal(res.status, 200); + const body = await res.json(); + assert.ok(Array.isArray(body)); + assert.equal(body[0].headword, 'する'); + }); + + it('GET /api/stats/kanji returns kanji frequency data', async () => { + const app = createStatsApp(createMockTracker()); + const res = await app.request('/api/stats/kanji'); + assert.equal(res.status, 200); + const body = await res.json(); + assert.ok(Array.isArray(body)); + assert.equal(body[0].kanji, '日'); + }); + + it('GET /api/stats/streak-calendar returns streak calendar rows', async () => { + const app = createStatsApp(createMockTracker()); + const res = await app.request('/api/stats/streak-calendar'); + assert.equal(res.status, 200); + const body = await res.json(); + assert.ok(Array.isArray(body)); + assert.equal(body.length, 2); + assert.equal(body[0].totalActiveMin, 30); + assert.equal(body[1].totalActiveMin, 45); + }); + + it('GET /api/stats/streak-calendar clamps oversized days', async () => { + let seenDays = 0; + const app = createStatsApp( + createMockTracker({ + getStreakCalendar: async (days?: number) => { + seenDays = days ?? 0; + return []; + }, + }), + ); + + const res = await app.request('/api/stats/streak-calendar?days=999999'); + assert.equal(res.status, 200); + assert.equal(seenDays, 365); + }); + + it('GET /api/stats/trends/episodes-per-day returns episode count rows', async () => { + const app = createStatsApp(createMockTracker()); + const res = await app.request('/api/stats/trends/episodes-per-day'); + assert.equal(res.status, 200); + const body = await res.json(); + assert.ok(Array.isArray(body)); + assert.equal(body.length, 2); + assert.equal(body[0].episodeCount, 3); + }); + + it('GET /api/stats/trends/episodes-per-day clamps oversized limits', async () => { + let seenLimit = 0; + const app = createStatsApp( + createMockTracker({ + getEpisodesPerDay: async (limit?: number) => { + seenLimit = limit ?? 0; + return EPISODES_PER_DAY; + }, + }), + ); + const res = await app.request('/api/stats/trends/episodes-per-day?limit=999999'); + assert.equal(res.status, 200); + assert.equal(seenLimit, 365); + }); + + it('GET /api/stats/trends/new-anime-per-day returns new anime rows', async () => { + const app = createStatsApp(createMockTracker()); + const res = await app.request('/api/stats/trends/new-anime-per-day'); + assert.equal(res.status, 200); + const body = await res.json(); + assert.ok(Array.isArray(body)); + assert.equal(body.length, 1); + assert.equal(body[0].newAnimeCount, 2); + }); + + it('GET /api/stats/trends/new-anime-per-day clamps oversized limits', async () => { + let seenLimit = 0; + const app = createStatsApp( + createMockTracker({ + getNewAnimePerDay: async (limit?: number) => { + seenLimit = limit ?? 0; + return NEW_ANIME_PER_DAY; + }, + }), + ); + const res = await app.request('/api/stats/trends/new-anime-per-day?limit=999999'); + assert.equal(res.status, 200); + assert.equal(seenLimit, 365); + }); + + it('GET /api/stats/trends/watch-time-per-anime returns watch time rows', async () => { + const app = createStatsApp(createMockTracker()); + const res = await app.request('/api/stats/trends/watch-time-per-anime'); + assert.equal(res.status, 200); + const body = await res.json(); + assert.ok(Array.isArray(body)); + assert.equal(body.length, 1); + assert.equal(body[0].animeTitle, 'Little Witch Academia'); + assert.equal(body[0].totalActiveMin, 25); + }); + + it('GET /api/stats/trends/watch-time-per-anime clamps oversized limits', async () => { + let seenLimit = 0; + const app = createStatsApp( + createMockTracker({ + getWatchTimePerAnime: async (limit?: number) => { + seenLimit = limit ?? 0; + return WATCH_TIME_PER_ANIME; + }, + }), + ); + const res = await app.request('/api/stats/trends/watch-time-per-anime?limit=999999'); + assert.equal(res.status, 200); + assert.equal(seenLimit, 365); + }); + + it('GET /api/stats/vocabulary/occurrences returns recent occurrence rows for a word', async () => { + let seenArgs: unknown[] = []; + const app = createStatsApp( + createMockTracker({ + getWordOccurrences: async (...args: unknown[]) => { + seenArgs = args; + return OCCURRENCES; + }, + }), + ); + + const res = await app.request( + '/api/stats/vocabulary/occurrences?headword=%E7%8C%AB&word=%E7%8C%AB&reading=%E3%81%AD%E3%81%93&limit=999999&offset=25', + ); + assert.equal(res.status, 200); + const body = await res.json(); + assert.ok(Array.isArray(body)); + assert.equal(body[0].animeTitle, 'Little Witch Academia'); + assert.deepEqual(seenArgs, ['猫', '猫', 'ねこ', 500, 25]); + }); + + it('GET /api/stats/kanji/occurrences returns recent occurrence rows for a kanji', async () => { + let seenArgs: unknown[] = []; + const app = createStatsApp( + createMockTracker({ + getKanjiOccurrences: async (...args: unknown[]) => { + seenArgs = args; + return OCCURRENCES; + }, + }), + ); + + const res = await app.request('/api/stats/kanji/occurrences?kanji=%E6%97%A5&limit=999999&offset=10'); + assert.equal(res.status, 200); + const body = await res.json(); + assert.ok(Array.isArray(body)); + assert.equal(body[0].occurrenceCount, 2); + assert.deepEqual(seenArgs, ['日', 500, 10]); + }); + + it('GET /api/stats/vocabulary/occurrences rejects missing required params', async () => { + const app = createStatsApp(createMockTracker()); + const res = await app.request('/api/stats/vocabulary/occurrences?headword=%E7%8C%AB'); + assert.equal(res.status, 400); + }); + + it('GET /api/stats/vocabulary clamps oversized limits', async () => { + let seenLimit = 0; + const app = createStatsApp( + createMockTracker({ + getVocabularyStats: async (limit?: number, _excludePos?: string[]) => { + seenLimit = limit ?? 0; + return VOCABULARY_STATS; + }, + }), + ); + + const res = await app.request('/api/stats/vocabulary?limit=999999'); + assert.equal(res.status, 200); + assert.equal(seenLimit, 500); + }); + + it('GET /api/stats/vocabulary passes excludePos to tracker', async () => { + let seenArgs: unknown[] = []; + const app = createStatsApp( + createMockTracker({ + getVocabularyStats: async (...args: unknown[]) => { + seenArgs = args; + return VOCABULARY_STATS; + }, + }), + ); + + const res = await app.request('/api/stats/vocabulary?excludePos=particle,auxiliary'); + assert.equal(res.status, 200); + assert.deepEqual(seenArgs, [100, ['particle', 'auxiliary']]); + }); + + it('GET /api/stats/vocabulary returns POS fields', async () => { + const app = createStatsApp(createMockTracker()); + const res = await app.request('/api/stats/vocabulary'); + assert.equal(res.status, 200); + const body = await res.json(); + assert.equal(body[0].partOfSpeech, 'verb'); + assert.equal(body[0].pos1, '動詞'); + assert.equal(body[0].pos2, '自立'); + assert.equal(body[0].pos3, null); + }); + + it('GET /api/stats/anime returns anime library', async () => { + const app = createStatsApp(createMockTracker()); + const res = await app.request('/api/stats/anime'); + assert.equal(res.status, 200); + const body = await res.json(); + assert.ok(Array.isArray(body)); + assert.equal(body[0].canonicalTitle, 'Little Witch Academia'); + }); + + it('GET /api/stats/anime/:animeId returns anime detail with episodes', async () => { + const app = createStatsApp(createMockTracker()); + const res = await app.request('/api/stats/anime/1'); + assert.equal(res.status, 200); + const body = await res.json(); + assert.ok(body.detail); + assert.equal(body.detail.canonicalTitle, 'Little Witch Academia'); + assert.ok(Array.isArray(body.episodes)); + assert.equal(body.episodes[0].videoId, 1); + }); + + it('GET /api/stats/anime/:animeId returns 404 for missing anime', async () => { + const app = createStatsApp(createMockTracker()); + const res = await app.request('/api/stats/anime/99999'); + assert.equal(res.status, 404); + }); + + it('GET /api/stats/anime/:animeId/cover returns cover art', async () => { + const app = createStatsApp(createMockTracker()); + const res = await app.request('/api/stats/anime/1/cover'); + assert.equal(res.status, 200); + assert.equal(res.headers.get('content-type'), 'image/jpeg'); + assert.equal(res.headers.get('cache-control'), 'public, max-age=86400'); + }); + + it('GET /api/stats/anime/:animeId/cover returns 404 for missing anime', async () => { + const app = createStatsApp(createMockTracker()); + const res = await app.request('/api/stats/anime/99999/cover'); + assert.equal(res.status, 404); + }); + + it('GET /api/stats/anime/:animeId/words returns top words for an anime', async () => { + let seenArgs: unknown[] = []; + const app = createStatsApp( + createMockTracker({ + getAnimeWords: async (...args: unknown[]) => { + seenArgs = args; + return ANIME_WORDS; + }, + }), + ); + + const res = await app.request('/api/stats/anime/1/words?limit=25'); + assert.equal(res.status, 200); + const body = await res.json(); + assert.ok(Array.isArray(body)); + assert.equal(body[0].headword, '魔法'); + assert.deepEqual(seenArgs, [1, 25]); + }); + + it('GET /api/stats/anime/:animeId/words rejects invalid animeId', async () => { + const app = createStatsApp(createMockTracker()); + const res = await app.request('/api/stats/anime/0/words'); + assert.equal(res.status, 400); + }); + + it('GET /api/stats/anime/:animeId/words clamps oversized limits', async () => { + let seenArgs: unknown[] = []; + const app = createStatsApp( + createMockTracker({ + getAnimeWords: async (...args: unknown[]) => { + seenArgs = args; + return ANIME_WORDS; + }, + }), + ); + + const res = await app.request('/api/stats/anime/1/words?limit=999999'); + assert.equal(res.status, 200); + assert.deepEqual(seenArgs, [1, 200]); + }); + + it('GET /api/stats/anime/:animeId/rollups returns daily rollups for an anime', async () => { + let seenArgs: unknown[] = []; + const app = createStatsApp( + createMockTracker({ + getAnimeDailyRollups: async (...args: unknown[]) => { + seenArgs = args; + return DAILY_ROLLUPS; + }, + }), + ); + + const res = await app.request('/api/stats/anime/1/rollups?limit=30'); + assert.equal(res.status, 200); + const body = await res.json(); + assert.ok(Array.isArray(body)); + assert.equal(body[0].totalSessions, 1); + assert.deepEqual(seenArgs, [1, 30]); + }); + + it('GET /api/stats/anime/:animeId/rollups rejects invalid animeId', async () => { + const app = createStatsApp(createMockTracker()); + const res = await app.request('/api/stats/anime/-1/rollups'); + assert.equal(res.status, 400); + }); + + it('GET /api/stats/anime/:animeId/rollups clamps oversized limits', async () => { + let seenArgs: unknown[] = []; + const app = createStatsApp( + createMockTracker({ + getAnimeDailyRollups: async (...args: unknown[]) => { + seenArgs = args; + return DAILY_ROLLUPS; + }, + }), + ); + + const res = await app.request('/api/stats/anime/1/rollups?limit=999999'); + assert.equal(res.status, 200); + assert.deepEqual(seenArgs, [1, 365]); + }); + + it('GET /api/stats/vocabulary/:wordId/detail returns word detail', async () => { + const app = createStatsApp(createMockTracker()); + const res = await app.request('/api/stats/vocabulary/1/detail'); + assert.equal(res.status, 200); + const body = await res.json(); + assert.ok(body.detail); + assert.equal(body.detail.headword, '猫'); + assert.equal(body.detail.wordId, 1); + assert.ok(Array.isArray(body.animeAppearances)); + assert.equal(body.animeAppearances[0].animeTitle, 'Little Witch Academia'); + assert.ok(Array.isArray(body.similarWords)); + assert.equal(body.similarWords[0].headword, '猫耳'); + }); + + it('GET /api/stats/vocabulary/:wordId/detail returns 404 for missing word', async () => { + const app = createStatsApp(createMockTracker()); + const res = await app.request('/api/stats/vocabulary/99999/detail'); + assert.equal(res.status, 404); + }); + + it('GET /api/stats/vocabulary/:wordId/detail returns 400 for invalid wordId', async () => { + const app = createStatsApp(createMockTracker()); + const res = await app.request('/api/stats/vocabulary/0/detail'); + assert.equal(res.status, 400); + }); + + it('GET /api/stats/kanji/:kanjiId/detail returns kanji detail', async () => { + const app = createStatsApp(createMockTracker()); + const res = await app.request('/api/stats/kanji/1/detail'); + assert.equal(res.status, 200); + const body = await res.json(); + assert.ok(body.detail); + assert.equal(body.detail.kanji, '日'); + assert.equal(body.detail.kanjiId, 1); + assert.ok(Array.isArray(body.animeAppearances)); + assert.equal(body.animeAppearances[0].animeTitle, 'Little Witch Academia'); + assert.ok(Array.isArray(body.words)); + assert.equal(body.words[0].headword, '日本'); + }); + + it('GET /api/stats/kanji/:kanjiId/detail returns 404 for missing kanji', async () => { + const app = createStatsApp(createMockTracker()); + const res = await app.request('/api/stats/kanji/99999/detail'); + assert.equal(res.status, 404); + }); + + it('GET /api/stats/kanji/:kanjiId/detail returns 400 for invalid kanjiId', async () => { + const app = createStatsApp(createMockTracker()); + const res = await app.request('/api/stats/kanji/0/detail'); + assert.equal(res.status, 400); + }); + + it('GET /api/stats/vocabulary/occurrences still works with detail routes present', async () => { + const app = createStatsApp(createMockTracker()); + const res = await app.request( + '/api/stats/vocabulary/occurrences?headword=%E7%8C%AB&word=%E7%8C%AB&reading=%E3%81%AD%E3%81%93', + ); + assert.equal(res.status, 200); + const body = await res.json(); + assert.ok(Array.isArray(body)); + }); + + it('GET /api/stats/kanji/occurrences still works with detail routes present', async () => { + const app = createStatsApp(createMockTracker()); + const res = await app.request('/api/stats/kanji/occurrences?kanji=%E6%97%A5'); + assert.equal(res.status, 200); + const body = await res.json(); + assert.ok(Array.isArray(body)); + }); + + it('GET /api/stats/episode/:videoId/detail returns episode detail', async () => { + const app = createStatsApp(createMockTracker()); + const res = await app.request('/api/stats/episode/1/detail'); + assert.equal(res.status, 200); + const body = await res.json(); + assert.ok(Array.isArray(body.sessions)); + assert.ok(Array.isArray(body.words)); + assert.ok(Array.isArray(body.cardEvents)); + assert.equal(body.cardEvents[0].noteIds[0], 12345); + }); + + it('GET /api/stats/episode/:videoId/detail returns 400 for invalid videoId', async () => { + const app = createStatsApp(createMockTracker()); + const res = await app.request('/api/stats/episode/0/detail'); + assert.equal(res.status, 400); + }); + + it('POST /api/stats/anki/browse returns 400 for missing noteId', async () => { + const app = createStatsApp(createMockTracker()); + const res = await app.request('/api/stats/anki/browse', { method: 'POST' }); + assert.equal(res.status, 400); + }); + + it('serves stats index and asset files from absolute static dir paths', async () => { + await withTempDir(async (dir) => { + const assetDir = path.join(dir, 'assets'); + fs.mkdirSync(assetDir, { recursive: true }); + fs.writeFileSync( + path.join(dir, 'index.html'), + '
', + ); + fs.writeFileSync(path.join(assetDir, 'app.js'), 'console.log("stats ok");'); + + const app = createStatsApp(createMockTracker(), { staticDir: dir }); + const indexRes = await app.request('/'); + assert.equal(indexRes.status, 200); + assert.match(await indexRes.text(), /assets\/app\.js/); + + const assetRes = await app.request('/assets/app.js'); + assert.equal(assetRes.status, 200); + assert.equal(assetRes.headers.get('content-type'), 'text/javascript; charset=utf-8'); + assert.match(await assetRes.text(), /stats ok/); + }); + }); + + it('fetches and serves missing cover art on demand', async () => { + let ensureCalls = 0; + let hasCover = false; + const app = createStatsApp( + createMockTracker({ + getCoverArt: async () => + hasCover + ? { + videoId: 1, + anilistId: 1, + coverUrl: 'https://example.com/cover.jpg', + coverBlob: Buffer.from([0xff, 0xd8, 0xff, 0xd9]), + titleRomaji: 'Test', + titleEnglish: 'Test', + episodesTotal: 12, + fetchedAtMs: Date.now(), + } + : null, + ensureCoverArt: async () => { + ensureCalls += 1; + hasCover = true; + return true; + }, + }), + ); + + const res = await app.request('/api/stats/media/1/cover'); + assert.equal(res.status, 200); + assert.equal(res.headers.get('content-type'), 'image/jpeg'); + assert.equal(ensureCalls, 1); + }); +}); diff --git a/src/core/services/app-lifecycle.test.ts b/src/core/services/app-lifecycle.test.ts index 61862f0..b75466f 100644 --- a/src/core/services/app-lifecycle.test.ts +++ b/src/core/services/app-lifecycle.test.ts @@ -34,6 +34,7 @@ function makeArgs(overrides: Partial = {}): CliArgs { anilistSetup: false, anilistRetryQueue: false, dictionary: false, + stats: false, jellyfin: false, jellyfinLogin: false, jellyfinLogout: false, diff --git a/src/core/services/cli-command.test.ts b/src/core/services/cli-command.test.ts index 22876aa..17fc5fc 100644 --- a/src/core/services/cli-command.test.ts +++ b/src/core/services/cli-command.test.ts @@ -34,6 +34,7 @@ function makeArgs(overrides: Partial = {}): CliArgs { anilistSetup: false, anilistRetryQueue: false, dictionary: false, + stats: false, jellyfin: false, jellyfinLogin: false, jellyfinLogout: false, @@ -177,6 +178,9 @@ function createDeps(overrides: Partial = {}) { mediaTitle: 'Test', entryCount: 10, }), + runStatsCommand: async () => { + calls.push('runStatsCommand'); + }, runJellyfinCommand: async () => { calls.push('runJellyfinCommand'); }, @@ -249,6 +253,21 @@ test('handleCliCommand opens first-run setup window for --setup', () => { assert.equal(calls.includes('openYomitanSettingsDelayed:1000'), false); }); +test('handleCliCommand dispatches stats command without overlay startup', async () => { + const { deps, calls } = createDeps({ + runStatsCommand: async () => { + calls.push('runStatsCommand'); + }, + }); + + handleCliCommand(makeArgs({ stats: true }), 'initial', deps); + await Promise.resolve(); + + assert.ok(calls.includes('runStatsCommand')); + assert.equal(calls.includes('initializeOverlayRuntime'), false); + assert.equal(calls.includes('connectMpvClient'), false); +}); + test('handleCliCommand applies cli log level for second-instance commands', () => { const { deps, calls } = createDeps({ setLogLevel: (level) => { diff --git a/src/core/services/cli-command.ts b/src/core/services/cli-command.ts index 05a91b5..7b9c2a2 100644 --- a/src/core/services/cli-command.ts +++ b/src/core/services/cli-command.ts @@ -61,6 +61,7 @@ export interface CliCommandServiceDeps { mediaTitle: string; entryCount: number; }>; + runStatsCommand: (args: CliArgs, source: CliCommandSource) => Promise; runJellyfinCommand: (args: CliArgs) => Promise; printHelp: () => void; hasMainWindow: () => boolean; @@ -154,6 +155,7 @@ export interface CliCommandDepsRuntimeOptions { }; jellyfin: { openSetup: () => void; + runStatsCommand: (args: CliArgs, source: CliCommandSource) => Promise; runCommand: (args: CliArgs) => Promise; }; ui: UiCliRuntime; @@ -222,6 +224,7 @@ export function createCliCommandDepsRuntime( getAnilistQueueStatus: options.anilist.getQueueStatus, retryAnilistQueue: options.anilist.retryQueueNow, generateCharacterDictionary: options.dictionary.generate, + runStatsCommand: options.jellyfin.runStatsCommand, runJellyfinCommand: options.jellyfin.runCommand, printHelp: options.ui.printHelp, hasMainWindow: options.app.hasMainWindow, @@ -410,6 +413,8 @@ export function handleCliCommand( deps.stopApp(); } }); + } else if (args.stats) { + void deps.runStatsCommand(args, source); } else if (args.anilistRetryQueue) { const queueStatus = deps.getAnilistQueueStatus(); deps.log( diff --git a/src/core/services/ipc.test.ts b/src/core/services/ipc.test.ts index 69d40ad..328788a 100644 --- a/src/core/services/ipc.test.ts +++ b/src/core/services/ipc.test.ts @@ -1,7 +1,7 @@ import test from 'node:test'; import assert from 'node:assert/strict'; -import { createIpcDepsRuntime, registerIpcHandlers } from './ipc'; +import { createIpcDepsRuntime, registerIpcHandlers, type IpcServiceDeps } from './ipc'; import { IPC_CHANNELS } from '../../shared/ipc/contracts'; interface FakeIpcRegistrar { @@ -33,6 +33,90 @@ function createFakeIpcRegistrar(): { }; } +function createRegisterIpcDeps(overrides: Partial = {}): IpcServiceDeps { + return { + onOverlayModalClosed: () => {}, + openYomitanSettings: () => {}, + quitApp: () => {}, + toggleDevTools: () => {}, + getVisibleOverlayVisibility: () => false, + toggleVisibleOverlay: () => {}, + tokenizeCurrentSubtitle: async () => null, + getCurrentSubtitleRaw: () => '', + getCurrentSubtitleAss: () => '', + getPlaybackPaused: () => false, + getSubtitlePosition: () => null, + getSubtitleStyle: () => null, + saveSubtitlePosition: () => {}, + getMecabStatus: () => ({ available: false, enabled: false, path: null }), + setMecabEnabled: () => {}, + handleMpvCommand: () => {}, + getKeybindings: () => [], + getConfiguredShortcuts: () => ({}), + getStatsToggleKey: () => 'Backquote', + getControllerConfig: () => + ({ + enabled: true, + preferredGamepadId: '', + preferredGamepadLabel: '', + smoothScroll: true, + scrollPixelsPerSecond: 960, + horizontalJumpPixels: 160, + stickDeadzone: 0.2, + triggerInputMode: 'auto', + triggerDeadzone: 0.5, + repeatDelayMs: 220, + repeatIntervalMs: 80, + buttonIndices: { + select: 6, + buttonSouth: 0, + buttonEast: 1, + buttonWest: 2, + buttonNorth: 3, + leftShoulder: 4, + rightShoulder: 5, + leftStickPress: 9, + rightStickPress: 10, + leftTrigger: 6, + rightTrigger: 7, + }, + bindings: { + toggleLookup: 'buttonSouth', + closeLookup: 'buttonEast', + toggleKeyboardOnlyMode: 'buttonNorth', + mineCard: 'buttonWest', + quitMpv: 'select', + previousAudio: 'leftShoulder', + nextAudio: 'rightShoulder', + playCurrentAudio: 'rightTrigger', + toggleMpvPause: 'leftTrigger', + leftStickHorizontal: 'leftStickX', + leftStickVertical: 'leftStickY', + rightStickHorizontal: 'rightStickX', + rightStickVertical: 'rightStickY', + }, + }) as never, + saveControllerPreference: async () => {}, + getSecondarySubMode: () => 'hover', + getCurrentSecondarySub: () => '', + focusMainWindow: () => {}, + runSubsyncManual: async () => ({ ok: true, message: 'ok' }), + getAnkiConnectStatus: () => false, + getRuntimeOptions: () => [], + setRuntimeOption: () => ({ ok: true }), + cycleRuntimeOption: () => ({ ok: true }), + reportOverlayContentBounds: () => {}, + getAnilistStatus: () => ({}), + clearAnilistToken: () => {}, + openAnilistSetup: () => {}, + getAnilistQueueStatus: () => ({}), + retryAnilistQueueNow: async () => ({ ok: true, message: 'ok' }), + appendClipboardVideoToQueue: () => ({ ok: true, message: 'ok' }), + immersionTracker: null, + ...overrides, + }; +} + test('createIpcDepsRuntime wires AniList handlers', async () => { const calls: string[] = []; const deps = createIpcDepsRuntime({ @@ -53,6 +137,7 @@ test('createIpcDepsRuntime wires AniList handlers', async () => { handleMpvCommand: () => {}, getKeybindings: () => [], getConfiguredShortcuts: () => ({}), + getStatsToggleKey: () => 'Backquote', getControllerConfig: () => ({ enabled: true, preferredGamepadId: '', @@ -159,6 +244,7 @@ test('registerIpcHandlers rejects malformed runtime-option payloads', async () = handleMpvCommand: () => {}, getKeybindings: () => [], getConfiguredShortcuts: () => ({}), + getStatsToggleKey: () => 'Backquote', getControllerConfig: () => ({ enabled: true, preferredGamepadId: '', @@ -266,6 +352,90 @@ test('registerIpcHandlers rejects malformed runtime-option payloads', async () = ); }); +test('registerIpcHandlers returns empty stats overview shape without a tracker', async () => { + const { registrar, handlers } = createFakeIpcRegistrar(); + registerIpcHandlers(createRegisterIpcDeps(), registrar); + + const overviewHandler = handlers.handle.get(IPC_CHANNELS.request.statsGetOverview); + assert.ok(overviewHandler); + assert.deepEqual(await overviewHandler!({}), { + sessions: [], + rollups: [], + hints: { + totalSessions: 0, + activeSessions: 0, + }, + }); +}); + +test('registerIpcHandlers validates and clamps stats request limits', async () => { + const { registrar, handlers } = createFakeIpcRegistrar(); + const calls: Array<[string, number, number?]> = []; + + registerIpcHandlers( + createRegisterIpcDeps({ + immersionTracker: { + getSessionSummaries: async (limit = 0) => { + calls.push(['sessions', limit]); + return []; + }, + getDailyRollups: async (limit = 0) => { + calls.push(['daily', limit]); + return []; + }, + getMonthlyRollups: async (limit = 0) => { + calls.push(['monthly', limit]); + return []; + }, + getQueryHints: async () => ({ totalSessions: 0, activeSessions: 0, episodesToday: 0, activeAnimeCount: 0 }), + getSessionTimeline: async (sessionId: number, limit = 0) => { + calls.push(['timeline', limit, sessionId]); + return []; + }, + getSessionEvents: async (sessionId: number, limit = 0) => { + calls.push(['events', limit, sessionId]); + return []; + }, + getVocabularyStats: async (limit = 0) => { + calls.push(['vocabulary', limit]); + return []; + }, + getKanjiStats: async (limit = 0) => { + calls.push(['kanji', limit]); + return []; + }, + getMediaLibrary: async () => [], + getMediaDetail: async () => null, + getMediaSessions: async () => [], + getMediaDailyRollups: async () => [], + getCoverArt: async () => null, + }, + }), + registrar, + ); + + await handlers.handle.get(IPC_CHANNELS.request.statsGetDailyRollups)!({}, -1); + await handlers.handle.get(IPC_CHANNELS.request.statsGetMonthlyRollups)!( + {}, + Number.POSITIVE_INFINITY, + ); + await handlers.handle.get(IPC_CHANNELS.request.statsGetSessions)!({}, 9999); + await handlers.handle.get(IPC_CHANNELS.request.statsGetSessionTimeline)!({}, 7, 12.5); + await handlers.handle.get(IPC_CHANNELS.request.statsGetSessionEvents)!({}, 7, 0); + await handlers.handle.get(IPC_CHANNELS.request.statsGetVocabulary)!({}, 1000); + await handlers.handle.get(IPC_CHANNELS.request.statsGetKanji)!({}, NaN); + + assert.deepEqual(calls, [ + ['daily', 60], + ['monthly', 24], + ['sessions', 500], + ['timeline', 200, 7], + ['events', 500, 7], + ['vocabulary', 500], + ['kanji', 100], + ]); +}); + test('registerIpcHandlers ignores malformed fire-and-forget payloads', () => { const { registrar, handlers } = createFakeIpcRegistrar(); const saves: unknown[] = []; @@ -299,6 +469,7 @@ test('registerIpcHandlers ignores malformed fire-and-forget payloads', () => { handleMpvCommand: () => {}, getKeybindings: () => [], getConfiguredShortcuts: () => ({}), + getStatsToggleKey: () => 'Backquote', getControllerConfig: () => ({ enabled: true, preferredGamepadId: '', @@ -400,6 +571,7 @@ test('registerIpcHandlers awaits saveControllerPreference through request-respon handleMpvCommand: () => {}, getKeybindings: () => [], getConfiguredShortcuts: () => ({}), + getStatsToggleKey: () => 'Backquote', getControllerConfig: () => ({ enabled: true, preferredGamepadId: '', @@ -508,6 +680,7 @@ test('registerIpcHandlers rejects malformed controller preference payloads', asy handleMpvCommand: () => {}, getKeybindings: () => [], getConfiguredShortcuts: () => ({}), + getStatsToggleKey: () => 'Backquote', getControllerConfig: () => ({ enabled: true, preferredGamepadId: '', diff --git a/src/core/services/ipc.ts b/src/core/services/ipc.ts index 0568950..15e776b 100644 --- a/src/core/services/ipc.ts +++ b/src/core/services/ipc.ts @@ -48,6 +48,7 @@ export interface IpcServiceDeps { handleMpvCommand: (command: Array) => void; getKeybindings: () => unknown; getConfiguredShortcuts: () => unknown; + getStatsToggleKey: () => string; getControllerConfig: () => ResolvedControllerConfig; saveControllerPreference: (update: ControllerPreferenceUpdate) => void | Promise; getSecondarySubMode: () => unknown; @@ -65,6 +66,21 @@ export interface IpcServiceDeps { getAnilistQueueStatus: () => unknown; retryAnilistQueueNow: () => Promise<{ ok: boolean; message: string }>; appendClipboardVideoToQueue: () => { ok: boolean; message: string }; + immersionTracker?: { + getSessionSummaries: (limit?: number) => Promise; + getDailyRollups: (limit?: number) => Promise; + getMonthlyRollups: (limit?: number) => Promise; + getQueryHints: () => Promise<{ totalSessions: number; activeSessions: number; episodesToday: number; activeAnimeCount: number }>; + getSessionTimeline: (sessionId: number, limit?: number) => Promise; + getSessionEvents: (sessionId: number, limit?: number) => Promise; + getVocabularyStats: (limit?: number) => Promise; + getKanjiStats: (limit?: number) => Promise; + getMediaLibrary: () => Promise; + getMediaDetail: (videoId: number) => Promise; + getMediaSessions: (videoId: number, limit?: number) => Promise; + getMediaDailyRollups: (videoId: number, limit?: number) => Promise; + getCoverArt: (videoId: number) => Promise; + } | null; } interface WindowLike { @@ -113,6 +129,7 @@ export interface IpcDepsRuntimeOptions { handleMpvCommand: (command: Array) => void; getKeybindings: () => unknown; getConfiguredShortcuts: () => unknown; + getStatsToggleKey: () => string; getControllerConfig: () => ResolvedControllerConfig; saveControllerPreference: (update: ControllerPreferenceUpdate) => void | Promise; getSecondarySubMode: () => unknown; @@ -130,6 +147,7 @@ export interface IpcDepsRuntimeOptions { getAnilistQueueStatus: () => unknown; retryAnilistQueueNow: () => Promise<{ ok: boolean; message: string }>; appendClipboardVideoToQueue: () => { ok: boolean; message: string }; + getImmersionTracker?: () => IpcServiceDeps['immersionTracker']; } export function createIpcDepsRuntime(options: IpcDepsRuntimeOptions): IpcServiceDeps { @@ -166,6 +184,7 @@ export function createIpcDepsRuntime(options: IpcDepsRuntimeOptions): IpcService handleMpvCommand: options.handleMpvCommand, getKeybindings: options.getKeybindings, getConfiguredShortcuts: options.getConfiguredShortcuts, + getStatsToggleKey: options.getStatsToggleKey, getControllerConfig: options.getControllerConfig, saveControllerPreference: options.saveControllerPreference, getSecondarySubMode: options.getSecondarySubMode, @@ -187,10 +206,24 @@ export function createIpcDepsRuntime(options: IpcDepsRuntimeOptions): IpcService getAnilistQueueStatus: options.getAnilistQueueStatus, retryAnilistQueueNow: options.retryAnilistQueueNow, appendClipboardVideoToQueue: options.appendClipboardVideoToQueue, + get immersionTracker() { + return options.getImmersionTracker?.() ?? null; + }, }; } export function registerIpcHandlers(deps: IpcServiceDeps, ipc: IpcMainRegistrar = ipcMain): void { + const parsePositiveIntLimit = ( + value: unknown, + defaultValue: number, + maxValue: number, + ): number => { + if (!Number.isInteger(value) || (value as number) < 1) { + return defaultValue; + } + return Math.min(value as number, maxValue); + }; + ipc.on( IPC_CHANNELS.command.setIgnoreMouseEvents, (event: unknown, ignore: unknown, options: unknown = {}) => { @@ -299,6 +332,10 @@ export function registerIpcHandlers(deps: IpcServiceDeps, ipc: IpcMainRegistrar return deps.getConfiguredShortcuts(); }); + ipc.handle(IPC_CHANNELS.request.getStatsToggleKey, () => { + return deps.getStatsToggleKey(); + }); + ipc.handle(IPC_CHANNELS.request.getControllerConfig, () => { return deps.getControllerConfig(); }); @@ -384,4 +421,106 @@ export function registerIpcHandlers(deps: IpcServiceDeps, ipc: IpcMainRegistrar ipc.handle(IPC_CHANNELS.request.appendClipboardVideoToQueue, () => { return deps.appendClipboardVideoToQueue(); }); + + // Stats request handlers + ipc.handle(IPC_CHANNELS.request.statsGetOverview, async () => { + const tracker = deps.immersionTracker; + if (!tracker) { + return { + sessions: [], + rollups: [], + hints: { + totalSessions: 0, + activeSessions: 0, + }, + }; + } + const [sessions, rollups, hints] = await Promise.all([ + tracker.getSessionSummaries(5), + tracker.getDailyRollups(14), + tracker.getQueryHints(), + ]); + return { sessions, rollups, hints }; + }); + + ipc.handle(IPC_CHANNELS.request.statsGetDailyRollups, async (_event, limit: unknown) => { + const parsedLimit = parsePositiveIntLimit(limit, 60, 500); + return deps.immersionTracker?.getDailyRollups(parsedLimit) ?? []; + }); + + ipc.handle(IPC_CHANNELS.request.statsGetMonthlyRollups, async (_event, limit: unknown) => { + const parsedLimit = parsePositiveIntLimit(limit, 24, 120); + return deps.immersionTracker?.getMonthlyRollups(parsedLimit) ?? []; + }); + + ipc.handle(IPC_CHANNELS.request.statsGetSessions, async (_event, limit: unknown) => { + const parsedLimit = parsePositiveIntLimit(limit, 50, 500); + return deps.immersionTracker?.getSessionSummaries(parsedLimit) ?? []; + }); + + ipc.handle( + IPC_CHANNELS.request.statsGetSessionTimeline, + async (_event, sessionId: unknown, limit: unknown) => { + if (typeof sessionId !== 'number') return []; + const parsedLimit = parsePositiveIntLimit(limit, 200, 1000); + return deps.immersionTracker?.getSessionTimeline(sessionId, parsedLimit) ?? []; + }, + ); + + ipc.handle( + IPC_CHANNELS.request.statsGetSessionEvents, + async (_event, sessionId: unknown, limit: unknown) => { + if (typeof sessionId !== 'number') return []; + const parsedLimit = parsePositiveIntLimit(limit, 500, 1000); + return deps.immersionTracker?.getSessionEvents(sessionId, parsedLimit) ?? []; + }, + ); + + ipc.handle(IPC_CHANNELS.request.statsGetVocabulary, async (_event, limit: unknown) => { + const parsedLimit = parsePositiveIntLimit(limit, 100, 500); + return deps.immersionTracker?.getVocabularyStats(parsedLimit) ?? []; + }); + + ipc.handle(IPC_CHANNELS.request.statsGetKanji, async (_event, limit: unknown) => { + const parsedLimit = parsePositiveIntLimit(limit, 100, 500); + return deps.immersionTracker?.getKanjiStats(parsedLimit) ?? []; + }); + + ipc.handle(IPC_CHANNELS.request.statsGetMediaLibrary, async () => { + return deps.immersionTracker?.getMediaLibrary() ?? []; + }); + + ipc.handle( + IPC_CHANNELS.request.statsGetMediaDetail, + async (_event, videoId: unknown) => { + if (typeof videoId !== 'number') return null; + return deps.immersionTracker?.getMediaDetail(videoId) ?? null; + }, + ); + + ipc.handle( + IPC_CHANNELS.request.statsGetMediaSessions, + async (_event, videoId: unknown, limit: unknown) => { + if (typeof videoId !== 'number') return []; + const parsedLimit = parsePositiveIntLimit(limit, 100, 500); + return deps.immersionTracker?.getMediaSessions(videoId, parsedLimit) ?? []; + }, + ); + + ipc.handle( + IPC_CHANNELS.request.statsGetMediaDailyRollups, + async (_event, videoId: unknown, limit: unknown) => { + if (typeof videoId !== 'number') return []; + const parsedLimit = parsePositiveIntLimit(limit, 90, 500); + return deps.immersionTracker?.getMediaDailyRollups(videoId, parsedLimit) ?? []; + }, + ); + + ipc.handle( + IPC_CHANNELS.request.statsGetMediaCover, + async (_event, videoId: unknown) => { + if (typeof videoId !== 'number') return null; + return deps.immersionTracker?.getCoverArt(videoId) ?? null; + }, + ); } diff --git a/src/core/services/mpv-properties.ts b/src/core/services/mpv-properties.ts index bd21078..089270f 100644 --- a/src/core/services/mpv-properties.ts +++ b/src/core/services/mpv-properties.ts @@ -59,6 +59,7 @@ const MPV_SUBTITLE_PROPERTY_OBSERVATIONS: string[] = [ 'sub-ass-override', 'sub-use-margins', 'pause', + 'duration', 'media-title', 'secondary-sub-visibility', 'sub-visibility', diff --git a/src/core/services/mpv-protocol.test.ts b/src/core/services/mpv-protocol.test.ts index 7c1639f..251b1a9 100644 --- a/src/core/services/mpv-protocol.test.ts +++ b/src/core/services/mpv-protocol.test.ts @@ -87,6 +87,7 @@ function createDeps(overrides: Partial = {}): { getPauseAtTime: () => null, setPauseAtTime: () => {}, emitTimePosChange: () => {}, + emitDurationChange: () => {}, emitPauseChange: () => {}, autoLoadSecondarySubTrack: () => {}, setCurrentVideoPath: () => {}, diff --git a/src/core/services/mpv-protocol.ts b/src/core/services/mpv-protocol.ts index d35288e..028d084 100644 --- a/src/core/services/mpv-protocol.ts +++ b/src/core/services/mpv-protocol.ts @@ -61,6 +61,7 @@ export interface MpvProtocolHandleMessageDeps { emitMediaPathChange: (payload: { path: string }) => void; emitMediaTitleChange: (payload: { title: string | null }) => void; emitTimePosChange: (payload: { time: number }) => void; + emitDurationChange: (payload: { duration: number }) => void; emitPauseChange: (payload: { paused: boolean }) => void; emitSubtitleMetricsChange: (payload: Partial) => void; setCurrentSecondarySubText: (text: string) => void; @@ -172,6 +173,11 @@ export async function dispatchMpvProtocolMessage( deps.setPauseAtTime(null); deps.sendCommand({ command: ['set_property', 'pause', true] }); } + } else if (msg.name === 'duration') { + const duration = typeof msg.data === 'number' ? msg.data : 0; + if (duration > 0) { + deps.emitDurationChange({ duration }); + } } else if (msg.name === 'pause') { deps.emitPauseChange({ paused: asBoolean(msg.data, false) }); } else if (msg.name === 'media-title') { diff --git a/src/core/services/mpv.ts b/src/core/services/mpv.ts index 8fb84ac..a5164e0 100644 --- a/src/core/services/mpv.ts +++ b/src/core/services/mpv.ts @@ -115,6 +115,7 @@ export interface MpvIpcClientEventMap { 'subtitle-ass-change': { text: string }; 'subtitle-timing': { text: string; start: number; end: number }; 'time-pos-change': { time: number }; + 'duration-change': { duration: number }; 'pause-change': { paused: boolean }; 'secondary-subtitle-change': { text: string }; 'media-path-change': { path: string }; @@ -314,6 +315,9 @@ export class MpvIpcClient implements MpvClient { emitTimePosChange: (payload) => { this.emit('time-pos-change', payload); }, + emitDurationChange: (payload) => { + this.emit('duration-change', payload); + }, emitPauseChange: (payload) => { this.playbackPaused = payload.paused; this.emit('pause-change', payload); diff --git a/src/core/services/startup-bootstrap.test.ts b/src/core/services/startup-bootstrap.test.ts index 1e89903..94b74f1 100644 --- a/src/core/services/startup-bootstrap.test.ts +++ b/src/core/services/startup-bootstrap.test.ts @@ -34,6 +34,7 @@ function makeArgs(overrides: Partial = {}): CliArgs { anilistSetup: false, anilistRetryQueue: false, dictionary: false, + stats: false, jellyfin: false, jellyfinLogin: false, jellyfinLogout: false, diff --git a/src/core/services/startup.test.ts b/src/core/services/startup.test.ts new file mode 100644 index 0000000..60e00fb --- /dev/null +++ b/src/core/services/startup.test.ts @@ -0,0 +1,95 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { runAppReadyRuntime } from './startup'; + +test('runAppReadyRuntime minimal startup skips Yomitan and first-run setup while still handling CLI args', async () => { + const calls: string[] = []; + + await runAppReadyRuntime({ + ensureDefaultConfigBootstrap: () => { + calls.push('bootstrap'); + }, + loadSubtitlePosition: () => { + calls.push('load-subtitle-position'); + }, + resolveKeybindings: () => { + calls.push('resolve-keybindings'); + }, + createMpvClient: () => { + calls.push('create-mpv'); + }, + reloadConfig: () => { + calls.push('reload-config'); + }, + getResolvedConfig: () => ({}), + getConfigWarnings: () => [], + logConfigWarning: () => { + calls.push('config-warning'); + }, + setLogLevel: () => { + calls.push('set-log-level'); + }, + initRuntimeOptionsManager: () => { + calls.push('init-runtime-options'); + }, + setSecondarySubMode: () => { + calls.push('set-secondary-sub-mode'); + }, + defaultSecondarySubMode: 'hover', + defaultWebsocketPort: 0, + defaultAnnotationWebsocketPort: 0, + defaultTexthookerPort: 0, + hasMpvWebsocketPlugin: () => false, + startSubtitleWebsocket: () => { + calls.push('subtitle-ws'); + }, + startAnnotationWebsocket: () => { + calls.push('annotation-ws'); + }, + startTexthooker: () => { + calls.push('texthooker'); + }, + log: () => { + calls.push('log'); + }, + createMecabTokenizerAndCheck: async () => { + calls.push('mecab'); + }, + createSubtitleTimingTracker: () => { + calls.push('subtitle-timing'); + }, + createImmersionTracker: () => { + calls.push('immersion'); + }, + startJellyfinRemoteSession: async () => { + calls.push('jellyfin'); + }, + loadYomitanExtension: async () => { + calls.push('load-yomitan'); + }, + handleFirstRunSetup: async () => { + calls.push('first-run'); + }, + prewarmSubtitleDictionaries: async () => { + calls.push('prewarm'); + }, + startBackgroundWarmups: () => { + calls.push('warmups'); + }, + texthookerOnlyMode: false, + shouldAutoInitializeOverlayRuntimeFromConfig: () => false, + setVisibleOverlayVisible: () => { + calls.push('visible-overlay'); + }, + initializeOverlayRuntime: () => { + calls.push('init-overlay'); + }, + handleInitialArgs: () => { + calls.push('handle-initial-args'); + }, + shouldUseMinimalStartup: () => true, + shouldSkipHeavyStartup: () => false, + }); + + assert.deepEqual(calls, ['bootstrap', 'reload-config', 'handle-initial-args']); +}); diff --git a/src/core/services/startup.ts b/src/core/services/startup.ts index 67d78bf..52198b0 100644 --- a/src/core/services/startup.ts +++ b/src/core/services/startup.ts @@ -135,6 +135,7 @@ export interface AppReadyRuntimeDeps { logDebug?: (message: string) => void; onCriticalConfigErrors?: (errors: string[]) => void; now?: () => number; + shouldUseMinimalStartup?: () => boolean; shouldSkipHeavyStartup?: () => boolean; } @@ -183,6 +184,12 @@ export async function runAppReadyRuntime(deps: AppReadyRuntimeDeps): Promise Date.now()); const startupStartedAtMs = now(); deps.ensureDefaultConfigBootstrap(); + if (deps.shouldUseMinimalStartup?.()) { + deps.reloadConfig(); + deps.handleInitialArgs(); + return; + } + if (deps.shouldSkipHeavyStartup?.()) { await deps.loadYomitanExtension(); deps.reloadConfig(); diff --git a/src/core/services/stats-server.ts b/src/core/services/stats-server.ts new file mode 100644 index 0000000..1fb61bc --- /dev/null +++ b/src/core/services/stats-server.ts @@ -0,0 +1,372 @@ +import { Hono } from 'hono'; +import { serve } from '@hono/node-server'; +import type { ImmersionTrackerService } from './immersion-tracker-service.js'; +import { extname, resolve, sep } from 'node:path'; +import { readFileSync, existsSync, statSync } from 'node:fs'; + +function parseIntQuery(raw: string | undefined, fallback: number, maxLimit?: number): number { + if (raw === undefined) return fallback; + const n = Number(raw); + if (!Number.isFinite(n) || n < 0) { + return fallback; + } + const parsed = Math.floor(n); + return maxLimit === undefined ? parsed : Math.min(parsed, maxLimit); +} + +export interface StatsServerConfig { + port: number; + staticDir: string; // Path to stats/dist/ + tracker: ImmersionTrackerService; +} + +const STATS_STATIC_CONTENT_TYPES: Record = { + '.css': 'text/css; charset=utf-8', + '.gif': 'image/gif', + '.html': 'text/html; charset=utf-8', + '.ico': 'image/x-icon', + '.jpeg': 'image/jpeg', + '.jpg': 'image/jpeg', + '.js': 'text/javascript; charset=utf-8', + '.json': 'application/json; charset=utf-8', + '.mjs': 'text/javascript; charset=utf-8', + '.png': 'image/png', + '.svg': 'image/svg+xml', + '.txt': 'text/plain; charset=utf-8', + '.webp': 'image/webp', + '.woff': 'font/woff', + '.woff2': 'font/woff2', +}; + +function resolveStatsStaticPath(staticDir: string, requestPath: string): string | null { + const normalizedPath = requestPath.replace(/^\/+/, '') || 'index.html'; + const decodedPath = decodeURIComponent(normalizedPath); + const absoluteStaticDir = resolve(staticDir); + const absolutePath = resolve(absoluteStaticDir, decodedPath); + if (absolutePath !== absoluteStaticDir && !absolutePath.startsWith(`${absoluteStaticDir}${sep}`)) { + return null; + } + if (!existsSync(absolutePath)) { + return null; + } + const stats = statSync(absolutePath); + if (!stats.isFile()) { + return null; + } + return absolutePath; +} + +function createStatsStaticResponse(staticDir: string, requestPath: string): Response | null { + const absolutePath = resolveStatsStaticPath(staticDir, requestPath); + if (!absolutePath) { + return null; + } + + const extension = extname(absolutePath).toLowerCase(); + const contentType = + STATS_STATIC_CONTENT_TYPES[extension] ?? 'application/octet-stream'; + const body = readFileSync(absolutePath); + return new Response(body, { + headers: { + 'Content-Type': contentType, + 'Cache-Control': absolutePath.endsWith('index.html') + ? 'no-cache' + : 'public, max-age=31536000, immutable', + }, + }); +} + +export function createStatsApp( + tracker: ImmersionTrackerService, + options?: { staticDir?: string }, +) { + const app = new Hono(); + + app.get('/api/stats/overview', async (c) => { + const [sessions, rollups, hints] = await Promise.all([ + tracker.getSessionSummaries(5), + tracker.getDailyRollups(14), + tracker.getQueryHints(), + ]); + return c.json({ sessions, rollups, hints }); + }); + + app.get('/api/stats/daily-rollups', async (c) => { + const limit = parseIntQuery(c.req.query('limit'), 60, 500); + const rollups = await tracker.getDailyRollups(limit); + return c.json(rollups); + }); + + app.get('/api/stats/monthly-rollups', async (c) => { + const limit = parseIntQuery(c.req.query('limit'), 24, 120); + const rollups = await tracker.getMonthlyRollups(limit); + return c.json(rollups); + }); + + app.get('/api/stats/streak-calendar', async (c) => { + const days = parseIntQuery(c.req.query('days'), 90, 365); + return c.json(await tracker.getStreakCalendar(days)); + }); + + app.get('/api/stats/trends/episodes-per-day', async (c) => { + const limit = parseIntQuery(c.req.query('limit'), 90, 365); + return c.json(await tracker.getEpisodesPerDay(limit)); + }); + + app.get('/api/stats/trends/new-anime-per-day', async (c) => { + const limit = parseIntQuery(c.req.query('limit'), 90, 365); + return c.json(await tracker.getNewAnimePerDay(limit)); + }); + + app.get('/api/stats/trends/watch-time-per-anime', async (c) => { + const limit = parseIntQuery(c.req.query('limit'), 90, 365); + return c.json(await tracker.getWatchTimePerAnime(limit)); + }); + + app.get('/api/stats/sessions', async (c) => { + const limit = parseIntQuery(c.req.query('limit'), 50, 500); + const sessions = await tracker.getSessionSummaries(limit); + return c.json(sessions); + }); + + app.get('/api/stats/sessions/:id/timeline', async (c) => { + const id = parseIntQuery(c.req.query('id') ?? c.req.param('id'), 0); + if (id <= 0) return c.json([], 400); + const limit = parseIntQuery(c.req.query('limit'), 200, 1000); + const timeline = await tracker.getSessionTimeline(id, limit); + return c.json(timeline); + }); + + app.get('/api/stats/sessions/:id/events', async (c) => { + const id = parseIntQuery(c.req.query('id') ?? c.req.param('id'), 0); + if (id <= 0) return c.json([], 400); + const limit = parseIntQuery(c.req.query('limit'), 500, 1000); + const events = await tracker.getSessionEvents(id, limit); + return c.json(events); + }); + + app.get('/api/stats/vocabulary', async (c) => { + const limit = parseIntQuery(c.req.query('limit'), 100, 500); + const excludePos = c.req.query('excludePos')?.split(',').filter(Boolean); + const vocab = await tracker.getVocabularyStats(limit, excludePos); + return c.json(vocab); + }); + + app.get('/api/stats/vocabulary/occurrences', async (c) => { + const headword = (c.req.query('headword') ?? '').trim(); + const word = (c.req.query('word') ?? '').trim(); + const reading = (c.req.query('reading') ?? '').trim(); + if (!headword || !word) { + return c.json([], 400); + } + const limit = parseIntQuery(c.req.query('limit'), 50, 500); + const offset = parseIntQuery(c.req.query('offset'), 0, 10_000); + const occurrences = await tracker.getWordOccurrences(headword, word, reading, limit, offset); + return c.json(occurrences); + }); + + app.get('/api/stats/kanji', async (c) => { + const limit = parseIntQuery(c.req.query('limit'), 100, 500); + const kanji = await tracker.getKanjiStats(limit); + return c.json(kanji); + }); + + app.get('/api/stats/kanji/occurrences', async (c) => { + const kanji = (c.req.query('kanji') ?? '').trim(); + if (!kanji) { + return c.json([], 400); + } + const limit = parseIntQuery(c.req.query('limit'), 50, 500); + const offset = parseIntQuery(c.req.query('offset'), 0, 10_000); + const occurrences = await tracker.getKanjiOccurrences(kanji, limit, offset); + return c.json(occurrences); + }); + + app.get('/api/stats/vocabulary/:wordId/detail', async (c) => { + const wordId = parseIntQuery(c.req.param('wordId'), 0); + if (wordId <= 0) return c.body(null, 400); + const detail = await tracker.getWordDetail(wordId); + if (!detail) return c.body(null, 404); + const animeAppearances = await tracker.getWordAnimeAppearances(wordId); + const similarWords = await tracker.getSimilarWords(wordId); + return c.json({ detail, animeAppearances, similarWords }); + }); + + app.get('/api/stats/kanji/:kanjiId/detail', async (c) => { + const kanjiId = parseIntQuery(c.req.param('kanjiId'), 0); + if (kanjiId <= 0) return c.body(null, 400); + const detail = await tracker.getKanjiDetail(kanjiId); + if (!detail) return c.body(null, 404); + const animeAppearances = await tracker.getKanjiAnimeAppearances(kanjiId); + const words = await tracker.getKanjiWords(kanjiId); + return c.json({ detail, animeAppearances, words }); + }); + + app.get('/api/stats/media', async (c) => { + const library = await tracker.getMediaLibrary(); + return c.json(library); + }); + + app.get('/api/stats/media/:videoId', async (c) => { + const videoId = parseIntQuery(c.req.param('videoId'), 0); + if (videoId <= 0) return c.json(null, 400); + const [detail, sessions, rollups] = await Promise.all([ + tracker.getMediaDetail(videoId), + tracker.getMediaSessions(videoId, 100), + tracker.getMediaDailyRollups(videoId, 90), + ]); + return c.json({ detail, sessions, rollups }); + }); + + app.get('/api/stats/anime', async (c) => { + const rows = await tracker.getAnimeLibrary(); + return c.json(rows); + }); + + app.get('/api/stats/anime/:animeId', async (c) => { + const animeId = parseIntQuery(c.req.param('animeId'), 0); + if (animeId <= 0) return c.body(null, 400); + const detail = await tracker.getAnimeDetail(animeId); + if (!detail) return c.body(null, 404); + const [episodes, anilistEntries] = await Promise.all([ + tracker.getAnimeEpisodes(animeId), + tracker.getAnimeAnilistEntries(animeId), + ]); + return c.json({ detail, episodes, anilistEntries }); + }); + + app.get('/api/stats/anime/:animeId/words', async (c) => { + const animeId = parseIntQuery(c.req.param('animeId'), 0); + const limit = parseIntQuery(c.req.query('limit'), 50, 200); + if (animeId <= 0) return c.body(null, 400); + return c.json(await tracker.getAnimeWords(animeId, limit)); + }); + + app.get('/api/stats/anime/:animeId/rollups', async (c) => { + const animeId = parseIntQuery(c.req.param('animeId'), 0); + const limit = parseIntQuery(c.req.query('limit'), 90, 365); + if (animeId <= 0) return c.body(null, 400); + return c.json(await tracker.getAnimeDailyRollups(animeId, limit)); + }); + + app.patch('/api/stats/media/:videoId/watched', async (c) => { + const videoId = parseIntQuery(c.req.param('videoId'), 0); + if (videoId <= 0) return c.body(null, 400); + const body = await c.req.json().catch(() => null); + const watched = typeof body?.watched === 'boolean' ? body.watched : true; + await tracker.setVideoWatched(videoId, watched); + return c.json({ ok: true }); + }); + + app.get('/api/stats/anime/:animeId/cover', async (c) => { + const animeId = parseIntQuery(c.req.param('animeId'), 0); + if (animeId <= 0) return c.body(null, 404); + const art = await tracker.getAnimeCoverArt(animeId); + if (!art?.coverBlob) return c.body(null, 404); + return new Response(new Uint8Array(art.coverBlob), { + headers: { + 'Content-Type': 'image/jpeg', + 'Cache-Control': 'public, max-age=86400', + }, + }); + }); + + app.get('/api/stats/media/:videoId/cover', async (c) => { + const videoId = parseIntQuery(c.req.param('videoId'), 0); + if (videoId <= 0) return c.body(null, 404); + let art = await tracker.getCoverArt(videoId); + if (!art?.coverBlob) { + await tracker.ensureCoverArt(videoId); + art = await tracker.getCoverArt(videoId); + } + if (!art?.coverBlob) return c.body(null, 404); + return new Response(new Uint8Array(art.coverBlob), { + headers: { + 'Content-Type': 'image/jpeg', + 'Cache-Control': 'public, max-age=604800', + }, + }); + }); + + app.get('/api/stats/episode/:videoId/detail', async (c) => { + const videoId = parseIntQuery(c.req.param('videoId'), 0); + if (videoId <= 0) return c.body(null, 400); + const sessions = await tracker.getEpisodeSessions(videoId); + const words = await tracker.getEpisodeWords(videoId); + const cardEvents = await tracker.getEpisodeCardEvents(videoId); + return c.json({ sessions, words, cardEvents }); + }); + + app.post('/api/stats/anki/browse', async (c) => { + const noteId = parseIntQuery(c.req.query('noteId'), 0); + if (noteId <= 0) return c.body(null, 400); + try { + const response = await fetch('http://127.0.0.1:8765', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ action: 'guiBrowse', version: 6, params: { query: `nid:${noteId}` } }), + }); + const result = await response.json(); + return c.json(result); + } catch { + return c.json({ error: 'Failed to reach AnkiConnect' }, 502); + } + }); + + app.post('/api/stats/anki/notesInfo', async (c) => { + const body = await c.req.json().catch(() => null); + const noteIds = Array.isArray(body?.noteIds) ? body.noteIds.filter((id: unknown) => typeof id === 'number') : []; + if (noteIds.length === 0) return c.json([]); + try { + const response = await fetch('http://127.0.0.1:8765', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ action: 'notesInfo', version: 6, params: { notes: noteIds } }), + }); + const result = await response.json() as { result?: Array<{ noteId: number; fields: Record }> }; + return c.json(result.result ?? []); + } catch { + return c.json([], 502); + } + }); + + if (options?.staticDir) { + app.get('/assets/*', (c) => { + const response = createStatsStaticResponse(options.staticDir!, c.req.path); + if (!response) return c.text('Not found', 404); + return response; + }); + + app.get('/index.html', (c) => { + const response = createStatsStaticResponse(options.staticDir!, '/index.html'); + if (!response) return c.text('Stats UI not built', 404); + return response; + }); + + app.get('*', (c) => { + const staticResponse = createStatsStaticResponse(options.staticDir!, c.req.path); + if (staticResponse) return staticResponse; + const fallback = createStatsStaticResponse(options.staticDir!, '/index.html'); + if (!fallback) return c.text('Stats UI not built', 404); + return fallback; + }); + } + + return app; +} + +export function startStatsServer(config: StatsServerConfig): { close: () => void } { + const app = createStatsApp(config.tracker, { staticDir: config.staticDir }); + + const server = serve({ + fetch: app.fetch, + port: config.port, + hostname: '127.0.0.1', + }); + + return { + close: () => { + server.close(); + }, + }; +} diff --git a/src/core/services/stats-window-runtime.ts b/src/core/services/stats-window-runtime.ts new file mode 100644 index 0000000..107ae21 --- /dev/null +++ b/src/core/services/stats-window-runtime.ts @@ -0,0 +1,64 @@ +import type { BrowserWindowConstructorOptions } from 'electron'; +import type { WindowGeometry } from '../../types'; + +const DEFAULT_STATS_WINDOW_WIDTH = 900; +const DEFAULT_STATS_WINDOW_HEIGHT = 700; + +function isBareToggleKeyInput(input: Electron.Input, toggleKey: string): boolean { + return ( + input.type === 'keyDown' && + input.code === toggleKey && + !input.control && + !input.alt && + !input.meta && + !input.shift && + !input.isAutoRepeat + ); +} + +export function shouldHideStatsWindowForInput( + input: Electron.Input, + toggleKey: string, +): boolean { + return ( + (input.type === 'keyDown' && input.key === 'Escape') || + isBareToggleKeyInput(input, toggleKey) + ); +} + +export function buildStatsWindowOptions(options: { + preloadPath: string; + bounds?: WindowGeometry | null; +}): BrowserWindowConstructorOptions { + return { + x: options.bounds?.x, + y: options.bounds?.y, + width: options.bounds?.width ?? DEFAULT_STATS_WINDOW_WIDTH, + height: options.bounds?.height ?? DEFAULT_STATS_WINDOW_HEIGHT, + frame: false, + transparent: false, + alwaysOnTop: true, + resizable: false, + skipTaskbar: true, + hasShadow: false, + focusable: true, + acceptFirstMouse: true, + fullscreenable: false, + backgroundColor: '#1e1e2e', + show: false, + webPreferences: { + nodeIntegration: false, + contextIsolation: true, + preload: options.preloadPath, + sandbox: false, + }, + }; +} + +export function buildStatsWindowLoadFileOptions(): { query: Record } { + return { + query: { + overlay: '1', + }, + }; +} diff --git a/src/core/services/stats-window.test.ts b/src/core/services/stats-window.test.ts new file mode 100644 index 0000000..ad713a3 --- /dev/null +++ b/src/core/services/stats-window.test.ts @@ -0,0 +1,90 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { + buildStatsWindowLoadFileOptions, + buildStatsWindowOptions, + shouldHideStatsWindowForInput, +} from './stats-window-runtime'; + +test('buildStatsWindowOptions uses tracked overlay bounds and preload-friendly web preferences', () => { + const options = buildStatsWindowOptions({ + preloadPath: '/tmp/preload-stats.js', + bounds: { + x: 120, + y: 80, + width: 1440, + height: 900, + }, + }); + + assert.equal(options.x, 120); + assert.equal(options.y, 80); + assert.equal(options.width, 1440); + assert.equal(options.height, 900); + assert.equal(options.frame, false); + assert.equal(options.transparent, true); + assert.equal(options.resizable, false); + assert.equal(options.webPreferences?.preload, '/tmp/preload-stats.js'); + assert.equal(options.webPreferences?.contextIsolation, true); + assert.equal(options.webPreferences?.nodeIntegration, false); + assert.equal(options.webPreferences?.sandbox, false); +}); + +test('shouldHideStatsWindowForInput matches Escape and configured bare toggle key', () => { + assert.equal( + shouldHideStatsWindowForInput( + { + type: 'keyDown', + key: 'Escape', + code: 'Escape', + } as Electron.Input, + 'Backquote', + ), + true, + ); + + assert.equal( + shouldHideStatsWindowForInput( + { + type: 'keyDown', + key: '`', + code: 'Backquote', + } as Electron.Input, + 'Backquote', + ), + true, + ); + + assert.equal( + shouldHideStatsWindowForInput( + { + type: 'keyDown', + key: '`', + code: 'Backquote', + shift: true, + } as Electron.Input, + 'Backquote', + ), + false, + ); + + assert.equal( + shouldHideStatsWindowForInput( + { + type: 'keyUp', + key: '`', + code: 'Backquote', + } as Electron.Input, + 'Backquote', + ), + false, + ); +}); + +test('buildStatsWindowLoadFileOptions enables overlay rendering mode', () => { + assert.deepEqual(buildStatsWindowLoadFileOptions(), { + query: { + overlay: '1', + }, + }); +}); diff --git a/src/core/services/stats-window.ts b/src/core/services/stats-window.ts new file mode 100644 index 0000000..467b0a7 --- /dev/null +++ b/src/core/services/stats-window.ts @@ -0,0 +1,98 @@ +import { BrowserWindow, ipcMain } from 'electron'; +import * as path from 'path'; +import type { WindowGeometry } from '../../types.js'; +import { IPC_CHANNELS } from '../../shared/ipc/contracts.js'; +import { + buildStatsWindowLoadFileOptions, + buildStatsWindowOptions, + shouldHideStatsWindowForInput, +} from './stats-window-runtime.js'; + +let statsWindow: BrowserWindow | null = null; +let toggleRegistered = false; + +export interface StatsWindowOptions { + /** Absolute path to stats/dist/ directory */ + staticDir: string; + /** Absolute path to the compiled preload-stats.js */ + preloadPath: string; + /** Resolve the active stats toggle key from config */ + getToggleKey: () => string; + /** Resolve the tracked overlay/mpv bounds */ + resolveBounds: () => WindowGeometry | null; +} + +function syncStatsWindowBounds(window: BrowserWindow, bounds: WindowGeometry | null): void { + if (!bounds || window.isDestroyed()) return; + window.setBounds({ + x: bounds.x, + y: bounds.y, + width: bounds.width, + height: bounds.height, + }); +} + +/** + * Toggle the stats overlay window: create on first call, then show/hide. + * The React app stays mounted across toggles — state is preserved. + */ +export function toggleStatsOverlay(options: StatsWindowOptions): void { + if (!statsWindow) { + statsWindow = new BrowserWindow( + buildStatsWindowOptions({ + preloadPath: options.preloadPath, + bounds: options.resolveBounds(), + }), + ); + + const indexPath = path.join(options.staticDir, 'index.html'); + statsWindow.loadFile(indexPath, buildStatsWindowLoadFileOptions()); + + statsWindow.on('closed', () => { + statsWindow = null; + }); + + statsWindow.webContents.on('before-input-event', (event, input) => { + if (shouldHideStatsWindowForInput(input, options.getToggleKey())) { + event.preventDefault(); + statsWindow?.hide(); + } + }); + + statsWindow.once('ready-to-show', () => { + if (statsWindow) { + syncStatsWindowBounds(statsWindow, options.resolveBounds()); + } + statsWindow?.show(); + }); + } else if (statsWindow.isVisible()) { + statsWindow.hide(); + } else { + syncStatsWindowBounds(statsWindow, options.resolveBounds()); + statsWindow.show(); + statsWindow.focus(); + } +} + +/** + * Register the IPC command handler for toggling the overlay. + * Call this once during app initialization. + */ +export function registerStatsOverlayToggle(options: StatsWindowOptions): void { + if (toggleRegistered) return; + toggleRegistered = true; + ipcMain.on(IPC_CHANNELS.command.toggleStatsOverlay, () => { + toggleStatsOverlay(options); + }); +} + +/** + * Clean up — destroy the stats window if it exists. + * Call during app quit. + */ +export function destroyStatsWindow(): void { + if (statsWindow && !statsWindow.isDestroyed()) { + statsWindow.destroy(); + statsWindow = null; + } +} diff --git a/src/main/runtime/anki-actions-main-deps.ts b/src/main/runtime/anki-actions-main-deps.ts index eda1e8c..76df21d 100644 --- a/src/main/runtime/anki-actions-main-deps.ts +++ b/src/main/runtime/anki-actions-main-deps.ts @@ -78,7 +78,7 @@ export function createBuildMineSentenceCardMainDepsHandler(deps: { mpvClient: TMpv; showMpvOsd: (text: string) => void; }) => Promise; - recordCardsMined: (count: number) => void; + recordCardsMined: (count: number, noteIds?: number[]) => void; }) { return () => ({ getAnkiIntegration: () => deps.getAnkiIntegration(), @@ -89,6 +89,6 @@ export function createBuildMineSentenceCardMainDepsHandler(deps: { mpvClient: TMpv; showMpvOsd: (text: string) => void; }) => deps.mineSentenceCardCore(options), - recordCardsMined: (count: number) => deps.recordCardsMined(count), + recordCardsMined: (count: number, noteIds?: number[]) => deps.recordCardsMined(count, noteIds), }); } diff --git a/src/main/runtime/anki-actions.ts b/src/main/runtime/anki-actions.ts index 443a918..f865cc8 100644 --- a/src/main/runtime/anki-actions.ts +++ b/src/main/runtime/anki-actions.ts @@ -75,7 +75,7 @@ export function createMineSentenceCardHandler(deps: { mpvClient: TMpv; showMpvOsd: (text: string) => void; }) => Promise; - recordCardsMined: (count: number) => void; + recordCardsMined: (count: number, noteIds?: number[]) => void; }) { return async (): Promise => { const created = await deps.mineSentenceCardCore({ diff --git a/src/main/runtime/stats-cli-command.test.ts b/src/main/runtime/stats-cli-command.test.ts new file mode 100644 index 0000000..d382f1e --- /dev/null +++ b/src/main/runtime/stats-cli-command.test.ts @@ -0,0 +1,111 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { createRunStatsCliCommandHandler } from './stats-cli-command'; + +function makeHandler(overrides: Partial[0]> = {}) { + const calls: string[] = []; + const responses: Array<{ responsePath: string; payload: { ok: boolean; url?: string; error?: string } }> = []; + + const handler = createRunStatsCliCommandHandler({ + getResolvedConfig: () => ({ + immersionTracking: { enabled: true }, + stats: { serverPort: 5175 }, + }), + ensureImmersionTrackerStarted: () => { + calls.push('ensureImmersionTrackerStarted'); + }, + getImmersionTracker: () => ({ cleanupVocabularyStats: undefined }), + ensureStatsServerStarted: () => { + calls.push('ensureStatsServerStarted'); + return 'http://127.0.0.1:5175'; + }, + openExternal: async (url) => { + calls.push(`openExternal:${url}`); + }, + writeResponse: (responsePath, payload) => { + responses.push({ responsePath, payload }); + }, + exitAppWithCode: (code) => { + calls.push(`exitAppWithCode:${code}`); + }, + logInfo: (message) => { + calls.push(`info:${message}`); + }, + logWarn: (message) => { + calls.push(`warn:${message}`); + }, + logError: (message, error) => { + calls.push(`error:${message}:${error instanceof Error ? error.message : String(error)}`); + }, + ...overrides, + }); + + return { handler, calls, responses }; +} + +test('stats cli command starts tracker, server, browser, and writes success response', async () => { + const { handler, calls, responses } = makeHandler(); + + await handler({ statsResponsePath: '/tmp/subminer-stats-response.json' }, 'initial'); + + assert.deepEqual(calls, [ + 'ensureImmersionTrackerStarted', + 'ensureStatsServerStarted', + 'openExternal:http://127.0.0.1:5175', + 'info:Stats dashboard available at http://127.0.0.1:5175', + ]); + assert.deepEqual(responses, [ + { + responsePath: '/tmp/subminer-stats-response.json', + payload: { ok: true, url: 'http://127.0.0.1:5175' }, + }, + ]); +}); + +test('stats cli command fails when immersion tracking is disabled', async () => { + const { handler, calls, responses } = makeHandler({ + getResolvedConfig: () => ({ + immersionTracking: { enabled: false }, + stats: { serverPort: 5175 }, + }), + }); + + await handler({ statsResponsePath: '/tmp/subminer-stats-response.json' }, 'initial'); + + assert.equal(calls.includes('ensureImmersionTrackerStarted'), false); + assert.ok(calls.includes('exitAppWithCode:1')); + assert.deepEqual(responses, [ + { + responsePath: '/tmp/subminer-stats-response.json', + payload: { ok: false, error: 'Immersion tracking is disabled in config.' }, + }, + ]); +}); + +test('stats cli command runs vocab cleanup instead of opening dashboard when cleanup mode is requested', async () => { + const { handler, calls, responses } = makeHandler({ + getImmersionTracker: () => ({ + cleanupVocabularyStats: async () => ({ scanned: 3, kept: 1, deleted: 2, repaired: 1 }), + }), + }); + + await handler( + { + statsResponsePath: '/tmp/subminer-stats-response.json', + statsCleanup: true, + statsCleanupVocab: true, + }, + 'initial', + ); + + assert.deepEqual(calls, [ + 'ensureImmersionTrackerStarted', + 'info:Stats vocabulary cleanup complete: scanned=3 kept=1 deleted=2 repaired=1', + ]); + assert.deepEqual(responses, [ + { + responsePath: '/tmp/subminer-stats-response.json', + payload: { ok: true }, + }, + ]); +}); diff --git a/src/main/runtime/stats-cli-command.ts b/src/main/runtime/stats-cli-command.ts new file mode 100644 index 0000000..95eb921 --- /dev/null +++ b/src/main/runtime/stats-cli-command.ts @@ -0,0 +1,99 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import type { CliArgs, CliCommandSource } from '../../cli/args'; +import type { 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; +}; + +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 } | null; + ensureStatsServerStarted: () => string; + 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, + source: CliCommandSource, + ): Promise => { + try { + const config = deps.getResolvedConfig(); + if (config.immersionTracking?.enabled === false) { + throw new Error('Immersion tracking is disabled in config.'); + } + + deps.ensureImmersionTrackerStarted(); + const tracker = deps.getImmersionTracker(); + if (!tracker) { + throw new Error('Immersion tracker failed to initialize.'); + } + + if (args.statsCleanup) { + await deps.ensureVocabularyCleanupTokenizerReady?.(); + if (!args.statsCleanupVocab || !tracker.cleanupVocabularyStats) { + throw new Error('Stats cleanup mode is not available.'); + } + 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; + } + + 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); + } + } + }; +} diff --git a/src/preload-stats.ts b/src/preload-stats.ts new file mode 100644 index 0000000..414daee --- /dev/null +++ b/src/preload-stats.ts @@ -0,0 +1,49 @@ +import { contextBridge, ipcRenderer } from 'electron'; +import { IPC_CHANNELS } from './shared/ipc/contracts'; + +const statsAPI = { + getOverview: (): Promise => + ipcRenderer.invoke(IPC_CHANNELS.request.statsGetOverview), + + getDailyRollups: (limit?: number): Promise => + ipcRenderer.invoke(IPC_CHANNELS.request.statsGetDailyRollups, limit), + + getMonthlyRollups: (limit?: number): Promise => + ipcRenderer.invoke(IPC_CHANNELS.request.statsGetMonthlyRollups, limit), + + getSessions: (limit?: number): Promise => + ipcRenderer.invoke(IPC_CHANNELS.request.statsGetSessions, limit), + + getSessionTimeline: (sessionId: number, limit?: number): Promise => + ipcRenderer.invoke(IPC_CHANNELS.request.statsGetSessionTimeline, sessionId, limit), + + getSessionEvents: (sessionId: number, limit?: number): Promise => + ipcRenderer.invoke(IPC_CHANNELS.request.statsGetSessionEvents, sessionId, limit), + + getVocabulary: (limit?: number): Promise => + ipcRenderer.invoke(IPC_CHANNELS.request.statsGetVocabulary, limit), + + getKanji: (limit?: number): Promise => + ipcRenderer.invoke(IPC_CHANNELS.request.statsGetKanji, limit), + + getMediaLibrary: (): Promise => + ipcRenderer.invoke(IPC_CHANNELS.request.statsGetMediaLibrary), + + getMediaDetail: (videoId: number): Promise => + ipcRenderer.invoke(IPC_CHANNELS.request.statsGetMediaDetail, videoId), + + getMediaSessions: (videoId: number, limit?: number): Promise => + ipcRenderer.invoke(IPC_CHANNELS.request.statsGetMediaSessions, videoId, limit), + + getMediaDailyRollups: (videoId: number, limit?: number): Promise => + ipcRenderer.invoke(IPC_CHANNELS.request.statsGetMediaDailyRollups, videoId, limit), + + getMediaCover: (videoId: number): Promise => + ipcRenderer.invoke(IPC_CHANNELS.request.statsGetMediaCover, videoId), + + hideOverlay: (): void => { + ipcRenderer.send(IPC_CHANNELS.command.toggleStatsOverlay); + }, +}; + +contextBridge.exposeInMainWorld('electronAPI', { stats: statsAPI }); diff --git a/src/preload.ts b/src/preload.ts index 7b0457a..4616ad3 100644 --- a/src/preload.ts +++ b/src/preload.ts @@ -207,6 +207,8 @@ const electronAPI: ElectronAPI = { ipcRenderer.invoke(IPC_CHANNELS.request.getKeybindings), getConfiguredShortcuts: (): Promise> => ipcRenderer.invoke(IPC_CHANNELS.request.getConfigShortcuts), + getStatsToggleKey: (): Promise => + ipcRenderer.invoke(IPC_CHANNELS.request.getStatsToggleKey), getControllerConfig: (): Promise => ipcRenderer.invoke(IPC_CHANNELS.request.getControllerConfig), saveControllerPreference: (update: ControllerPreferenceUpdate): Promise => @@ -233,6 +235,10 @@ const electronAPI: ElectronAPI = { ipcRenderer.send(IPC_CHANNELS.command.toggleOverlay); }, + toggleStatsOverlay: () => { + ipcRenderer.send(IPC_CHANNELS.command.toggleStatsOverlay); + }, + getAnkiConnectStatus: (): Promise => ipcRenderer.invoke(IPC_CHANNELS.request.getAnkiConnectStatus), setAnkiConnectEnabled: (enabled: boolean) => { diff --git a/src/shared/ipc/contracts.ts b/src/shared/ipc/contracts.ts index 33e7fcf..600660b 100644 --- a/src/shared/ipc/contracts.ts +++ b/src/shared/ipc/contracts.ts @@ -28,6 +28,7 @@ export const IPC_CHANNELS = { kikuFieldGroupingRespond: 'kiku:field-grouping-respond', reportOverlayContentBounds: 'overlay-content-bounds:report', overlayModalOpened: 'overlay:modal-opened', + toggleStatsOverlay: 'stats:toggle-overlay', }, request: { getVisibleOverlayVisibility: 'get-visible-overlay-visibility', @@ -40,6 +41,7 @@ export const IPC_CHANNELS = { getMecabStatus: 'get-mecab-status', getKeybindings: 'get-keybindings', getConfigShortcuts: 'get-config-shortcuts', + getStatsToggleKey: 'get-stats-toggle-key', getControllerConfig: 'get-controller-config', getSecondarySubMode: 'get-secondary-sub-mode', getCurrentSecondarySub: 'get-current-secondary-sub', @@ -60,6 +62,19 @@ export const IPC_CHANNELS = { jimakuListFiles: 'jimaku:list-files', jimakuDownloadFile: 'jimaku:download-file', kikuBuildMergePreview: 'kiku:build-merge-preview', + statsGetOverview: 'stats:get-overview', + statsGetDailyRollups: 'stats:get-daily-rollups', + statsGetMonthlyRollups: 'stats:get-monthly-rollups', + statsGetSessions: 'stats:get-sessions', + statsGetSessionTimeline: 'stats:get-session-timeline', + statsGetSessionEvents: 'stats:get-session-events', + statsGetVocabulary: 'stats:get-vocabulary', + statsGetKanji: 'stats:get-kanji', + statsGetMediaLibrary: 'stats:get-media-library', + statsGetMediaDetail: 'stats:get-media-detail', + statsGetMediaSessions: 'stats:get-media-sessions', + statsGetMediaDailyRollups: 'stats:get-media-daily-rollups', + statsGetMediaCover: 'stats:get-media-cover', }, event: { subtitleSet: 'subtitle:set', diff --git a/src/types.ts b/src/types.ts index f1aa764..9533199 100644 --- a/src/types.ts +++ b/src/types.ts @@ -556,6 +556,13 @@ export interface YoutubeSubgenConfig { primarySubLanguages?: string[]; } +export interface StatsConfig { + toggleKey?: string; + serverPort?: number; + autoStartServer?: boolean; + autoOpenBrowser?: boolean; +} + export interface ImmersionTrackingConfig { enabled?: boolean; dbPath?: string; @@ -595,6 +602,7 @@ export interface Config { ai?: AiConfig; youtubeSubgen?: YoutubeSubgenConfig; immersionTracking?: ImmersionTrackingConfig; + stats?: StatsConfig; logging?: { level?: 'debug' | 'info' | 'warn' | 'error'; }; @@ -790,6 +798,12 @@ export interface ResolvedConfig { vacuumIntervalDays: number; }; }; + stats: { + toggleKey: string; + serverPort: number; + autoStartServer: boolean; + autoOpenBrowser: boolean; + }; logging: { level: 'debug' | 'info' | 'warn' | 'error'; }; @@ -976,6 +990,7 @@ export interface ElectronAPI { sendMpvCommand: (command: (string | number)[]) => void; getKeybindings: () => Promise; getConfiguredShortcuts: () => Promise>; + getStatsToggleKey: () => Promise; getControllerConfig: () => Promise; saveControllerPreference: (update: ControllerPreferenceUpdate) => Promise; getJimakuMediaInfo: () => Promise; @@ -985,6 +1000,7 @@ export interface ElectronAPI { quitApp: () => void; toggleDevTools: () => void; toggleOverlay: () => void; + toggleStatsOverlay: () => void; getAnkiConnectStatus: () => Promise; setAnkiConnectEnabled: (enabled: boolean) => void; clearAnkiConnectHistory: () => void;