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
This commit is contained in:
2026-03-14 22:14:09 -07:00
parent f005f542a3
commit a7c294a90c
36 changed files with 2312 additions and 4 deletions

View File

@@ -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<KikuFieldGroupingChoice>,
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;
}
}

View File

@@ -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', () => {

View File

@@ -15,6 +15,7 @@ interface AnkiConnectEnvelope {
export interface AnkiConnectProxyServerDeps {
shouldAutoUpdateNewCards: () => boolean;
processNewCard: (noteId: number) => Promise<void>;
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();
}

View File

@@ -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<number>();
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]);
});

View File

@@ -9,6 +9,7 @@ export interface PollingRunnerDeps {
) => Promise<number[]>;
shouldAutoUpdateNewCards: () => boolean;
processNewCard: (noteId: number) => Promise<void>;
recordCardsAdded?: (count: number, noteIds: number[]) => void;
isUpdateInProgress: () => boolean;
setUpdateInProgress: (value: boolean) => void;
getTrackedNoteIds: () => Set<number>;
@@ -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) {

View File

@@ -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 };

View File

@@ -0,0 +1,10 @@
import { ResolvedConfig } from '../../types.js';
export const STATS_DEFAULT_CONFIG: Pick<ResolvedConfig, 'stats'> = {
stats: {
toggleKey: 'Backquote',
serverPort: 5175,
autoStartServer: true,
autoOpenBrowser: true,
},
};

View File

@@ -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.',
},
];
}

View File

@@ -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[] = [

View File

@@ -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;

View File

@@ -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.');
}
}

View File

@@ -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> = {},
): 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<T>(fn: (dir: string) => Promise<T> | T): Promise<T> | 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'),
'<!doctype html><html><body><div id="root"></div><script src="./assets/app.js"></script></body></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);
});
});

View File

@@ -34,6 +34,7 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
anilistSetup: false,
anilistRetryQueue: false,
dictionary: false,
stats: false,
jellyfin: false,
jellyfinLogin: false,
jellyfinLogout: false,

View File

@@ -34,6 +34,7 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
anilistSetup: false,
anilistRetryQueue: false,
dictionary: false,
stats: false,
jellyfin: false,
jellyfinLogin: false,
jellyfinLogout: false,
@@ -177,6 +178,9 @@ function createDeps(overrides: Partial<CliCommandServiceDeps> = {}) {
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) => {

View File

@@ -61,6 +61,7 @@ export interface CliCommandServiceDeps {
mediaTitle: string;
entryCount: number;
}>;
runStatsCommand: (args: CliArgs, source: CliCommandSource) => Promise<void>;
runJellyfinCommand: (args: CliArgs) => Promise<void>;
printHelp: () => void;
hasMainWindow: () => boolean;
@@ -154,6 +155,7 @@ export interface CliCommandDepsRuntimeOptions {
};
jellyfin: {
openSetup: () => void;
runStatsCommand: (args: CliArgs, source: CliCommandSource) => Promise<void>;
runCommand: (args: CliArgs) => Promise<void>;
};
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(

View File

@@ -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> = {}): 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: '',

View File

@@ -48,6 +48,7 @@ export interface IpcServiceDeps {
handleMpvCommand: (command: Array<string | number>) => void;
getKeybindings: () => unknown;
getConfiguredShortcuts: () => unknown;
getStatsToggleKey: () => string;
getControllerConfig: () => ResolvedControllerConfig;
saveControllerPreference: (update: ControllerPreferenceUpdate) => void | Promise<void>;
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<unknown>;
getDailyRollups: (limit?: number) => Promise<unknown>;
getMonthlyRollups: (limit?: number) => Promise<unknown>;
getQueryHints: () => Promise<{ totalSessions: number; activeSessions: number; episodesToday: number; activeAnimeCount: number }>;
getSessionTimeline: (sessionId: number, limit?: number) => Promise<unknown>;
getSessionEvents: (sessionId: number, limit?: number) => Promise<unknown>;
getVocabularyStats: (limit?: number) => Promise<unknown>;
getKanjiStats: (limit?: number) => Promise<unknown>;
getMediaLibrary: () => Promise<unknown>;
getMediaDetail: (videoId: number) => Promise<unknown>;
getMediaSessions: (videoId: number, limit?: number) => Promise<unknown>;
getMediaDailyRollups: (videoId: number, limit?: number) => Promise<unknown>;
getCoverArt: (videoId: number) => Promise<unknown>;
} | null;
}
interface WindowLike {
@@ -113,6 +129,7 @@ export interface IpcDepsRuntimeOptions {
handleMpvCommand: (command: Array<string | number>) => void;
getKeybindings: () => unknown;
getConfiguredShortcuts: () => unknown;
getStatsToggleKey: () => string;
getControllerConfig: () => ResolvedControllerConfig;
saveControllerPreference: (update: ControllerPreferenceUpdate) => void | Promise<void>;
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;
},
);
}

