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:
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,
|
||||
anilistRetryQueue: false,
|
||||
dictionary: false,
|
||||
stats: false,
|
||||
jellyfin: false,
|
||||
jellyfinLogin: false,
|
||||
jellyfinLogout: false,
|
||||
|
||||
@@ -34,6 +34,7 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
|
||||
anilistSetup: false,
|
||||
anilistRetryQueue: false,
|
||||
dictionary: false,
|
||||
stats: false,
|
||||
jellyfin: false,
|
||||
jellyfinLogin: false,
|
||||
jellyfinLogout: false,
|
||||
@@ -177,6 +178,9 @@ function createDeps(overrides: Partial<CliCommandServiceDeps> = {}) {
|
||||
mediaTitle: 'Test',
|
||||
entryCount: 10,
|
||||
}),
|
||||
runStatsCommand: async () => {
|
||||
calls.push('runStatsCommand');
|
||||
},
|
||||
runJellyfinCommand: async () => {
|
||||
calls.push('runJellyfinCommand');
|
||||
},
|
||||
@@ -249,6 +253,21 @@ test('handleCliCommand opens first-run setup window for --setup', () => {
|
||||
assert.equal(calls.includes('openYomitanSettingsDelayed:1000'), false);
|
||||
});
|
||||
|
||||
test('handleCliCommand dispatches stats command without overlay startup', async () => {
|
||||
const { deps, calls } = createDeps({
|
||||
runStatsCommand: async () => {
|
||||
calls.push('runStatsCommand');
|
||||
},
|
||||
});
|
||||
|
||||
handleCliCommand(makeArgs({ stats: true }), 'initial', deps);
|
||||
await Promise.resolve();
|
||||
|
||||
assert.ok(calls.includes('runStatsCommand'));
|
||||
assert.equal(calls.includes('initializeOverlayRuntime'), false);
|
||||
assert.equal(calls.includes('connectMpvClient'), false);
|
||||
});
|
||||
|
||||
test('handleCliCommand applies cli log level for second-instance commands', () => {
|
||||
const { deps, calls } = createDeps({
|
||||
setLogLevel: (level) => {
|
||||
|
||||
@@ -61,6 +61,7 @@ export interface CliCommandServiceDeps {
|
||||
mediaTitle: string;
|
||||
entryCount: number;
|
||||
}>;
|
||||
runStatsCommand: (args: CliArgs, source: CliCommandSource) => Promise<void>;
|
||||
runJellyfinCommand: (args: CliArgs) => Promise<void>;
|
||||
printHelp: () => void;
|
||||
hasMainWindow: () => boolean;
|
||||
@@ -154,6 +155,7 @@ export interface CliCommandDepsRuntimeOptions {
|
||||
};
|
||||
jellyfin: {
|
||||
openSetup: () => void;
|
||||
runStatsCommand: (args: CliArgs, source: CliCommandSource) => Promise<void>;
|
||||
runCommand: (args: CliArgs) => Promise<void>;
|
||||
};
|
||||
ui: UiCliRuntime;
|
||||
@@ -222,6 +224,7 @@ export function createCliCommandDepsRuntime(
|
||||
getAnilistQueueStatus: options.anilist.getQueueStatus,
|
||||
retryAnilistQueue: options.anilist.retryQueueNow,
|
||||
generateCharacterDictionary: options.dictionary.generate,
|
||||
runStatsCommand: options.jellyfin.runStatsCommand,
|
||||
runJellyfinCommand: options.jellyfin.runCommand,
|
||||
printHelp: options.ui.printHelp,
|
||||
hasMainWindow: options.app.hasMainWindow,
|
||||
@@ -410,6 +413,8 @@ export function handleCliCommand(
|
||||
deps.stopApp();
|
||||
}
|
||||
});
|
||||
} else if (args.stats) {
|
||||
void deps.runStatsCommand(args, source);
|
||||
} else if (args.anilistRetryQueue) {
|
||||
const queueStatus = deps.getAnilistQueueStatus();
|
||||
deps.log(
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import { createIpcDepsRuntime, registerIpcHandlers } from './ipc';
|
||||
import { createIpcDepsRuntime, registerIpcHandlers, type IpcServiceDeps } from './ipc';
|
||||
import { IPC_CHANNELS } from '../../shared/ipc/contracts';
|
||||
|
||||
interface FakeIpcRegistrar {
|
||||
@@ -77,6 +77,50 @@ function createControllerConfigFixture() {
|
||||
};
|
||||
}
|
||||
|
||||
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: () => createControllerConfigFixture(),
|
||||
saveControllerConfig: async () => {},
|
||||
saveControllerPreference: async () => {},
|
||||
getSecondarySubMode: () => 'hover',
|
||||
getCurrentSecondarySub: () => '',
|
||||
focusMainWindow: () => {},
|
||||
runSubsyncManual: async () => ({ ok: true, message: 'ok' }),
|
||||
getAnkiConnectStatus: () => false,
|
||||
getRuntimeOptions: () => [],
|
||||
setRuntimeOption: () => ({ ok: true }),
|
||||
cycleRuntimeOption: () => ({ ok: true }),
|
||||
reportOverlayContentBounds: () => {},
|
||||
getAnilistStatus: () => ({}),
|
||||
clearAnilistToken: () => {},
|
||||
openAnilistSetup: () => {},
|
||||
getAnilistQueueStatus: () => ({}),
|
||||
retryAnilistQueueNow: async () => ({ ok: true, message: 'ok' }),
|
||||
appendClipboardVideoToQueue: () => ({ ok: true, message: 'ok' }),
|
||||
immersionTracker: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
test('createIpcDepsRuntime wires AniList handlers', async () => {
|
||||
const calls: string[] = [];
|
||||
const deps = createIpcDepsRuntime({
|
||||
@@ -97,6 +141,7 @@ test('createIpcDepsRuntime wires AniList handlers', async () => {
|
||||
handleMpvCommand: () => {},
|
||||
getKeybindings: () => [],
|
||||
getConfiguredShortcuts: () => ({}),
|
||||
getStatsToggleKey: () => 'Backquote',
|
||||
getControllerConfig: () => createControllerConfigFixture(),
|
||||
saveControllerConfig: () => {},
|
||||
saveControllerPreference: () => {},
|
||||
@@ -164,6 +209,7 @@ test('registerIpcHandlers rejects malformed runtime-option payloads', async () =
|
||||
handleMpvCommand: () => {},
|
||||
getKeybindings: () => [],
|
||||
getConfiguredShortcuts: () => ({}),
|
||||
getStatsToggleKey: () => 'Backquote',
|
||||
getControllerConfig: () => createControllerConfigFixture(),
|
||||
saveControllerConfig: () => {},
|
||||
saveControllerPreference: () => {},
|
||||
@@ -232,6 +278,90 @@ test('registerIpcHandlers rejects malformed runtime-option payloads', async () =
|
||||
);
|
||||
});
|
||||
|
||||
test('registerIpcHandlers returns empty stats overview shape without a tracker', async () => {
|
||||
const { registrar, handlers } = createFakeIpcRegistrar();
|
||||
registerIpcHandlers(createRegisterIpcDeps(), registrar);
|
||||
|
||||
const overviewHandler = handlers.handle.get(IPC_CHANNELS.request.statsGetOverview);
|
||||
assert.ok(overviewHandler);
|
||||
assert.deepEqual(await overviewHandler!({}), {
|
||||
sessions: [],
|
||||
rollups: [],
|
||||
hints: {
|
||||
totalSessions: 0,
|
||||
activeSessions: 0,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test('registerIpcHandlers validates and clamps stats request limits', async () => {
|
||||
const { registrar, handlers } = createFakeIpcRegistrar();
|
||||
const calls: Array<[string, number, number?]> = [];
|
||||
|
||||
registerIpcHandlers(
|
||||
createRegisterIpcDeps({
|
||||
immersionTracker: {
|
||||
getSessionSummaries: async (limit = 0) => {
|
||||
calls.push(['sessions', limit]);
|
||||
return [];
|
||||
},
|
||||
getDailyRollups: async (limit = 0) => {
|
||||
calls.push(['daily', limit]);
|
||||
return [];
|
||||
},
|
||||
getMonthlyRollups: async (limit = 0) => {
|
||||
calls.push(['monthly', limit]);
|
||||
return [];
|
||||
},
|
||||
getQueryHints: async () => ({ totalSessions: 0, activeSessions: 0, episodesToday: 0, activeAnimeCount: 0 }),
|
||||
getSessionTimeline: async (sessionId: number, limit = 0) => {
|
||||
calls.push(['timeline', limit, sessionId]);
|
||||
return [];
|
||||
},
|
||||
getSessionEvents: async (sessionId: number, limit = 0) => {
|
||||
calls.push(['events', limit, sessionId]);
|
||||
return [];
|
||||
},
|
||||
getVocabularyStats: async (limit = 0) => {
|
||||
calls.push(['vocabulary', limit]);
|
||||
return [];
|
||||
},
|
||||
getKanjiStats: async (limit = 0) => {
|
||||
calls.push(['kanji', limit]);
|
||||
return [];
|
||||
},
|
||||
getMediaLibrary: async () => [],
|
||||
getMediaDetail: async () => null,
|
||||
getMediaSessions: async () => [],
|
||||
getMediaDailyRollups: async () => [],
|
||||
getCoverArt: async () => null,
|
||||
},
|
||||
}),
|
||||
registrar,
|
||||
);
|
||||
|
||||
await handlers.handle.get(IPC_CHANNELS.request.statsGetDailyRollups)!({}, -1);
|
||||
await handlers.handle.get(IPC_CHANNELS.request.statsGetMonthlyRollups)!(
|
||||
{},
|
||||
Number.POSITIVE_INFINITY,
|
||||
);
|
||||
await handlers.handle.get(IPC_CHANNELS.request.statsGetSessions)!({}, 9999);
|
||||
await handlers.handle.get(IPC_CHANNELS.request.statsGetSessionTimeline)!({}, 7, 12.5);
|
||||
await handlers.handle.get(IPC_CHANNELS.request.statsGetSessionEvents)!({}, 7, 0);
|
||||
await handlers.handle.get(IPC_CHANNELS.request.statsGetVocabulary)!({}, 1000);
|
||||
await handlers.handle.get(IPC_CHANNELS.request.statsGetKanji)!({}, NaN);
|
||||
|
||||
assert.deepEqual(calls, [
|
||||
['daily', 60],
|
||||
['monthly', 24],
|
||||
['sessions', 500],
|
||||
['timeline', 200, 7],
|
||||
['events', 500, 7],
|
||||
['vocabulary', 500],
|
||||
['kanji', 100],
|
||||
]);
|
||||
});
|
||||
|
||||
test('registerIpcHandlers ignores malformed fire-and-forget payloads', () => {
|
||||
const { registrar, handlers } = createFakeIpcRegistrar();
|
||||
const saves: unknown[] = [];
|
||||
@@ -265,10 +395,9 @@ test('registerIpcHandlers ignores malformed fire-and-forget payloads', () => {
|
||||
handleMpvCommand: () => {},
|
||||
getKeybindings: () => [],
|
||||
getConfiguredShortcuts: () => ({}),
|
||||
getStatsToggleKey: () => 'Backquote',
|
||||
getControllerConfig: () => createControllerConfigFixture(),
|
||||
saveControllerConfig: (update) => {
|
||||
controllerSaves.push(update);
|
||||
},
|
||||
saveControllerConfig: () => {},
|
||||
saveControllerPreference: (update) => {
|
||||
controllerSaves.push(update);
|
||||
},
|
||||
@@ -329,6 +458,7 @@ test('registerIpcHandlers awaits saveControllerPreference through request-respon
|
||||
handleMpvCommand: () => {},
|
||||
getKeybindings: () => [],
|
||||
getConfiguredShortcuts: () => ({}),
|
||||
getStatsToggleKey: () => 'Backquote',
|
||||
getControllerConfig: () => createControllerConfigFixture(),
|
||||
saveControllerConfig: async () => {},
|
||||
saveControllerPreference: async (update) => {
|
||||
@@ -376,85 +506,6 @@ test('registerIpcHandlers awaits saveControllerPreference through request-respon
|
||||
]);
|
||||
});
|
||||
|
||||
test('registerIpcHandlers awaits saveControllerConfig through request-response IPC', async () => {
|
||||
const { registrar, handlers } = createFakeIpcRegistrar();
|
||||
const controllerConfigSaves: unknown[] = [];
|
||||
registerIpcHandlers(
|
||||
{
|
||||
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: () => ({}),
|
||||
getControllerConfig: () => createControllerConfigFixture(),
|
||||
saveControllerConfig: async (update) => {
|
||||
await Promise.resolve();
|
||||
controllerConfigSaves.push(update);
|
||||
},
|
||||
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' }),
|
||||
},
|
||||
registrar,
|
||||
);
|
||||
|
||||
const saveHandler = handlers.handle.get(IPC_CHANNELS.command.saveControllerConfig);
|
||||
assert.ok(saveHandler);
|
||||
|
||||
await assert.rejects(
|
||||
async () => {
|
||||
await saveHandler!({}, { bindings: { toggleLookup: { kind: 'button', buttonIndex: -1 } } });
|
||||
},
|
||||
/Invalid controller config payload/,
|
||||
);
|
||||
|
||||
await saveHandler!({}, {
|
||||
preferredGamepadId: 'pad-2',
|
||||
bindings: {
|
||||
toggleLookup: { kind: 'button', buttonIndex: 11 },
|
||||
closeLookup: { kind: 'axis', axisIndex: 4, direction: 'negative' },
|
||||
leftStickHorizontal: { kind: 'axis', axisIndex: 7, dpadFallback: 'none' },
|
||||
},
|
||||
});
|
||||
|
||||
assert.deepEqual(controllerConfigSaves, [
|
||||
{
|
||||
preferredGamepadId: 'pad-2',
|
||||
bindings: {
|
||||
toggleLookup: { kind: 'button', buttonIndex: 11 },
|
||||
closeLookup: { kind: 'axis', axisIndex: 4, direction: 'negative' },
|
||||
leftStickHorizontal: { kind: 'axis', axisIndex: 7, dpadFallback: 'none' },
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test('registerIpcHandlers rejects malformed controller preference payloads', async () => {
|
||||
const { registrar, handlers } = createFakeIpcRegistrar();
|
||||
registerIpcHandlers(
|
||||
@@ -477,6 +528,7 @@ test('registerIpcHandlers rejects malformed controller preference payloads', asy
|
||||
handleMpvCommand: () => {},
|
||||
getKeybindings: () => [],
|
||||
getConfiguredShortcuts: () => ({}),
|
||||
getStatsToggleKey: () => 'Backquote',
|
||||
getControllerConfig: () => createControllerConfigFixture(),
|
||||
saveControllerConfig: async () => {},
|
||||
saveControllerPreference: async () => {},
|
||||
|
||||
@@ -50,6 +50,7 @@ export interface IpcServiceDeps {
|
||||
handleMpvCommand: (command: Array<string | number>) => void;
|
||||
getKeybindings: () => unknown;
|
||||
getConfiguredShortcuts: () => unknown;
|
||||
getStatsToggleKey: () => string;
|
||||
getControllerConfig: () => ResolvedControllerConfig;
|
||||
saveControllerConfig: (update: ControllerConfigUpdate) => void | Promise<void>;
|
||||
saveControllerPreference: (update: ControllerPreferenceUpdate) => void | Promise<void>;
|
||||
@@ -68,6 +69,21 @@ export interface IpcServiceDeps {
|
||||
getAnilistQueueStatus: () => unknown;
|
||||
retryAnilistQueueNow: () => Promise<{ ok: boolean; message: string }>;
|
||||
appendClipboardVideoToQueue: () => { ok: boolean; message: string };
|
||||
immersionTracker?: {
|
||||
getSessionSummaries: (limit?: number) => Promise<unknown>;
|
||||
getDailyRollups: (limit?: number) => Promise<unknown>;
|
||||
getMonthlyRollups: (limit?: number) => Promise<unknown>;
|
||||
getQueryHints: () => Promise<{ totalSessions: number; activeSessions: number; episodesToday: number; activeAnimeCount: number }>;
|
||||
getSessionTimeline: (sessionId: number, limit?: number) => Promise<unknown>;
|
||||
getSessionEvents: (sessionId: number, limit?: number) => Promise<unknown>;
|
||||
getVocabularyStats: (limit?: number) => Promise<unknown>;
|
||||
getKanjiStats: (limit?: number) => Promise<unknown>;
|
||||
getMediaLibrary: () => Promise<unknown>;
|
||||
getMediaDetail: (videoId: number) => Promise<unknown>;
|
||||
getMediaSessions: (videoId: number, limit?: number) => Promise<unknown>;
|
||||
getMediaDailyRollups: (videoId: number, limit?: number) => Promise<unknown>;
|
||||
getCoverArt: (videoId: number) => Promise<unknown>;
|
||||
} | null;
|
||||
}
|
||||
|
||||
interface WindowLike {
|
||||
@@ -116,6 +132,7 @@ export interface IpcDepsRuntimeOptions {
|
||||
handleMpvCommand: (command: Array<string | number>) => void;
|
||||
getKeybindings: () => unknown;
|
||||
getConfiguredShortcuts: () => unknown;
|
||||
getStatsToggleKey: () => string;
|
||||
getControllerConfig: () => ResolvedControllerConfig;
|
||||
saveControllerConfig: (update: ControllerConfigUpdate) => void | Promise<void>;
|
||||
saveControllerPreference: (update: ControllerPreferenceUpdate) => void | Promise<void>;
|
||||
@@ -134,6 +151,7 @@ export interface IpcDepsRuntimeOptions {
|
||||
getAnilistQueueStatus: () => unknown;
|
||||
retryAnilistQueueNow: () => Promise<{ ok: boolean; message: string }>;
|
||||
appendClipboardVideoToQueue: () => { ok: boolean; message: string };
|
||||
getImmersionTracker?: () => IpcServiceDeps['immersionTracker'];
|
||||
}
|
||||
|
||||
export function createIpcDepsRuntime(options: IpcDepsRuntimeOptions): IpcServiceDeps {
|
||||
@@ -170,6 +188,7 @@ export function createIpcDepsRuntime(options: IpcDepsRuntimeOptions): IpcService
|
||||
handleMpvCommand: options.handleMpvCommand,
|
||||
getKeybindings: options.getKeybindings,
|
||||
getConfiguredShortcuts: options.getConfiguredShortcuts,
|
||||
getStatsToggleKey: options.getStatsToggleKey,
|
||||
getControllerConfig: options.getControllerConfig,
|
||||
saveControllerConfig: options.saveControllerConfig,
|
||||
saveControllerPreference: options.saveControllerPreference,
|
||||
@@ -192,10 +211,24 @@ export function createIpcDepsRuntime(options: IpcDepsRuntimeOptions): IpcService
|
||||
getAnilistQueueStatus: options.getAnilistQueueStatus,
|
||||
retryAnilistQueueNow: options.retryAnilistQueueNow,
|
||||
appendClipboardVideoToQueue: options.appendClipboardVideoToQueue,
|
||||
get immersionTracker() {
|
||||
return options.getImmersionTracker?.() ?? null;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function registerIpcHandlers(deps: IpcServiceDeps, ipc: IpcMainRegistrar = ipcMain): void {
|
||||
const parsePositiveIntLimit = (
|
||||
value: unknown,
|
||||
defaultValue: number,
|
||||
maxValue: number,
|
||||
): number => {
|
||||
if (!Number.isInteger(value) || (value as number) < 1) {
|
||||
return defaultValue;
|
||||
}
|
||||
return Math.min(value as number, maxValue);
|
||||
};
|
||||
|
||||
ipc.on(
|
||||
IPC_CHANNELS.command.setIgnoreMouseEvents,
|
||||
(event: unknown, ignore: unknown, options: unknown = {}) => {
|
||||
@@ -312,6 +345,10 @@ export function registerIpcHandlers(deps: IpcServiceDeps, ipc: IpcMainRegistrar
|
||||
return deps.getConfiguredShortcuts();
|
||||
});
|
||||
|
||||
ipc.handle(IPC_CHANNELS.request.getStatsToggleKey, () => {
|
||||
return deps.getStatsToggleKey();
|
||||
});
|
||||
|
||||
ipc.handle(IPC_CHANNELS.request.getControllerConfig, () => {
|
||||
return deps.getControllerConfig();
|
||||
});
|
||||
@@ -397,4 +434,106 @@ export function registerIpcHandlers(deps: IpcServiceDeps, ipc: IpcMainRegistrar
|
||||
ipc.handle(IPC_CHANNELS.request.appendClipboardVideoToQueue, () => {
|
||||
return deps.appendClipboardVideoToQueue();
|
||||
});
|
||||
|
||||
// Stats request handlers
|
||||
ipc.handle(IPC_CHANNELS.request.statsGetOverview, async () => {
|
||||
const tracker = deps.immersionTracker;
|
||||
if (!tracker) {
|
||||
return {
|
||||
sessions: [],
|
||||
rollups: [],
|
||||
hints: {
|
||||
totalSessions: 0,
|
||||
activeSessions: 0,
|
||||
},
|
||||
};
|
||||
}
|
||||
const [sessions, rollups, hints] = await Promise.all([
|
||||
tracker.getSessionSummaries(5),
|
||||
tracker.getDailyRollups(14),
|
||||
tracker.getQueryHints(),
|
||||
]);
|
||||
return { sessions, rollups, hints };
|
||||
});
|
||||
|
||||
ipc.handle(IPC_CHANNELS.request.statsGetDailyRollups, async (_event, limit: unknown) => {
|
||||
const parsedLimit = parsePositiveIntLimit(limit, 60, 500);
|
||||
return deps.immersionTracker?.getDailyRollups(parsedLimit) ?? [];
|
||||
});
|
||||
|
||||
ipc.handle(IPC_CHANNELS.request.statsGetMonthlyRollups, async (_event, limit: unknown) => {
|
||||
const parsedLimit = parsePositiveIntLimit(limit, 24, 120);
|
||||
return deps.immersionTracker?.getMonthlyRollups(parsedLimit) ?? [];
|
||||
});
|
||||
|
||||
ipc.handle(IPC_CHANNELS.request.statsGetSessions, async (_event, limit: unknown) => {
|
||||
const parsedLimit = parsePositiveIntLimit(limit, 50, 500);
|
||||
return deps.immersionTracker?.getSessionSummaries(parsedLimit) ?? [];
|
||||
});
|
||||
|
||||
ipc.handle(
|
||||
IPC_CHANNELS.request.statsGetSessionTimeline,
|
||||
async (_event, sessionId: unknown, limit: unknown) => {
|
||||
if (typeof sessionId !== 'number') return [];
|
||||
const parsedLimit = parsePositiveIntLimit(limit, 200, 1000);
|
||||
return deps.immersionTracker?.getSessionTimeline(sessionId, parsedLimit) ?? [];
|
||||
},
|
||||
);
|
||||
|
||||
ipc.handle(
|
||||
IPC_CHANNELS.request.statsGetSessionEvents,
|
||||
async (_event, sessionId: unknown, limit: unknown) => {
|
||||
if (typeof sessionId !== 'number') return [];
|
||||
const parsedLimit = parsePositiveIntLimit(limit, 500, 1000);
|
||||
return deps.immersionTracker?.getSessionEvents(sessionId, parsedLimit) ?? [];
|
||||
},
|
||||
);
|
||||
|
||||
ipc.handle(IPC_CHANNELS.request.statsGetVocabulary, async (_event, limit: unknown) => {
|
||||
const parsedLimit = parsePositiveIntLimit(limit, 100, 500);
|
||||
return deps.immersionTracker?.getVocabularyStats(parsedLimit) ?? [];
|
||||
});
|
||||
|
||||
ipc.handle(IPC_CHANNELS.request.statsGetKanji, async (_event, limit: unknown) => {
|
||||
const parsedLimit = parsePositiveIntLimit(limit, 100, 500);
|
||||
return deps.immersionTracker?.getKanjiStats(parsedLimit) ?? [];
|
||||
});
|
||||
|
||||
ipc.handle(IPC_CHANNELS.request.statsGetMediaLibrary, async () => {
|
||||
return deps.immersionTracker?.getMediaLibrary() ?? [];
|
||||
});
|
||||
|
||||
ipc.handle(
|
||||
IPC_CHANNELS.request.statsGetMediaDetail,
|
||||
async (_event, videoId: unknown) => {
|
||||
if (typeof videoId !== 'number') return null;
|
||||
return deps.immersionTracker?.getMediaDetail(videoId) ?? null;
|
||||
},
|
||||
);
|
||||
|
||||
ipc.handle(
|
||||
IPC_CHANNELS.request.statsGetMediaSessions,
|
||||
async (_event, videoId: unknown, limit: unknown) => {
|
||||
if (typeof videoId !== 'number') return [];
|
||||
const parsedLimit = parsePositiveIntLimit(limit, 100, 500);
|
||||
return deps.immersionTracker?.getMediaSessions(videoId, parsedLimit) ?? [];
|
||||
},
|
||||
);
|
||||
|
||||
ipc.handle(
|
||||
IPC_CHANNELS.request.statsGetMediaDailyRollups,
|
||||
async (_event, videoId: unknown, limit: unknown) => {
|
||||
if (typeof videoId !== 'number') return [];
|
||||
const parsedLimit = parsePositiveIntLimit(limit, 90, 500);
|
||||
return deps.immersionTracker?.getMediaDailyRollups(videoId, parsedLimit) ?? [];
|
||||
},
|
||||
);
|
||||
|
||||
ipc.handle(
|
||||
IPC_CHANNELS.request.statsGetMediaCover,
|
||||
async (_event, videoId: unknown) => {
|
||||
if (typeof videoId !== 'number') return null;
|
||||
return deps.immersionTracker?.getCoverArt(videoId) ?? null;
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -59,6 +59,7 @@ const MPV_SUBTITLE_PROPERTY_OBSERVATIONS: string[] = [
|
||||
'sub-ass-override',
|
||||
'sub-use-margins',
|
||||
'pause',
|
||||
'duration',
|
||||
'media-title',
|
||||
'secondary-sub-visibility',
|
||||
'sub-visibility',
|
||||
|
||||
@@ -87,6 +87,7 @@ function createDeps(overrides: Partial<MpvProtocolHandleMessageDeps> = {}): {
|
||||
getPauseAtTime: () => null,
|
||||
setPauseAtTime: () => {},
|
||||
emitTimePosChange: () => {},
|
||||
emitDurationChange: () => {},
|
||||
emitPauseChange: () => {},
|
||||
autoLoadSecondarySubTrack: () => {},
|
||||
setCurrentVideoPath: () => {},
|
||||
|
||||
@@ -61,6 +61,7 @@ export interface MpvProtocolHandleMessageDeps {
|
||||
emitMediaPathChange: (payload: { path: string }) => void;
|
||||
emitMediaTitleChange: (payload: { title: string | null }) => void;
|
||||
emitTimePosChange: (payload: { time: number }) => void;
|
||||
emitDurationChange: (payload: { duration: number }) => void;
|
||||
emitPauseChange: (payload: { paused: boolean }) => void;
|
||||
emitSubtitleMetricsChange: (payload: Partial<MpvSubtitleRenderMetrics>) => void;
|
||||
setCurrentSecondarySubText: (text: string) => void;
|
||||
@@ -172,6 +173,11 @@ export async function dispatchMpvProtocolMessage(
|
||||
deps.setPauseAtTime(null);
|
||||
deps.sendCommand({ command: ['set_property', 'pause', true] });
|
||||
}
|
||||
} else if (msg.name === 'duration') {
|
||||
const duration = typeof msg.data === 'number' ? msg.data : 0;
|
||||
if (duration > 0) {
|
||||
deps.emitDurationChange({ duration });
|
||||
}
|
||||
} else if (msg.name === 'pause') {
|
||||
deps.emitPauseChange({ paused: asBoolean(msg.data, false) });
|
||||
} else if (msg.name === 'media-title') {
|
||||
|
||||
@@ -115,6 +115,7 @@ export interface MpvIpcClientEventMap {
|
||||
'subtitle-ass-change': { text: string };
|
||||
'subtitle-timing': { text: string; start: number; end: number };
|
||||
'time-pos-change': { time: number };
|
||||
'duration-change': { duration: number };
|
||||
'pause-change': { paused: boolean };
|
||||
'secondary-subtitle-change': { text: string };
|
||||
'media-path-change': { path: string };
|
||||
@@ -314,6 +315,9 @@ export class MpvIpcClient implements MpvClient {
|
||||
emitTimePosChange: (payload) => {
|
||||
this.emit('time-pos-change', payload);
|
||||
},
|
||||
emitDurationChange: (payload) => {
|
||||
this.emit('duration-change', payload);
|
||||
},
|
||||
emitPauseChange: (payload) => {
|
||||
this.playbackPaused = payload.paused;
|
||||
this.emit('pause-change', payload);
|
||||
|
||||
@@ -34,6 +34,7 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
|
||||
anilistSetup: false,
|
||||
anilistRetryQueue: false,
|
||||
dictionary: false,
|
||||
stats: false,
|
||||
jellyfin: false,
|
||||
jellyfinLogin: 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;
|
||||
onCriticalConfigErrors?: (errors: string[]) => void;
|
||||
now?: () => number;
|
||||
shouldUseMinimalStartup?: () => boolean;
|
||||
shouldSkipHeavyStartup?: () => boolean;
|
||||
}
|
||||
|
||||
@@ -183,6 +184,12 @@ export async function runAppReadyRuntime(deps: AppReadyRuntimeDeps): Promise<voi
|
||||
const now = deps.now ?? (() => Date.now());
|
||||
const startupStartedAtMs = now();
|
||||
deps.ensureDefaultConfigBootstrap();
|
||||
if (deps.shouldUseMinimalStartup?.()) {
|
||||
deps.reloadConfig();
|
||||
deps.handleInitialArgs();
|
||||
return;
|
||||
}
|
||||
|
||||
if (deps.shouldSkipHeavyStartup?.()) {
|
||||
await deps.loadYomitanExtension();
|
||||
deps.reloadConfig();
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user