mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-03-20 12:11:28 -07:00
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:
@@ -137,6 +137,7 @@ export class AnkiIntegration {
|
|||||||
private fieldGroupingWorkflow: FieldGroupingWorkflow;
|
private fieldGroupingWorkflow: FieldGroupingWorkflow;
|
||||||
private runtime: AnkiIntegrationRuntime;
|
private runtime: AnkiIntegrationRuntime;
|
||||||
private aiConfig: AiConfig;
|
private aiConfig: AiConfig;
|
||||||
|
private recordCardsMinedCallback: ((count: number, noteIds?: number[]) => void) | null = null;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
config: AnkiConnectConfig,
|
config: AnkiConnectConfig,
|
||||||
@@ -150,6 +151,7 @@ export class AnkiIntegration {
|
|||||||
}) => Promise<KikuFieldGroupingChoice>,
|
}) => Promise<KikuFieldGroupingChoice>,
|
||||||
knownWordCacheStatePath?: string,
|
knownWordCacheStatePath?: string,
|
||||||
aiConfig: AiConfig = {},
|
aiConfig: AiConfig = {},
|
||||||
|
recordCardsMined?: (count: number, noteIds?: number[]) => void,
|
||||||
) {
|
) {
|
||||||
this.config = normalizeAnkiIntegrationConfig(config);
|
this.config = normalizeAnkiIntegrationConfig(config);
|
||||||
this.aiConfig = { ...aiConfig };
|
this.aiConfig = { ...aiConfig };
|
||||||
@@ -160,6 +162,7 @@ export class AnkiIntegration {
|
|||||||
this.osdCallback = osdCallback || null;
|
this.osdCallback = osdCallback || null;
|
||||||
this.notificationCallback = notificationCallback || null;
|
this.notificationCallback = notificationCallback || null;
|
||||||
this.fieldGroupingCallback = fieldGroupingCallback || null;
|
this.fieldGroupingCallback = fieldGroupingCallback || null;
|
||||||
|
this.recordCardsMinedCallback = recordCardsMined ?? null;
|
||||||
this.knownWordCache = this.createKnownWordCache(knownWordCacheStatePath);
|
this.knownWordCache = this.createKnownWordCache(knownWordCacheStatePath);
|
||||||
this.pollingRunner = this.createPollingRunner();
|
this.pollingRunner = this.createPollingRunner();
|
||||||
this.cardCreationService = this.createCardCreationService();
|
this.cardCreationService = this.createCardCreationService();
|
||||||
@@ -208,6 +211,9 @@ export class AnkiIntegration {
|
|||||||
(await this.client.findNotes(query, options)) as number[],
|
(await this.client.findNotes(query, options)) as number[],
|
||||||
shouldAutoUpdateNewCards: () => this.config.behavior?.autoUpdateNewCards !== false,
|
shouldAutoUpdateNewCards: () => this.config.behavior?.autoUpdateNewCards !== false,
|
||||||
processNewCard: (noteId) => this.processNewCard(noteId),
|
processNewCard: (noteId) => this.processNewCard(noteId),
|
||||||
|
recordCardsAdded: (count, noteIds) => {
|
||||||
|
this.recordCardsMinedCallback?.(count, noteIds);
|
||||||
|
},
|
||||||
isUpdateInProgress: () => this.updateInProgress,
|
isUpdateInProgress: () => this.updateInProgress,
|
||||||
setUpdateInProgress: (value) => {
|
setUpdateInProgress: (value) => {
|
||||||
this.updateInProgress = value;
|
this.updateInProgress = value;
|
||||||
@@ -229,6 +235,9 @@ export class AnkiIntegration {
|
|||||||
return new AnkiConnectProxyServer({
|
return new AnkiConnectProxyServer({
|
||||||
shouldAutoUpdateNewCards: () => this.config.behavior?.autoUpdateNewCards !== false,
|
shouldAutoUpdateNewCards: () => this.config.behavior?.autoUpdateNewCards !== false,
|
||||||
processNewCard: (noteId: number) => this.processNewCard(noteId),
|
processNewCard: (noteId: number) => this.processNewCard(noteId),
|
||||||
|
recordCardsAdded: (count, noteIds) => {
|
||||||
|
this.recordCardsMinedCallback?.(count, noteIds);
|
||||||
|
},
|
||||||
getDeck: () => this.config.deck,
|
getDeck: () => this.config.deck,
|
||||||
findNotes: async (query, options) =>
|
findNotes: async (query, options) =>
|
||||||
(await this.client.findNotes(query, options)) as number[],
|
(await this.client.findNotes(query, options)) as number[],
|
||||||
@@ -1112,4 +1121,8 @@ export class AnkiIntegration {
|
|||||||
this.stop();
|
this.stop();
|
||||||
this.mediaGenerator.cleanup();
|
this.mediaGenerator.cleanup();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setRecordCardsMinedCallback(callback: ((count: number, noteIds?: number[]) => void) | null): void {
|
||||||
|
this.recordCardsMinedCallback = callback;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,11 +17,15 @@ async function waitForCondition(
|
|||||||
|
|
||||||
test('proxy enqueues addNote result for enrichment', async () => {
|
test('proxy enqueues addNote result for enrichment', async () => {
|
||||||
const processed: number[] = [];
|
const processed: number[] = [];
|
||||||
|
const recordedCards: number[] = [];
|
||||||
const proxy = new AnkiConnectProxyServer({
|
const proxy = new AnkiConnectProxyServer({
|
||||||
shouldAutoUpdateNewCards: () => true,
|
shouldAutoUpdateNewCards: () => true,
|
||||||
processNewCard: async (noteId) => {
|
processNewCard: async (noteId) => {
|
||||||
processed.push(noteId);
|
processed.push(noteId);
|
||||||
},
|
},
|
||||||
|
recordCardsAdded: (count) => {
|
||||||
|
recordedCards.push(count);
|
||||||
|
},
|
||||||
logInfo: () => undefined,
|
logInfo: () => undefined,
|
||||||
logWarn: () => undefined,
|
logWarn: () => undefined,
|
||||||
logError: () => undefined,
|
logError: () => undefined,
|
||||||
@@ -38,6 +42,7 @@ test('proxy enqueues addNote result for enrichment', async () => {
|
|||||||
|
|
||||||
await waitForCondition(() => processed.length === 1);
|
await waitForCondition(() => processed.length === 1);
|
||||||
assert.deepEqual(processed, [42]);
|
assert.deepEqual(processed, [42]);
|
||||||
|
assert.deepEqual(recordedCards, [1]);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('proxy enqueues addNote bare numeric response for enrichment', async () => {
|
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 () => {
|
test('proxy de-duplicates addNotes IDs within the same response', async () => {
|
||||||
const processed: number[] = [];
|
const processed: number[] = [];
|
||||||
|
const recordedCards: number[] = [];
|
||||||
const proxy = new AnkiConnectProxyServer({
|
const proxy = new AnkiConnectProxyServer({
|
||||||
shouldAutoUpdateNewCards: () => true,
|
shouldAutoUpdateNewCards: () => true,
|
||||||
processNewCard: async (noteId) => {
|
processNewCard: async (noteId) => {
|
||||||
processed.push(noteId);
|
processed.push(noteId);
|
||||||
await new Promise((resolve) => setTimeout(resolve, 5));
|
await new Promise((resolve) => setTimeout(resolve, 5));
|
||||||
},
|
},
|
||||||
|
recordCardsAdded: (count) => {
|
||||||
|
recordedCards.push(count);
|
||||||
|
},
|
||||||
logInfo: () => undefined,
|
logInfo: () => undefined,
|
||||||
logWarn: () => undefined,
|
logWarn: () => undefined,
|
||||||
logError: () => undefined,
|
logError: () => undefined,
|
||||||
@@ -86,6 +95,7 @@ test('proxy de-duplicates addNotes IDs within the same response', async () => {
|
|||||||
|
|
||||||
await waitForCondition(() => processed.length === 2);
|
await waitForCondition(() => processed.length === 2);
|
||||||
assert.deepEqual(processed, [101, 102]);
|
assert.deepEqual(processed, [101, 102]);
|
||||||
|
assert.deepEqual(recordedCards, [2]);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('proxy enqueues note IDs from multi action addNote/addNotes results', async () => {
|
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 () => {
|
test('proxy fallback-enqueues latest note for addNote responses without note IDs and escapes deck quotes', async () => {
|
||||||
const processed: number[] = [];
|
const processed: number[] = [];
|
||||||
|
const recordedCards: number[] = [];
|
||||||
const findNotesQueries: string[] = [];
|
const findNotesQueries: string[] = [];
|
||||||
const proxy = new AnkiConnectProxyServer({
|
const proxy = new AnkiConnectProxyServer({
|
||||||
shouldAutoUpdateNewCards: () => true,
|
shouldAutoUpdateNewCards: () => true,
|
||||||
processNewCard: async (noteId) => {
|
processNewCard: async (noteId) => {
|
||||||
processed.push(noteId);
|
processed.push(noteId);
|
||||||
},
|
},
|
||||||
|
recordCardsAdded: (count) => {
|
||||||
|
recordedCards.push(count);
|
||||||
|
},
|
||||||
getDeck: () => 'My "Japanese" Deck',
|
getDeck: () => 'My "Japanese" Deck',
|
||||||
findNotes: async (query) => {
|
findNotes: async (query) => {
|
||||||
findNotesQueries.push(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);
|
await waitForCondition(() => processed.length === 1);
|
||||||
assert.deepEqual(findNotesQueries, ['"deck:My \\"Japanese\\" Deck" added:1']);
|
assert.deepEqual(findNotesQueries, ['"deck:My \\"Japanese\\" Deck" added:1']);
|
||||||
assert.deepEqual(processed, [501]);
|
assert.deepEqual(processed, [501]);
|
||||||
|
assert.deepEqual(recordedCards, [1]);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('proxy detects self-referential loop configuration', () => {
|
test('proxy detects self-referential loop configuration', () => {
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ interface AnkiConnectEnvelope {
|
|||||||
export interface AnkiConnectProxyServerDeps {
|
export interface AnkiConnectProxyServerDeps {
|
||||||
shouldAutoUpdateNewCards: () => boolean;
|
shouldAutoUpdateNewCards: () => boolean;
|
||||||
processNewCard: (noteId: number) => Promise<void>;
|
processNewCard: (noteId: number) => Promise<void>;
|
||||||
|
recordCardsAdded?: (count: number, noteIds: number[]) => void;
|
||||||
getDeck?: () => string | undefined;
|
getDeck?: () => string | undefined;
|
||||||
findNotes?: (
|
findNotes?: (
|
||||||
query: string,
|
query: string,
|
||||||
@@ -332,12 +333,14 @@ export class AnkiConnectProxyServer {
|
|||||||
|
|
||||||
private enqueueNotes(noteIds: number[]): void {
|
private enqueueNotes(noteIds: number[]): void {
|
||||||
let enqueuedCount = 0;
|
let enqueuedCount = 0;
|
||||||
|
const acceptedIds: number[] = [];
|
||||||
for (const noteId of noteIds) {
|
for (const noteId of noteIds) {
|
||||||
if (this.pendingNoteIdSet.has(noteId) || this.inFlightNoteIds.has(noteId)) {
|
if (this.pendingNoteIdSet.has(noteId) || this.inFlightNoteIds.has(noteId)) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
this.pendingNoteIds.push(noteId);
|
this.pendingNoteIds.push(noteId);
|
||||||
this.pendingNoteIdSet.add(noteId);
|
this.pendingNoteIdSet.add(noteId);
|
||||||
|
acceptedIds.push(noteId);
|
||||||
enqueuedCount += 1;
|
enqueuedCount += 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -345,6 +348,7 @@ export class AnkiConnectProxyServer {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this.deps.recordCardsAdded?.(enqueuedCount, acceptedIds);
|
||||||
this.deps.logInfo(`[anki-proxy] Enqueued ${enqueuedCount} note(s) for enrichment`);
|
this.deps.logInfo(`[anki-proxy] Enqueued ${enqueuedCount} note(s) for enrichment`);
|
||||||
this.processQueue();
|
this.processQueue();
|
||||||
}
|
}
|
||||||
|
|||||||
35
src/anki-integration/polling.test.ts
Normal file
35
src/anki-integration/polling.test.ts
Normal 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]);
|
||||||
|
});
|
||||||
@@ -9,6 +9,7 @@ export interface PollingRunnerDeps {
|
|||||||
) => Promise<number[]>;
|
) => Promise<number[]>;
|
||||||
shouldAutoUpdateNewCards: () => boolean;
|
shouldAutoUpdateNewCards: () => boolean;
|
||||||
processNewCard: (noteId: number) => Promise<void>;
|
processNewCard: (noteId: number) => Promise<void>;
|
||||||
|
recordCardsAdded?: (count: number, noteIds: number[]) => void;
|
||||||
isUpdateInProgress: () => boolean;
|
isUpdateInProgress: () => boolean;
|
||||||
setUpdateInProgress: (value: boolean) => void;
|
setUpdateInProgress: (value: boolean) => void;
|
||||||
getTrackedNoteIds: () => Set<number>;
|
getTrackedNoteIds: () => Set<number>;
|
||||||
@@ -80,6 +81,7 @@ export class PollingRunner {
|
|||||||
previousNoteIds.add(noteId);
|
previousNoteIds.add(noteId);
|
||||||
}
|
}
|
||||||
this.deps.setTrackedNoteIds(previousNoteIds);
|
this.deps.setTrackedNoteIds(previousNoteIds);
|
||||||
|
this.deps.recordCardsAdded?.(newNoteIds.length, newNoteIds);
|
||||||
|
|
||||||
if (this.deps.shouldAutoUpdateNewCards()) {
|
if (this.deps.shouldAutoUpdateNewCards()) {
|
||||||
for (const noteId of newNoteIds) {
|
for (const noteId of newNoteIds) {
|
||||||
|
|||||||
@@ -2,10 +2,12 @@ import { RawConfig, ResolvedConfig } from '../types';
|
|||||||
import { CORE_DEFAULT_CONFIG } from './definitions/defaults-core';
|
import { CORE_DEFAULT_CONFIG } from './definitions/defaults-core';
|
||||||
import { IMMERSION_DEFAULT_CONFIG } from './definitions/defaults-immersion';
|
import { IMMERSION_DEFAULT_CONFIG } from './definitions/defaults-immersion';
|
||||||
import { INTEGRATIONS_DEFAULT_CONFIG } from './definitions/defaults-integrations';
|
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 { SUBTITLE_DEFAULT_CONFIG } from './definitions/defaults-subtitle';
|
||||||
import { buildCoreConfigOptionRegistry } from './definitions/options-core';
|
import { buildCoreConfigOptionRegistry } from './definitions/options-core';
|
||||||
import { buildImmersionConfigOptionRegistry } from './definitions/options-immersion';
|
import { buildImmersionConfigOptionRegistry } from './definitions/options-immersion';
|
||||||
import { buildIntegrationConfigOptionRegistry } from './definitions/options-integrations';
|
import { buildIntegrationConfigOptionRegistry } from './definitions/options-integrations';
|
||||||
|
import { buildStatsConfigOptionRegistry } from './definitions/options-stats';
|
||||||
import { buildSubtitleConfigOptionRegistry } from './definitions/options-subtitle';
|
import { buildSubtitleConfigOptionRegistry } from './definitions/options-subtitle';
|
||||||
import { buildRuntimeOptionRegistry } from './definitions/runtime-options';
|
import { buildRuntimeOptionRegistry } from './definitions/runtime-options';
|
||||||
import { CONFIG_TEMPLATE_SECTIONS } from './definitions/template-sections';
|
import { CONFIG_TEMPLATE_SECTIONS } from './definitions/template-sections';
|
||||||
@@ -36,6 +38,7 @@ const { ankiConnect, jimaku, anilist, yomitan, jellyfin, discordPresence, ai, yo
|
|||||||
INTEGRATIONS_DEFAULT_CONFIG;
|
INTEGRATIONS_DEFAULT_CONFIG;
|
||||||
const { subtitleStyle } = SUBTITLE_DEFAULT_CONFIG;
|
const { subtitleStyle } = SUBTITLE_DEFAULT_CONFIG;
|
||||||
const { immersionTracking } = IMMERSION_DEFAULT_CONFIG;
|
const { immersionTracking } = IMMERSION_DEFAULT_CONFIG;
|
||||||
|
const { stats } = STATS_DEFAULT_CONFIG;
|
||||||
|
|
||||||
export const DEFAULT_CONFIG: ResolvedConfig = {
|
export const DEFAULT_CONFIG: ResolvedConfig = {
|
||||||
subtitlePosition,
|
subtitlePosition,
|
||||||
@@ -60,6 +63,7 @@ export const DEFAULT_CONFIG: ResolvedConfig = {
|
|||||||
ai,
|
ai,
|
||||||
youtubeSubgen,
|
youtubeSubgen,
|
||||||
immersionTracking,
|
immersionTracking,
|
||||||
|
stats,
|
||||||
};
|
};
|
||||||
|
|
||||||
export const DEFAULT_ANKI_CONNECT_CONFIG = DEFAULT_CONFIG.ankiConnect;
|
export const DEFAULT_ANKI_CONNECT_CONFIG = DEFAULT_CONFIG.ankiConnect;
|
||||||
@@ -71,6 +75,7 @@ export const CONFIG_OPTION_REGISTRY = [
|
|||||||
...buildSubtitleConfigOptionRegistry(DEFAULT_CONFIG),
|
...buildSubtitleConfigOptionRegistry(DEFAULT_CONFIG),
|
||||||
...buildIntegrationConfigOptionRegistry(DEFAULT_CONFIG, RUNTIME_OPTION_REGISTRY),
|
...buildIntegrationConfigOptionRegistry(DEFAULT_CONFIG, RUNTIME_OPTION_REGISTRY),
|
||||||
...buildImmersionConfigOptionRegistry(DEFAULT_CONFIG),
|
...buildImmersionConfigOptionRegistry(DEFAULT_CONFIG),
|
||||||
|
...buildStatsConfigOptionRegistry(DEFAULT_CONFIG),
|
||||||
];
|
];
|
||||||
|
|
||||||
export { CONFIG_TEMPLATE_SECTIONS };
|
export { CONFIG_TEMPLATE_SECTIONS };
|
||||||
|
|||||||
10
src/config/definitions/defaults-stats.ts
Normal file
10
src/config/definitions/defaults-stats.ts
Normal 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,
|
||||||
|
},
|
||||||
|
};
|
||||||
33
src/config/definitions/options-stats.ts
Normal file
33
src/config/definitions/options-stats.ts
Normal 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.',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
@@ -176,6 +176,14 @@ const IMMERSION_TEMPLATE_SECTIONS: ConfigTemplateSection[] = [
|
|||||||
],
|
],
|
||||||
key: 'immersionTracking',
|
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[] = [
|
export const CONFIG_TEMPLATE_SECTIONS: ConfigTemplateSection[] = [
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { createResolveContext } from './resolve/context';
|
|||||||
import { applyCoreDomainConfig } from './resolve/core-domains';
|
import { applyCoreDomainConfig } from './resolve/core-domains';
|
||||||
import { applyImmersionTrackingConfig } from './resolve/immersion-tracking';
|
import { applyImmersionTrackingConfig } from './resolve/immersion-tracking';
|
||||||
import { applyIntegrationConfig } from './resolve/integrations';
|
import { applyIntegrationConfig } from './resolve/integrations';
|
||||||
|
import { applyStatsConfig } from './resolve/stats';
|
||||||
import { applySubtitleDomainConfig } from './resolve/subtitle-domains';
|
import { applySubtitleDomainConfig } from './resolve/subtitle-domains';
|
||||||
import { applyTopLevelConfig } from './resolve/top-level';
|
import { applyTopLevelConfig } from './resolve/top-level';
|
||||||
|
|
||||||
@@ -13,6 +14,7 @@ const APPLY_RESOLVE_STEPS = [
|
|||||||
applySubtitleDomainConfig,
|
applySubtitleDomainConfig,
|
||||||
applyIntegrationConfig,
|
applyIntegrationConfig,
|
||||||
applyImmersionTrackingConfig,
|
applyImmersionTrackingConfig,
|
||||||
|
applyStatsConfig,
|
||||||
applyAnkiConnectResolution,
|
applyAnkiConnectResolution,
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
|
|||||||
36
src/config/resolve/stats.ts
Normal file
36
src/config/resolve/stats.ts
Normal 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.');
|
||||||
|
}
|
||||||
|
}
|
||||||
773
src/core/services/__tests__/stats-server.test.ts
Normal file
773
src/core/services/__tests__/stats-server.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -34,6 +34,7 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
|
|||||||
anilistSetup: false,
|
anilistSetup: false,
|
||||||
anilistRetryQueue: false,
|
anilistRetryQueue: false,
|
||||||
dictionary: false,
|
dictionary: false,
|
||||||
|
stats: false,
|
||||||
jellyfin: false,
|
jellyfin: false,
|
||||||
jellyfinLogin: false,
|
jellyfinLogin: false,
|
||||||
jellyfinLogout: false,
|
jellyfinLogout: false,
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
|
|||||||
anilistSetup: false,
|
anilistSetup: false,
|
||||||
anilistRetryQueue: false,
|
anilistRetryQueue: false,
|
||||||
dictionary: false,
|
dictionary: false,
|
||||||
|
stats: false,
|
||||||
jellyfin: false,
|
jellyfin: false,
|
||||||
jellyfinLogin: false,
|
jellyfinLogin: false,
|
||||||
jellyfinLogout: false,
|
jellyfinLogout: false,
|
||||||
@@ -177,6 +178,9 @@ function createDeps(overrides: Partial<CliCommandServiceDeps> = {}) {
|
|||||||
mediaTitle: 'Test',
|
mediaTitle: 'Test',
|
||||||
entryCount: 10,
|
entryCount: 10,
|
||||||
}),
|
}),
|
||||||
|
runStatsCommand: async () => {
|
||||||
|
calls.push('runStatsCommand');
|
||||||
|
},
|
||||||
runJellyfinCommand: async () => {
|
runJellyfinCommand: async () => {
|
||||||
calls.push('runJellyfinCommand');
|
calls.push('runJellyfinCommand');
|
||||||
},
|
},
|
||||||
@@ -249,6 +253,21 @@ test('handleCliCommand opens first-run setup window for --setup', () => {
|
|||||||
assert.equal(calls.includes('openYomitanSettingsDelayed:1000'), false);
|
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', () => {
|
test('handleCliCommand applies cli log level for second-instance commands', () => {
|
||||||
const { deps, calls } = createDeps({
|
const { deps, calls } = createDeps({
|
||||||
setLogLevel: (level) => {
|
setLogLevel: (level) => {
|
||||||
|
|||||||
@@ -61,6 +61,7 @@ export interface CliCommandServiceDeps {
|
|||||||
mediaTitle: string;
|
mediaTitle: string;
|
||||||
entryCount: number;
|
entryCount: number;
|
||||||
}>;
|
}>;
|
||||||
|
runStatsCommand: (args: CliArgs, source: CliCommandSource) => Promise<void>;
|
||||||
runJellyfinCommand: (args: CliArgs) => Promise<void>;
|
runJellyfinCommand: (args: CliArgs) => Promise<void>;
|
||||||
printHelp: () => void;
|
printHelp: () => void;
|
||||||
hasMainWindow: () => boolean;
|
hasMainWindow: () => boolean;
|
||||||
@@ -154,6 +155,7 @@ export interface CliCommandDepsRuntimeOptions {
|
|||||||
};
|
};
|
||||||
jellyfin: {
|
jellyfin: {
|
||||||
openSetup: () => void;
|
openSetup: () => void;
|
||||||
|
runStatsCommand: (args: CliArgs, source: CliCommandSource) => Promise<void>;
|
||||||
runCommand: (args: CliArgs) => Promise<void>;
|
runCommand: (args: CliArgs) => Promise<void>;
|
||||||
};
|
};
|
||||||
ui: UiCliRuntime;
|
ui: UiCliRuntime;
|
||||||
@@ -222,6 +224,7 @@ export function createCliCommandDepsRuntime(
|
|||||||
getAnilistQueueStatus: options.anilist.getQueueStatus,
|
getAnilistQueueStatus: options.anilist.getQueueStatus,
|
||||||
retryAnilistQueue: options.anilist.retryQueueNow,
|
retryAnilistQueue: options.anilist.retryQueueNow,
|
||||||
generateCharacterDictionary: options.dictionary.generate,
|
generateCharacterDictionary: options.dictionary.generate,
|
||||||
|
runStatsCommand: options.jellyfin.runStatsCommand,
|
||||||
runJellyfinCommand: options.jellyfin.runCommand,
|
runJellyfinCommand: options.jellyfin.runCommand,
|
||||||
printHelp: options.ui.printHelp,
|
printHelp: options.ui.printHelp,
|
||||||
hasMainWindow: options.app.hasMainWindow,
|
hasMainWindow: options.app.hasMainWindow,
|
||||||
@@ -410,6 +413,8 @@ export function handleCliCommand(
|
|||||||
deps.stopApp();
|
deps.stopApp();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
} else if (args.stats) {
|
||||||
|
void deps.runStatsCommand(args, source);
|
||||||
} else if (args.anilistRetryQueue) {
|
} else if (args.anilistRetryQueue) {
|
||||||
const queueStatus = deps.getAnilistQueueStatus();
|
const queueStatus = deps.getAnilistQueueStatus();
|
||||||
deps.log(
|
deps.log(
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import test from 'node:test';
|
import test from 'node:test';
|
||||||
import assert from 'node:assert/strict';
|
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';
|
import { IPC_CHANNELS } from '../../shared/ipc/contracts';
|
||||||
|
|
||||||
interface FakeIpcRegistrar {
|
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 () => {
|
test('createIpcDepsRuntime wires AniList handlers', async () => {
|
||||||
const calls: string[] = [];
|
const calls: string[] = [];
|
||||||
const deps = createIpcDepsRuntime({
|
const deps = createIpcDepsRuntime({
|
||||||
@@ -53,6 +137,7 @@ test('createIpcDepsRuntime wires AniList handlers', async () => {
|
|||||||
handleMpvCommand: () => {},
|
handleMpvCommand: () => {},
|
||||||
getKeybindings: () => [],
|
getKeybindings: () => [],
|
||||||
getConfiguredShortcuts: () => ({}),
|
getConfiguredShortcuts: () => ({}),
|
||||||
|
getStatsToggleKey: () => 'Backquote',
|
||||||
getControllerConfig: () => ({
|
getControllerConfig: () => ({
|
||||||
enabled: true,
|
enabled: true,
|
||||||
preferredGamepadId: '',
|
preferredGamepadId: '',
|
||||||
@@ -159,6 +244,7 @@ test('registerIpcHandlers rejects malformed runtime-option payloads', async () =
|
|||||||
handleMpvCommand: () => {},
|
handleMpvCommand: () => {},
|
||||||
getKeybindings: () => [],
|
getKeybindings: () => [],
|
||||||
getConfiguredShortcuts: () => ({}),
|
getConfiguredShortcuts: () => ({}),
|
||||||
|
getStatsToggleKey: () => 'Backquote',
|
||||||
getControllerConfig: () => ({
|
getControllerConfig: () => ({
|
||||||
enabled: true,
|
enabled: true,
|
||||||
preferredGamepadId: '',
|
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', () => {
|
test('registerIpcHandlers ignores malformed fire-and-forget payloads', () => {
|
||||||
const { registrar, handlers } = createFakeIpcRegistrar();
|
const { registrar, handlers } = createFakeIpcRegistrar();
|
||||||
const saves: unknown[] = [];
|
const saves: unknown[] = [];
|
||||||
@@ -299,6 +469,7 @@ test('registerIpcHandlers ignores malformed fire-and-forget payloads', () => {
|
|||||||
handleMpvCommand: () => {},
|
handleMpvCommand: () => {},
|
||||||
getKeybindings: () => [],
|
getKeybindings: () => [],
|
||||||
getConfiguredShortcuts: () => ({}),
|
getConfiguredShortcuts: () => ({}),
|
||||||
|
getStatsToggleKey: () => 'Backquote',
|
||||||
getControllerConfig: () => ({
|
getControllerConfig: () => ({
|
||||||
enabled: true,
|
enabled: true,
|
||||||
preferredGamepadId: '',
|
preferredGamepadId: '',
|
||||||
@@ -400,6 +571,7 @@ test('registerIpcHandlers awaits saveControllerPreference through request-respon
|
|||||||
handleMpvCommand: () => {},
|
handleMpvCommand: () => {},
|
||||||
getKeybindings: () => [],
|
getKeybindings: () => [],
|
||||||
getConfiguredShortcuts: () => ({}),
|
getConfiguredShortcuts: () => ({}),
|
||||||
|
getStatsToggleKey: () => 'Backquote',
|
||||||
getControllerConfig: () => ({
|
getControllerConfig: () => ({
|
||||||
enabled: true,
|
enabled: true,
|
||||||
preferredGamepadId: '',
|
preferredGamepadId: '',
|
||||||
@@ -508,6 +680,7 @@ test('registerIpcHandlers rejects malformed controller preference payloads', asy
|
|||||||
handleMpvCommand: () => {},
|
handleMpvCommand: () => {},
|
||||||
getKeybindings: () => [],
|
getKeybindings: () => [],
|
||||||
getConfiguredShortcuts: () => ({}),
|
getConfiguredShortcuts: () => ({}),
|
||||||
|
getStatsToggleKey: () => 'Backquote',
|
||||||
getControllerConfig: () => ({
|
getControllerConfig: () => ({
|
||||||
enabled: true,
|
enabled: true,
|
||||||
preferredGamepadId: '',
|
preferredGamepadId: '',
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ export interface IpcServiceDeps {
|
|||||||
handleMpvCommand: (command: Array<string | number>) => void;
|
handleMpvCommand: (command: Array<string | number>) => void;
|
||||||
getKeybindings: () => unknown;
|
getKeybindings: () => unknown;
|
||||||
getConfiguredShortcuts: () => unknown;
|
getConfiguredShortcuts: () => unknown;
|
||||||
|
getStatsToggleKey: () => string;
|
||||||
getControllerConfig: () => ResolvedControllerConfig;
|
getControllerConfig: () => ResolvedControllerConfig;
|
||||||
saveControllerPreference: (update: ControllerPreferenceUpdate) => void | Promise<void>;
|
saveControllerPreference: (update: ControllerPreferenceUpdate) => void | Promise<void>;
|
||||||
getSecondarySubMode: () => unknown;
|
getSecondarySubMode: () => unknown;
|
||||||
@@ -65,6 +66,21 @@ export interface IpcServiceDeps {
|
|||||||
getAnilistQueueStatus: () => unknown;
|
getAnilistQueueStatus: () => unknown;
|
||||||
retryAnilistQueueNow: () => Promise<{ ok: boolean; message: string }>;
|
retryAnilistQueueNow: () => Promise<{ ok: boolean; message: string }>;
|
||||||
appendClipboardVideoToQueue: () => { 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 {
|
interface WindowLike {
|
||||||
@@ -113,6 +129,7 @@ export interface IpcDepsRuntimeOptions {
|
|||||||
handleMpvCommand: (command: Array<string | number>) => void;
|
handleMpvCommand: (command: Array<string | number>) => void;
|
||||||
getKeybindings: () => unknown;
|
getKeybindings: () => unknown;
|
||||||
getConfiguredShortcuts: () => unknown;
|
getConfiguredShortcuts: () => unknown;
|
||||||
|
getStatsToggleKey: () => string;
|
||||||
getControllerConfig: () => ResolvedControllerConfig;
|
getControllerConfig: () => ResolvedControllerConfig;
|
||||||
saveControllerPreference: (update: ControllerPreferenceUpdate) => void | Promise<void>;
|
saveControllerPreference: (update: ControllerPreferenceUpdate) => void | Promise<void>;
|
||||||
getSecondarySubMode: () => unknown;
|
getSecondarySubMode: () => unknown;
|
||||||
@@ -130,6 +147,7 @@ export interface IpcDepsRuntimeOptions {
|
|||||||
getAnilistQueueStatus: () => unknown;
|
getAnilistQueueStatus: () => unknown;
|
||||||
retryAnilistQueueNow: () => Promise<{ ok: boolean; message: string }>;
|
retryAnilistQueueNow: () => Promise<{ ok: boolean; message: string }>;
|
||||||
appendClipboardVideoToQueue: () => { ok: boolean; message: string };
|
appendClipboardVideoToQueue: () => { ok: boolean; message: string };
|
||||||
|
getImmersionTracker?: () => IpcServiceDeps['immersionTracker'];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createIpcDepsRuntime(options: IpcDepsRuntimeOptions): IpcServiceDeps {
|
export function createIpcDepsRuntime(options: IpcDepsRuntimeOptions): IpcServiceDeps {
|
||||||
@@ -166,6 +184,7 @@ export function createIpcDepsRuntime(options: IpcDepsRuntimeOptions): IpcService
|
|||||||
handleMpvCommand: options.handleMpvCommand,
|
handleMpvCommand: options.handleMpvCommand,
|
||||||
getKeybindings: options.getKeybindings,
|
getKeybindings: options.getKeybindings,
|
||||||
getConfiguredShortcuts: options.getConfiguredShortcuts,
|
getConfiguredShortcuts: options.getConfiguredShortcuts,
|
||||||
|
getStatsToggleKey: options.getStatsToggleKey,
|
||||||
getControllerConfig: options.getControllerConfig,
|
getControllerConfig: options.getControllerConfig,
|
||||||
saveControllerPreference: options.saveControllerPreference,
|
saveControllerPreference: options.saveControllerPreference,
|
||||||
getSecondarySubMode: options.getSecondarySubMode,
|
getSecondarySubMode: options.getSecondarySubMode,
|
||||||
@@ -187,10 +206,24 @@ export function createIpcDepsRuntime(options: IpcDepsRuntimeOptions): IpcService
|
|||||||
getAnilistQueueStatus: options.getAnilistQueueStatus,
|
getAnilistQueueStatus: options.getAnilistQueueStatus,
|
||||||
retryAnilistQueueNow: options.retryAnilistQueueNow,
|
retryAnilistQueueNow: options.retryAnilistQueueNow,
|
||||||
appendClipboardVideoToQueue: options.appendClipboardVideoToQueue,
|
appendClipboardVideoToQueue: options.appendClipboardVideoToQueue,
|
||||||
|
get immersionTracker() {
|
||||||
|
return options.getImmersionTracker?.() ?? null;
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function registerIpcHandlers(deps: IpcServiceDeps, ipc: IpcMainRegistrar = ipcMain): void {
|
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.on(
|
||||||
IPC_CHANNELS.command.setIgnoreMouseEvents,
|
IPC_CHANNELS.command.setIgnoreMouseEvents,
|
||||||
(event: unknown, ignore: unknown, options: unknown = {}) => {
|
(event: unknown, ignore: unknown, options: unknown = {}) => {
|
||||||
@@ -299,6 +332,10 @@ export function registerIpcHandlers(deps: IpcServiceDeps, ipc: IpcMainRegistrar
|
|||||||
return deps.getConfiguredShortcuts();
|
return deps.getConfiguredShortcuts();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
ipc.handle(IPC_CHANNELS.request.getStatsToggleKey, () => {
|
||||||
|
return deps.getStatsToggleKey();
|
||||||
|
});
|
||||||
|
|
||||||
ipc.handle(IPC_CHANNELS.request.getControllerConfig, () => {
|
ipc.handle(IPC_CHANNELS.request.getControllerConfig, () => {
|
||||||
return deps.getControllerConfig();
|
return deps.getControllerConfig();
|
||||||
});
|
});
|
||||||
@@ -384,4 +421,106 @@ export function registerIpcHandlers(deps: IpcServiceDeps, ipc: IpcMainRegistrar
|
|||||||
ipc.handle(IPC_CHANNELS.request.appendClipboardVideoToQueue, () => {
|
ipc.handle(IPC_CHANNELS.request.appendClipboardVideoToQueue, () => {
|
||||||
return deps.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;
|
||||||
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -59,6 +59,7 @@ const MPV_SUBTITLE_PROPERTY_OBSERVATIONS: string[] = [
|
|||||||
'sub-ass-override',
|
'sub-ass-override',
|
||||||
'sub-use-margins',
|
'sub-use-margins',
|
||||||
'pause',
|
'pause',
|
||||||
|
'duration',
|
||||||
'media-title',
|
'media-title',
|
||||||
'secondary-sub-visibility',
|
'secondary-sub-visibility',
|
||||||
'sub-visibility',
|
'sub-visibility',
|
||||||
|
|||||||
@@ -87,6 +87,7 @@ function createDeps(overrides: Partial<MpvProtocolHandleMessageDeps> = {}): {
|
|||||||
getPauseAtTime: () => null,
|
getPauseAtTime: () => null,
|
||||||
setPauseAtTime: () => {},
|
setPauseAtTime: () => {},
|
||||||
emitTimePosChange: () => {},
|
emitTimePosChange: () => {},
|
||||||
|
emitDurationChange: () => {},
|
||||||
emitPauseChange: () => {},
|
emitPauseChange: () => {},
|
||||||
autoLoadSecondarySubTrack: () => {},
|
autoLoadSecondarySubTrack: () => {},
|
||||||
setCurrentVideoPath: () => {},
|
setCurrentVideoPath: () => {},
|
||||||
|
|||||||
@@ -61,6 +61,7 @@ export interface MpvProtocolHandleMessageDeps {
|
|||||||
emitMediaPathChange: (payload: { path: string }) => void;
|
emitMediaPathChange: (payload: { path: string }) => void;
|
||||||
emitMediaTitleChange: (payload: { title: string | null }) => void;
|
emitMediaTitleChange: (payload: { title: string | null }) => void;
|
||||||
emitTimePosChange: (payload: { time: number }) => void;
|
emitTimePosChange: (payload: { time: number }) => void;
|
||||||
|
emitDurationChange: (payload: { duration: number }) => void;
|
||||||
emitPauseChange: (payload: { paused: boolean }) => void;
|
emitPauseChange: (payload: { paused: boolean }) => void;
|
||||||
emitSubtitleMetricsChange: (payload: Partial<MpvSubtitleRenderMetrics>) => void;
|
emitSubtitleMetricsChange: (payload: Partial<MpvSubtitleRenderMetrics>) => void;
|
||||||
setCurrentSecondarySubText: (text: string) => void;
|
setCurrentSecondarySubText: (text: string) => void;
|
||||||
@@ -172,6 +173,11 @@ export async function dispatchMpvProtocolMessage(
|
|||||||
deps.setPauseAtTime(null);
|
deps.setPauseAtTime(null);
|
||||||
deps.sendCommand({ command: ['set_property', 'pause', true] });
|
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') {
|
} else if (msg.name === 'pause') {
|
||||||
deps.emitPauseChange({ paused: asBoolean(msg.data, false) });
|
deps.emitPauseChange({ paused: asBoolean(msg.data, false) });
|
||||||
} else if (msg.name === 'media-title') {
|
} else if (msg.name === 'media-title') {
|
||||||
|
|||||||
@@ -115,6 +115,7 @@ export interface MpvIpcClientEventMap {
|
|||||||
'subtitle-ass-change': { text: string };
|
'subtitle-ass-change': { text: string };
|
||||||
'subtitle-timing': { text: string; start: number; end: number };
|
'subtitle-timing': { text: string; start: number; end: number };
|
||||||
'time-pos-change': { time: number };
|
'time-pos-change': { time: number };
|
||||||
|
'duration-change': { duration: number };
|
||||||
'pause-change': { paused: boolean };
|
'pause-change': { paused: boolean };
|
||||||
'secondary-subtitle-change': { text: string };
|
'secondary-subtitle-change': { text: string };
|
||||||
'media-path-change': { path: string };
|
'media-path-change': { path: string };
|
||||||
@@ -314,6 +315,9 @@ export class MpvIpcClient implements MpvClient {
|
|||||||
emitTimePosChange: (payload) => {
|
emitTimePosChange: (payload) => {
|
||||||
this.emit('time-pos-change', payload);
|
this.emit('time-pos-change', payload);
|
||||||
},
|
},
|
||||||
|
emitDurationChange: (payload) => {
|
||||||
|
this.emit('duration-change', payload);
|
||||||
|
},
|
||||||
emitPauseChange: (payload) => {
|
emitPauseChange: (payload) => {
|
||||||
this.playbackPaused = payload.paused;
|
this.playbackPaused = payload.paused;
|
||||||
this.emit('pause-change', payload);
|
this.emit('pause-change', payload);
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
|
|||||||
anilistSetup: false,
|
anilistSetup: false,
|
||||||
anilistRetryQueue: false,
|
anilistRetryQueue: false,
|
||||||
dictionary: false,
|
dictionary: false,
|
||||||
|
stats: false,
|
||||||
jellyfin: false,
|
jellyfin: false,
|
||||||
jellyfinLogin: false,
|
jellyfinLogin: false,
|
||||||
jellyfinLogout: false,
|
jellyfinLogout: false,
|
||||||
|
|||||||
95
src/core/services/startup.test.ts
Normal file
95
src/core/services/startup.test.ts
Normal 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']);
|
||||||
|
});
|
||||||
@@ -135,6 +135,7 @@ export interface AppReadyRuntimeDeps {
|
|||||||
logDebug?: (message: string) => void;
|
logDebug?: (message: string) => void;
|
||||||
onCriticalConfigErrors?: (errors: string[]) => void;
|
onCriticalConfigErrors?: (errors: string[]) => void;
|
||||||
now?: () => number;
|
now?: () => number;
|
||||||
|
shouldUseMinimalStartup?: () => boolean;
|
||||||
shouldSkipHeavyStartup?: () => boolean;
|
shouldSkipHeavyStartup?: () => boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -183,6 +184,12 @@ export async function runAppReadyRuntime(deps: AppReadyRuntimeDeps): Promise<voi
|
|||||||
const now = deps.now ?? (() => Date.now());
|
const now = deps.now ?? (() => Date.now());
|
||||||
const startupStartedAtMs = now();
|
const startupStartedAtMs = now();
|
||||||
deps.ensureDefaultConfigBootstrap();
|
deps.ensureDefaultConfigBootstrap();
|
||||||
|
if (deps.shouldUseMinimalStartup?.()) {
|
||||||
|
deps.reloadConfig();
|
||||||
|
deps.handleInitialArgs();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (deps.shouldSkipHeavyStartup?.()) {
|
if (deps.shouldSkipHeavyStartup?.()) {
|
||||||
await deps.loadYomitanExtension();
|
await deps.loadYomitanExtension();
|
||||||
deps.reloadConfig();
|
deps.reloadConfig();
|
||||||
|
|||||||
372
src/core/services/stats-server.ts
Normal file
372
src/core/services/stats-server.ts
Normal 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();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
64
src/core/services/stats-window-runtime.ts
Normal file
64
src/core/services/stats-window-runtime.ts
Normal 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',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
90
src/core/services/stats-window.test.ts
Normal file
90
src/core/services/stats-window.test.ts
Normal 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',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
98
src/core/services/stats-window.ts
Normal file
98
src/core/services/stats-window.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -78,7 +78,7 @@ export function createBuildMineSentenceCardMainDepsHandler<TAnki, TMpv>(deps: {
|
|||||||
mpvClient: TMpv;
|
mpvClient: TMpv;
|
||||||
showMpvOsd: (text: string) => void;
|
showMpvOsd: (text: string) => void;
|
||||||
}) => Promise<boolean>;
|
}) => Promise<boolean>;
|
||||||
recordCardsMined: (count: number) => void;
|
recordCardsMined: (count: number, noteIds?: number[]) => void;
|
||||||
}) {
|
}) {
|
||||||
return () => ({
|
return () => ({
|
||||||
getAnkiIntegration: () => deps.getAnkiIntegration(),
|
getAnkiIntegration: () => deps.getAnkiIntegration(),
|
||||||
@@ -89,6 +89,6 @@ export function createBuildMineSentenceCardMainDepsHandler<TAnki, TMpv>(deps: {
|
|||||||
mpvClient: TMpv;
|
mpvClient: TMpv;
|
||||||
showMpvOsd: (text: string) => void;
|
showMpvOsd: (text: string) => void;
|
||||||
}) => deps.mineSentenceCardCore(options),
|
}) => deps.mineSentenceCardCore(options),
|
||||||
recordCardsMined: (count: number) => deps.recordCardsMined(count),
|
recordCardsMined: (count: number, noteIds?: number[]) => deps.recordCardsMined(count, noteIds),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -75,7 +75,7 @@ export function createMineSentenceCardHandler<TAnki, TMpv>(deps: {
|
|||||||
mpvClient: TMpv;
|
mpvClient: TMpv;
|
||||||
showMpvOsd: (text: string) => void;
|
showMpvOsd: (text: string) => void;
|
||||||
}) => Promise<boolean>;
|
}) => Promise<boolean>;
|
||||||
recordCardsMined: (count: number) => void;
|
recordCardsMined: (count: number, noteIds?: number[]) => void;
|
||||||
}) {
|
}) {
|
||||||
return async (): Promise<void> => {
|
return async (): Promise<void> => {
|
||||||
const created = await deps.mineSentenceCardCore({
|
const created = await deps.mineSentenceCardCore({
|
||||||
|
|||||||
111
src/main/runtime/stats-cli-command.test.ts
Normal file
111
src/main/runtime/stats-cli-command.test.ts
Normal 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 },
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
99
src/main/runtime/stats-cli-command.ts
Normal file
99
src/main/runtime/stats-cli-command.ts
Normal 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
49
src/preload-stats.ts
Normal 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 });
|
||||||
@@ -207,6 +207,8 @@ const electronAPI: ElectronAPI = {
|
|||||||
ipcRenderer.invoke(IPC_CHANNELS.request.getKeybindings),
|
ipcRenderer.invoke(IPC_CHANNELS.request.getKeybindings),
|
||||||
getConfiguredShortcuts: (): Promise<Required<ShortcutsConfig>> =>
|
getConfiguredShortcuts: (): Promise<Required<ShortcutsConfig>> =>
|
||||||
ipcRenderer.invoke(IPC_CHANNELS.request.getConfigShortcuts),
|
ipcRenderer.invoke(IPC_CHANNELS.request.getConfigShortcuts),
|
||||||
|
getStatsToggleKey: (): Promise<string> =>
|
||||||
|
ipcRenderer.invoke(IPC_CHANNELS.request.getStatsToggleKey),
|
||||||
getControllerConfig: (): Promise<ResolvedControllerConfig> =>
|
getControllerConfig: (): Promise<ResolvedControllerConfig> =>
|
||||||
ipcRenderer.invoke(IPC_CHANNELS.request.getControllerConfig),
|
ipcRenderer.invoke(IPC_CHANNELS.request.getControllerConfig),
|
||||||
saveControllerPreference: (update: ControllerPreferenceUpdate): Promise<void> =>
|
saveControllerPreference: (update: ControllerPreferenceUpdate): Promise<void> =>
|
||||||
@@ -233,6 +235,10 @@ const electronAPI: ElectronAPI = {
|
|||||||
ipcRenderer.send(IPC_CHANNELS.command.toggleOverlay);
|
ipcRenderer.send(IPC_CHANNELS.command.toggleOverlay);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
toggleStatsOverlay: () => {
|
||||||
|
ipcRenderer.send(IPC_CHANNELS.command.toggleStatsOverlay);
|
||||||
|
},
|
||||||
|
|
||||||
getAnkiConnectStatus: (): Promise<boolean> =>
|
getAnkiConnectStatus: (): Promise<boolean> =>
|
||||||
ipcRenderer.invoke(IPC_CHANNELS.request.getAnkiConnectStatus),
|
ipcRenderer.invoke(IPC_CHANNELS.request.getAnkiConnectStatus),
|
||||||
setAnkiConnectEnabled: (enabled: boolean) => {
|
setAnkiConnectEnabled: (enabled: boolean) => {
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ export const IPC_CHANNELS = {
|
|||||||
kikuFieldGroupingRespond: 'kiku:field-grouping-respond',
|
kikuFieldGroupingRespond: 'kiku:field-grouping-respond',
|
||||||
reportOverlayContentBounds: 'overlay-content-bounds:report',
|
reportOverlayContentBounds: 'overlay-content-bounds:report',
|
||||||
overlayModalOpened: 'overlay:modal-opened',
|
overlayModalOpened: 'overlay:modal-opened',
|
||||||
|
toggleStatsOverlay: 'stats:toggle-overlay',
|
||||||
},
|
},
|
||||||
request: {
|
request: {
|
||||||
getVisibleOverlayVisibility: 'get-visible-overlay-visibility',
|
getVisibleOverlayVisibility: 'get-visible-overlay-visibility',
|
||||||
@@ -40,6 +41,7 @@ export const IPC_CHANNELS = {
|
|||||||
getMecabStatus: 'get-mecab-status',
|
getMecabStatus: 'get-mecab-status',
|
||||||
getKeybindings: 'get-keybindings',
|
getKeybindings: 'get-keybindings',
|
||||||
getConfigShortcuts: 'get-config-shortcuts',
|
getConfigShortcuts: 'get-config-shortcuts',
|
||||||
|
getStatsToggleKey: 'get-stats-toggle-key',
|
||||||
getControllerConfig: 'get-controller-config',
|
getControllerConfig: 'get-controller-config',
|
||||||
getSecondarySubMode: 'get-secondary-sub-mode',
|
getSecondarySubMode: 'get-secondary-sub-mode',
|
||||||
getCurrentSecondarySub: 'get-current-secondary-sub',
|
getCurrentSecondarySub: 'get-current-secondary-sub',
|
||||||
@@ -60,6 +62,19 @@ export const IPC_CHANNELS = {
|
|||||||
jimakuListFiles: 'jimaku:list-files',
|
jimakuListFiles: 'jimaku:list-files',
|
||||||
jimakuDownloadFile: 'jimaku:download-file',
|
jimakuDownloadFile: 'jimaku:download-file',
|
||||||
kikuBuildMergePreview: 'kiku:build-merge-preview',
|
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: {
|
event: {
|
||||||
subtitleSet: 'subtitle:set',
|
subtitleSet: 'subtitle:set',
|
||||||
|
|||||||
16
src/types.ts
16
src/types.ts
@@ -556,6 +556,13 @@ export interface YoutubeSubgenConfig {
|
|||||||
primarySubLanguages?: string[];
|
primarySubLanguages?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface StatsConfig {
|
||||||
|
toggleKey?: string;
|
||||||
|
serverPort?: number;
|
||||||
|
autoStartServer?: boolean;
|
||||||
|
autoOpenBrowser?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
export interface ImmersionTrackingConfig {
|
export interface ImmersionTrackingConfig {
|
||||||
enabled?: boolean;
|
enabled?: boolean;
|
||||||
dbPath?: string;
|
dbPath?: string;
|
||||||
@@ -595,6 +602,7 @@ export interface Config {
|
|||||||
ai?: AiConfig;
|
ai?: AiConfig;
|
||||||
youtubeSubgen?: YoutubeSubgenConfig;
|
youtubeSubgen?: YoutubeSubgenConfig;
|
||||||
immersionTracking?: ImmersionTrackingConfig;
|
immersionTracking?: ImmersionTrackingConfig;
|
||||||
|
stats?: StatsConfig;
|
||||||
logging?: {
|
logging?: {
|
||||||
level?: 'debug' | 'info' | 'warn' | 'error';
|
level?: 'debug' | 'info' | 'warn' | 'error';
|
||||||
};
|
};
|
||||||
@@ -790,6 +798,12 @@ export interface ResolvedConfig {
|
|||||||
vacuumIntervalDays: number;
|
vacuumIntervalDays: number;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
stats: {
|
||||||
|
toggleKey: string;
|
||||||
|
serverPort: number;
|
||||||
|
autoStartServer: boolean;
|
||||||
|
autoOpenBrowser: boolean;
|
||||||
|
};
|
||||||
logging: {
|
logging: {
|
||||||
level: 'debug' | 'info' | 'warn' | 'error';
|
level: 'debug' | 'info' | 'warn' | 'error';
|
||||||
};
|
};
|
||||||
@@ -976,6 +990,7 @@ export interface ElectronAPI {
|
|||||||
sendMpvCommand: (command: (string | number)[]) => void;
|
sendMpvCommand: (command: (string | number)[]) => void;
|
||||||
getKeybindings: () => Promise<Keybinding[]>;
|
getKeybindings: () => Promise<Keybinding[]>;
|
||||||
getConfiguredShortcuts: () => Promise<Required<ShortcutsConfig>>;
|
getConfiguredShortcuts: () => Promise<Required<ShortcutsConfig>>;
|
||||||
|
getStatsToggleKey: () => Promise<string>;
|
||||||
getControllerConfig: () => Promise<ResolvedControllerConfig>;
|
getControllerConfig: () => Promise<ResolvedControllerConfig>;
|
||||||
saveControllerPreference: (update: ControllerPreferenceUpdate) => Promise<void>;
|
saveControllerPreference: (update: ControllerPreferenceUpdate) => Promise<void>;
|
||||||
getJimakuMediaInfo: () => Promise<JimakuMediaInfo>;
|
getJimakuMediaInfo: () => Promise<JimakuMediaInfo>;
|
||||||
@@ -985,6 +1000,7 @@ export interface ElectronAPI {
|
|||||||
quitApp: () => void;
|
quitApp: () => void;
|
||||||
toggleDevTools: () => void;
|
toggleDevTools: () => void;
|
||||||
toggleOverlay: () => void;
|
toggleOverlay: () => void;
|
||||||
|
toggleStatsOverlay: () => void;
|
||||||
getAnkiConnectStatus: () => Promise<boolean>;
|
getAnkiConnectStatus: () => Promise<boolean>;
|
||||||
setAnkiConnectEnabled: (enabled: boolean) => void;
|
setAnkiConnectEnabled: (enabled: boolean) => void;
|
||||||
clearAnkiConnectHistory: () => void;
|
clearAnkiConnectHistory: () => void;
|
||||||
|
|||||||
Reference in New Issue
Block a user