View File

@@ -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',

View File

@@ -87,6 +87,7 @@ function createDeps(overrides: Partial<MpvProtocolHandleMessageDeps> = {}): {
getPauseAtTime: () => null,
setPauseAtTime: () => {},
emitTimePosChange: () => {},
emitDurationChange: () => {},
emitPauseChange: () => {},
autoLoadSecondarySubTrack: () => {},
setCurrentVideoPath: () => {},

View File

@@ -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<MpvSubtitleRenderMetrics>) => 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') {

View File

@@ -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);

View File

@@ -34,6 +34,7 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
anilistSetup: false,
anilistRetryQueue: false,
dictionary: false,
stats: false,
jellyfin: false,
jellyfinLogin: false,
jellyfinLogout: false,

View File

@@ -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']);
});

View File

@@ -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<voi
const now = deps.now ?? (() => Date.now());
const startupStartedAtMs = now();
deps.ensureDefaultConfigBootstrap();
if (deps.shouldUseMinimalStartup?.()) {
deps.reloadConfig();
deps.handleInitialArgs();
return;
}
if (deps.shouldSkipHeavyStartup?.()) {
await deps.loadYomitanExtension();
deps.reloadConfig();

View File

@@ -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<string, string> = {
'.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<string, { value: string }> }> };
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();
},
};
}

View File

@@ -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<string, string> } {
return {
query: {
overlay: '1',
},
};
}

View File

@@ -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',
},
});
});

View File

@@ -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;
}
}

View File

@@ -78,7 +78,7 @@ export function createBuildMineSentenceCardMainDepsHandler<TAnki, TMpv>(deps: {
mpvClient: TMpv;
showMpvOsd: (text: string) => void;
}) => Promise<boolean>;
recordCardsMined: (count: number) => void;
recordCardsMined: (count: number, noteIds?: number[]) => void;
}) {
return () => ({
getAnkiIntegration: () => deps.getAnkiIntegration(),
@@ -89,6 +89,6 @@ export function createBuildMineSentenceCardMainDepsHandler<TAnki, TMpv>(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),
});
}

View File

@@ -75,7 +75,7 @@ export function createMineSentenceCardHandler<TAnki, TMpv>(deps: {
mpvClient: TMpv;
showMpvOsd: (text: string) => void;
}) => Promise<boolean>;
recordCardsMined: (count: number) => void;
recordCardsMined: (count: number, noteIds?: number[]) => void;
}) {
return async (): Promise<void> => {
const created = await deps.mineSentenceCardCore({

View File

@@ -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<Parameters<typeof createRunStatsCliCommandHandler>[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 },
},
]);
});

View File

@@ -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> | void;
getImmersionTracker: () => { cleanupVocabularyStats?: () => Promise<VocabularyCleanupSummary> } | null;
ensureStatsServerStarted: () => string;
openExternal: (url: string) => Promise<unknown>;
writeResponse: (responsePath: string, payload: StatsCliCommandResponse) => void;
exitAppWithCode: (code: number) => void;
logInfo: (message: string) => void;
logWarn: (message: string, error: unknown) => void;
logError: (message: string, error: unknown) => void;
}) {
const writeResponseSafe = (
responsePath: string | undefined,
payload: StatsCliCommandResponse,
): void => {
if (!responsePath) return;
try {
deps.writeResponse(responsePath, payload);
} catch (error) {
deps.logWarn(`Failed to write stats response: ${responsePath}`, error);
}
};
return async (
args: Pick<CliArgs, 'statsResponsePath' | 'statsCleanup' | 'statsCleanupVocab'>,
source: CliCommandSource,
): Promise<void> => {
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);
}
}
};
}

49
src/preload-stats.ts Normal file
View File

@@ -0,0 +1,49 @@
import { contextBridge, ipcRenderer } from 'electron';
import { IPC_CHANNELS } from './shared/ipc/contracts';
const statsAPI = {
getOverview: (): Promise<unknown> =>
ipcRenderer.invoke(IPC_CHANNELS.request.statsGetOverview),
getDailyRollups: (limit?: number): Promise<unknown> =>
ipcRenderer.invoke(IPC_CHANNELS.request.statsGetDailyRollups, limit),
getMonthlyRollups: (limit?: number): Promise<unknown> =>
ipcRenderer.invoke(IPC_CHANNELS.request.statsGetMonthlyRollups, limit),
getSessions: (limit?: number): Promise<unknown> =>
ipcRenderer.invoke(IPC_CHANNELS.request.statsGetSessions, limit),
getSessionTimeline: (sessionId: number, limit?: number): Promise<unknown> =>
ipcRenderer.invoke(IPC_CHANNELS.request.statsGetSessionTimeline, sessionId, limit),
getSessionEvents: (sessionId: number, limit?: number): Promise<unknown> =>
ipcRenderer.invoke(IPC_CHANNELS.request.statsGetSessionEvents, sessionId, limit),
getVocabulary: (limit?: number): Promise<unknown> =>
ipcRenderer.invoke(IPC_CHANNELS.request.statsGetVocabulary, limit),
getKanji: (limit?: number): Promise<unknown> =>
ipcRenderer.invoke(IPC_CHANNELS.request.statsGetKanji, limit),
getMediaLibrary: (): Promise<unknown> =>
ipcRenderer.invoke(IPC_CHANNELS.request.statsGetMediaLibrary),
getMediaDetail: (videoId: number): Promise<unknown> =>
ipcRenderer.invoke(IPC_CHANNELS.request.statsGetMediaDetail, videoId),
getMediaSessions: (videoId: number, limit?: number): Promise<unknown> =>
ipcRenderer.invoke(IPC_CHANNELS.request.statsGetMediaSessions, videoId, limit),
getMediaDailyRollups: (videoId: number, limit?: number): Promise<unknown> =>
ipcRenderer.invoke(IPC_CHANNELS.request.statsGetMediaDailyRollups, videoId, limit),
getMediaCover: (videoId: number): Promise<unknown> =>
ipcRenderer.invoke(IPC_CHANNELS.request.statsGetMediaCover, videoId),
hideOverlay: (): void => {
ipcRenderer.send(IPC_CHANNELS.command.toggleStatsOverlay);
},
};
contextBridge.exposeInMainWorld('electronAPI', { stats: statsAPI });

View File

@@ -207,6 +207,8 @@ const electronAPI: ElectronAPI = {
ipcRenderer.invoke(IPC_CHANNELS.request.getKeybindings),
getConfiguredShortcuts: (): Promise<Required<ShortcutsConfig>> =>
ipcRenderer.invoke(IPC_CHANNELS.request.getConfigShortcuts),
getStatsToggleKey: (): Promise<string> =>
ipcRenderer.invoke(IPC_CHANNELS.request.getStatsToggleKey),
getControllerConfig: (): Promise<ResolvedControllerConfig> =>
ipcRenderer.invoke(IPC_CHANNELS.request.getControllerConfig),
saveControllerPreference: (update: ControllerPreferenceUpdate): Promise<void> =>
@@ -233,6 +235,10 @@ const electronAPI: ElectronAPI = {
ipcRenderer.send(IPC_CHANNELS.command.toggleOverlay);
},
toggleStatsOverlay: () => {
ipcRenderer.send(IPC_CHANNELS.command.toggleStatsOverlay);
},
getAnkiConnectStatus: (): Promise<boolean> =>
ipcRenderer.invoke(IPC_CHANNELS.request.getAnkiConnectStatus),
setAnkiConnectEnabled: (enabled: boolean) => {

View File

@@ -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',

View File

@@ -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<Keybinding[]>;
getConfiguredShortcuts: () => Promise<Required<ShortcutsConfig>>;
getStatsToggleKey: () => Promise<string>;
getControllerConfig: () => Promise<ResolvedControllerConfig>;
saveControllerPreference: (update: ControllerPreferenceUpdate) => Promise<void>;
getJimakuMediaInfo: () => Promise<JimakuMediaInfo>;
@@ -985,6 +1000,7 @@ export interface ElectronAPI {
quitApp: () => void;
toggleDevTools: () => void;
toggleOverlay: () => void;
toggleStatsOverlay: () => void;
getAnkiConnectStatus: () => Promise<boolean>;
setAnkiConnectEnabled: (enabled: boolean) => void;
clearAnkiConnectHistory: () => void;