mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-07-28 04:49:49 -07:00
refactor(stats): drop dead IPC handlers, unify stats types over HTTP (#164)
This commit is contained in:
@@ -0,0 +1,4 @@
|
|||||||
|
type: internal
|
||||||
|
area: stats
|
||||||
|
|
||||||
|
- Removed the unused stats IPC data transport and unified the stats dashboard's HTTP wire types with the backend contract.
|
||||||
@@ -39,6 +39,7 @@ Read when: you need to find the owner module for a behavior or test surface
|
|||||||
- Runtime-option contracts: `src/types/runtime-options.ts`
|
- Runtime-option contracts: `src/types/runtime-options.ts`
|
||||||
- Settings UI contracts: `src/types/settings.ts`
|
- Settings UI contracts: `src/types/settings.ts`
|
||||||
- Session-binding contracts: `src/types/session-bindings.ts`
|
- Session-binding contracts: `src/types/session-bindings.ts`
|
||||||
|
- Stats HTTP wire contracts: `src/types/stats-wire.ts`, `src/types/stats-http-contract.ts`
|
||||||
- Compatibility-only barrel: `src/types.ts`
|
- Compatibility-only barrel: `src/types.ts`
|
||||||
|
|
||||||
## Ownership Heuristics
|
## Ownership Heuristics
|
||||||
|
|||||||
@@ -33,6 +33,10 @@ Trend charts now consume one chart-oriented backend payload from `/api/stats/tre
|
|||||||
|
|
||||||
## Contract
|
## Contract
|
||||||
|
|
||||||
|
Stats data crosses the process boundary over HTTP only. Shared JSON models live in `src/types/stats-wire.ts`; endpoint request/response mappings and the client interface live in `src/types/stats-http-contract.ts`. The server and stats client both type-check against those files. `stats/src/types/stats.ts` is a compatibility re-export, not an independent contract copy.
|
||||||
|
|
||||||
|
The stats preload bridge remains limited to native window behavior such as confirmation-dialog layering. Do not add stats data request channels back to Electron IPC.
|
||||||
|
|
||||||
The stats UI should treat the trends payload as chart-ready data. Presentation-only work in the client is fine, but rebuilding the main trend datasets from raw sessions should stay out of the render path.
|
The stats UI should treat the trends payload as chart-ready data. Presentation-only work in the client is fine, but rebuilding the main trend datasets from raw sessions should stay out of the render path.
|
||||||
|
|
||||||
For session detail timelines, omitting `limit` now means "return the full retained session telemetry/history". Explicit `limit` remains available for bounded callers, but the default stats UI path should not trim long sessions to the newest 200 samples.
|
For session detail timelines, omitting `limit` now means "return the full retained session telemetry/history". Explicit `limit` remains available for bounded callers, but the default stats UI path should not trim long sessions to the newest 200 samples.
|
||||||
|
|||||||
@@ -170,6 +170,8 @@ const TRENDS_DASHBOARD = {
|
|||||||
},
|
},
|
||||||
ratios: {
|
ratios: {
|
||||||
lookupsPerHundred: [{ label: 'Mar 1', value: 5 }],
|
lookupsPerHundred: [{ label: 'Mar 1', value: 5 }],
|
||||||
|
cardsPerHour: [{ label: 'Mar 1', value: 12 }],
|
||||||
|
readingSpeed: [{ label: 'Mar 1', value: 180 }],
|
||||||
},
|
},
|
||||||
librarySummary: [
|
librarySummary: [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -572,7 +572,7 @@ export class ImmersionTrackerService {
|
|||||||
range: '7d' | '30d' | '90d' | '365d' | 'all' = '30d',
|
range: '7d' | '30d' | '90d' | '365d' | 'all' = '30d',
|
||||||
groupBy: 'day' | 'month' = 'day',
|
groupBy: 'day' | 'month' = 'day',
|
||||||
fillEmptyBuckets = true,
|
fillEmptyBuckets = true,
|
||||||
): Promise<unknown> {
|
) {
|
||||||
return getTrendsDashboard(this.db, range, groupBy, fillEmptyBuckets);
|
return getTrendsDashboard(this.db, range, groupBy, fillEmptyBuckets);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -181,35 +181,6 @@ function createFakeImmersionTracker(
|
|||||||
): NonNullable<IpcServiceDeps['immersionTracker']> {
|
): NonNullable<IpcServiceDeps['immersionTracker']> {
|
||||||
return {
|
return {
|
||||||
recordYomitanLookup: () => {},
|
recordYomitanLookup: () => {},
|
||||||
getSessionSummaries: async () => [],
|
|
||||||
getDailyRollups: async () => [],
|
|
||||||
getMonthlyRollups: async () => [],
|
|
||||||
getQueryHints: async () => ({
|
|
||||||
totalSessions: 0,
|
|
||||||
activeSessions: 0,
|
|
||||||
episodesToday: 0,
|
|
||||||
activeAnimeCount: 0,
|
|
||||||
totalActiveMin: 0,
|
|
||||||
totalCards: 0,
|
|
||||||
activeDays: 0,
|
|
||||||
totalEpisodesWatched: 0,
|
|
||||||
totalAnimeCompleted: 0,
|
|
||||||
totalTokensSeen: 0,
|
|
||||||
totalLookupCount: 0,
|
|
||||||
totalLookupHits: 0,
|
|
||||||
totalYomitanLookupCount: 0,
|
|
||||||
newWordsToday: 0,
|
|
||||||
newWordsThisWeek: 0,
|
|
||||||
}),
|
|
||||||
getSessionTimeline: async () => [],
|
|
||||||
getSessionEvents: async () => [],
|
|
||||||
getVocabularyStats: async () => [],
|
|
||||||
getKanjiStats: async () => [],
|
|
||||||
getMediaLibrary: async () => [],
|
|
||||||
getMediaDetail: async () => null,
|
|
||||||
getMediaSessions: async () => [],
|
|
||||||
getMediaDailyRollups: async () => [],
|
|
||||||
getCoverArt: async () => null,
|
|
||||||
markActiveVideoWatched: async () => false,
|
markActiveVideoWatched: async () => false,
|
||||||
...overrides,
|
...overrides,
|
||||||
};
|
};
|
||||||
@@ -776,172 +747,6 @@ test('registerIpcHandlers records yomitan lookup when subtitle context recording
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
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,
|
|
||||||
episodesToday: 0,
|
|
||||||
activeAnimeCount: 0,
|
|
||||||
totalCards: 0,
|
|
||||||
totalActiveMin: 0,
|
|
||||||
activeDays: 0,
|
|
||||||
totalEpisodesWatched: 0,
|
|
||||||
totalAnimeCompleted: 0,
|
|
||||||
totalTokensSeen: 0,
|
|
||||||
totalLookupCount: 0,
|
|
||||||
totalLookupHits: 0,
|
|
||||||
totalYomitanLookupCount: 0,
|
|
||||||
newWordsToday: 0,
|
|
||||||
newWordsThisWeek: 0,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test('registerIpcHandlers validates and clamps stats request limits', async () => {
|
|
||||||
const { registrar, handlers } = createFakeIpcRegistrar();
|
|
||||||
const calls: Array<[string, number, number?]> = [];
|
|
||||||
|
|
||||||
registerIpcHandlers(
|
|
||||||
createRegisterIpcDeps({
|
|
||||||
immersionTracker: {
|
|
||||||
recordYomitanLookup: () => {},
|
|
||||||
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,
|
|
||||||
totalCards: 0,
|
|
||||||
totalActiveMin: 0,
|
|
||||||
activeDays: 0,
|
|
||||||
totalEpisodesWatched: 0,
|
|
||||||
totalAnimeCompleted: 0,
|
|
||||||
totalTokensSeen: 0,
|
|
||||||
totalLookupCount: 0,
|
|
||||||
totalLookupHits: 0,
|
|
||||||
totalYomitanLookupCount: 0,
|
|
||||||
newWordsToday: 0,
|
|
||||||
newWordsThisWeek: 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,
|
|
||||||
markActiveVideoWatched: async () => false,
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
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 requests the full timeline when no limit is provided', async () => {
|
|
||||||
const { registrar, handlers } = createFakeIpcRegistrar();
|
|
||||||
const calls: Array<[string, number | undefined, number]> = [];
|
|
||||||
|
|
||||||
registerIpcHandlers(
|
|
||||||
createRegisterIpcDeps({
|
|
||||||
immersionTracker: {
|
|
||||||
recordYomitanLookup: () => {},
|
|
||||||
getSessionSummaries: async () => [],
|
|
||||||
getDailyRollups: async () => [],
|
|
||||||
getMonthlyRollups: async () => [],
|
|
||||||
getQueryHints: async () => ({
|
|
||||||
totalSessions: 0,
|
|
||||||
activeSessions: 0,
|
|
||||||
episodesToday: 0,
|
|
||||||
activeAnimeCount: 0,
|
|
||||||
totalCards: 0,
|
|
||||||
totalActiveMin: 0,
|
|
||||||
activeDays: 0,
|
|
||||||
totalEpisodesWatched: 0,
|
|
||||||
totalAnimeCompleted: 0,
|
|
||||||
totalTokensSeen: 0,
|
|
||||||
totalLookupCount: 0,
|
|
||||||
totalLookupHits: 0,
|
|
||||||
totalYomitanLookupCount: 0,
|
|
||||||
newWordsToday: 0,
|
|
||||||
newWordsThisWeek: 0,
|
|
||||||
}),
|
|
||||||
getSessionTimeline: async (sessionId: number, limit?: number) => {
|
|
||||||
calls.push(['timeline', limit, sessionId]);
|
|
||||||
return [];
|
|
||||||
},
|
|
||||||
getSessionEvents: async () => [],
|
|
||||||
getVocabularyStats: async () => [],
|
|
||||||
getKanjiStats: async () => [],
|
|
||||||
getMediaLibrary: async () => [],
|
|
||||||
getMediaDetail: async () => null,
|
|
||||||
getMediaSessions: async () => [],
|
|
||||||
getMediaDailyRollups: async () => [],
|
|
||||||
getCoverArt: async () => null,
|
|
||||||
markActiveVideoWatched: async () => false,
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
registrar,
|
|
||||||
);
|
|
||||||
|
|
||||||
await handlers.handle.get(IPC_CHANNELS.request.statsGetSessionTimeline)!({}, 7, undefined);
|
|
||||||
|
|
||||||
assert.deepEqual(calls, [['timeline', undefined, 7]]);
|
|
||||||
});
|
|
||||||
|
|
||||||
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[] = [];
|
||||||
|
|||||||
@@ -132,35 +132,6 @@ export interface IpcServiceDeps {
|
|||||||
) => Promise<PlaylistBrowserMutationResult>;
|
) => Promise<PlaylistBrowserMutationResult>;
|
||||||
immersionTracker?: {
|
immersionTracker?: {
|
||||||
recordYomitanLookup: () => void;
|
recordYomitanLookup: () => void;
|
||||||
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;
|
|
||||||
totalActiveMin: number;
|
|
||||||
totalCards: number;
|
|
||||||
activeDays: number;
|
|
||||||
totalEpisodesWatched: number;
|
|
||||||
totalAnimeCompleted: number;
|
|
||||||
totalTokensSeen: number;
|
|
||||||
totalLookupCount: number;
|
|
||||||
totalLookupHits: number;
|
|
||||||
totalYomitanLookupCount: number;
|
|
||||||
newWordsToday: number;
|
|
||||||
newWordsThisWeek: 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>;
|
|
||||||
markActiveVideoWatched: () => Promise<boolean>;
|
markActiveVideoWatched: () => Promise<boolean>;
|
||||||
} | null;
|
} | null;
|
||||||
}
|
}
|
||||||
@@ -459,24 +430,6 @@ export function createIpcDepsRuntime(options: IpcDepsRuntimeOptions): IpcService
|
|||||||
}
|
}
|
||||||
|
|
||||||
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);
|
|
||||||
};
|
|
||||||
|
|
||||||
const parsePositiveInteger = (value: unknown): number | null => {
|
|
||||||
if (typeof value !== 'number' || !Number.isInteger(value) || value <= 0) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return value;
|
|
||||||
};
|
|
||||||
|
|
||||||
ipc.on(
|
ipc.on(
|
||||||
IPC_CHANNELS.command.setIgnoreMouseEvents,
|
IPC_CHANNELS.command.setIgnoreMouseEvents,
|
||||||
(event: unknown, ignore: unknown, options: unknown = {}) => {
|
(event: unknown, ignore: unknown, options: unknown = {}) => {
|
||||||
@@ -904,115 +857,4 @@ export function registerIpcHandlers(deps: IpcServiceDeps, ipc: IpcMainRegistrar
|
|||||||
return await deps.movePlaylistBrowserIndex(index as number, direction as 1 | -1);
|
return await deps.movePlaylistBrowserIndex(index as number, direction as 1 | -1);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
// Stats request handlers
|
|
||||||
ipc.handle(IPC_CHANNELS.request.statsGetOverview, async () => {
|
|
||||||
const tracker = deps.immersionTracker;
|
|
||||||
if (!tracker) {
|
|
||||||
return {
|
|
||||||
sessions: [],
|
|
||||||
rollups: [],
|
|
||||||
hints: {
|
|
||||||
totalSessions: 0,
|
|
||||||
activeSessions: 0,
|
|
||||||
episodesToday: 0,
|
|
||||||
activeAnimeCount: 0,
|
|
||||||
totalActiveMin: 0,
|
|
||||||
totalCards: 0,
|
|
||||||
activeDays: 0,
|
|
||||||
totalEpisodesWatched: 0,
|
|
||||||
totalAnimeCompleted: 0,
|
|
||||||
totalTokensSeen: 0,
|
|
||||||
totalLookupCount: 0,
|
|
||||||
totalLookupHits: 0,
|
|
||||||
totalYomitanLookupCount: 0,
|
|
||||||
newWordsToday: 0,
|
|
||||||
newWordsThisWeek: 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) => {
|
|
||||||
const parsedSessionId = parsePositiveInteger(sessionId);
|
|
||||||
if (parsedSessionId === null) return [];
|
|
||||||
const parsedLimit = limit === undefined ? undefined : parsePositiveIntLimit(limit, 200, 1000);
|
|
||||||
return deps.immersionTracker?.getSessionTimeline(parsedSessionId, parsedLimit) ?? [];
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
ipc.handle(
|
|
||||||
IPC_CHANNELS.request.statsGetSessionEvents,
|
|
||||||
async (_event, sessionId: unknown, limit: unknown) => {
|
|
||||||
const parsedSessionId = parsePositiveInteger(sessionId);
|
|
||||||
if (parsedSessionId === null) return [];
|
|
||||||
const parsedLimit = parsePositiveIntLimit(limit, 500, 1000);
|
|
||||||
return deps.immersionTracker?.getSessionEvents(parsedSessionId, 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;
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,15 +1,10 @@
|
|||||||
import type { Hono } from 'hono';
|
import type { Hono } from 'hono';
|
||||||
import type { ImmersionTrackerService } from './immersion-tracker-service.js';
|
import type { ImmersionTrackerService } from './immersion-tracker-service.js';
|
||||||
|
import { statsJson, type StatsCoverImagesRequest } from '../../types/stats-http-contract.js';
|
||||||
|
import type { StatsCoverImage } from '../../types/stats-wire.js';
|
||||||
|
|
||||||
type StatsCoverImagePayload = {
|
type StatsCoverImagePayload = StatsCoverImage | null;
|
||||||
contentType: string;
|
type StatsCoverBatchBody = Partial<Record<keyof StatsCoverImagesRequest, unknown>>;
|
||||||
dataUrl: string;
|
|
||||||
} | null;
|
|
||||||
|
|
||||||
type StatsCoverBatchBody = {
|
|
||||||
animeIds?: unknown;
|
|
||||||
videoIds?: unknown;
|
|
||||||
};
|
|
||||||
|
|
||||||
const MAX_BACKGROUND_ANIME_COVER_FETCHES = 3;
|
const MAX_BACKGROUND_ANIME_COVER_FETCHES = 3;
|
||||||
|
|
||||||
@@ -130,7 +125,7 @@ export function registerStatsCoverRoutes(app: Hono, tracker: ImmersionTrackerSer
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
return c.json({ anime, media });
|
return c.json(statsJson('coverImages', { anime, media }));
|
||||||
});
|
});
|
||||||
|
|
||||||
app.get('/api/stats/anime/:animeId/cover', async (c) => {
|
app.get('/api/stats/anime/:animeId/cover', async (c) => {
|
||||||
|
|||||||
@@ -18,6 +18,12 @@ import {
|
|||||||
import { resolveAnimatedImageLeadInSeconds } from '../../anki-integration/animated-image-sync.js';
|
import { resolveAnimatedImageLeadInSeconds } from '../../anki-integration/animated-image-sync.js';
|
||||||
import type { AnilistRateLimiter } from './anilist/rate-limiter.js';
|
import type { AnilistRateLimiter } from './anilist/rate-limiter.js';
|
||||||
import { registerStatsCoverRoutes } from './stats-cover-routes.js';
|
import { registerStatsCoverRoutes } from './stats-cover-routes.js';
|
||||||
|
import {
|
||||||
|
statsJson,
|
||||||
|
type StatsAnilistSearchResult,
|
||||||
|
type StatsAnkiBrowseResponse,
|
||||||
|
} from '../../types/stats-http-contract.js';
|
||||||
|
import type { StatsExcludedWord } from '../../types/stats-wire.js';
|
||||||
import {
|
import {
|
||||||
resolveRetimedSecondarySubtitleTextFromSidecar,
|
resolveRetimedSecondarySubtitleTextFromSidecar,
|
||||||
resolveSecondarySubtitleTextFromSidecar,
|
resolveSecondarySubtitleTextFromSidecar,
|
||||||
@@ -46,12 +52,6 @@ export type StatsMiningTimingEvent = {
|
|||||||
noteId?: number;
|
noteId?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
type StatsExcludedWordPayload = {
|
|
||||||
headword: string;
|
|
||||||
word: string;
|
|
||||||
reading: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
function parseIntQuery(raw: string | undefined, fallback: number, maxLimit?: number): number {
|
function parseIntQuery(raw: string | undefined, fallback: number, maxLimit?: number): number {
|
||||||
if (raw === undefined) return fallback;
|
if (raw === undefined) return fallback;
|
||||||
const n = Number(raw);
|
const n = Number(raw);
|
||||||
@@ -87,12 +87,12 @@ function parseEventTypesQuery(raw: string | undefined): number[] | undefined {
|
|||||||
return parsed.length > 0 ? parsed : undefined;
|
return parsed.length > 0 ? parsed : undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseExcludedWordsBody(body: unknown): StatsExcludedWordPayload[] | null {
|
function parseExcludedWordsBody(body: unknown): StatsExcludedWord[] | null {
|
||||||
if (!body || typeof body !== 'object' || !Array.isArray((body as { words?: unknown }).words)) {
|
if (!body || typeof body !== 'object' || !Array.isArray((body as { words?: unknown }).words)) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const words: StatsExcludedWordPayload[] = [];
|
const words: StatsExcludedWord[] = [];
|
||||||
for (const row of (body as { words: unknown[] }).words) {
|
for (const row of (body as { words: unknown[] }).words) {
|
||||||
if (!row || typeof row !== 'object') return null;
|
if (!row || typeof row !== 'object') return null;
|
||||||
const { headword, word, reading } = row as Record<string, unknown>;
|
const { headword, word, reading } = row as Record<string, unknown>;
|
||||||
@@ -320,20 +320,22 @@ function summarizeFilteredWordOccurrences(
|
|||||||
return { knownWordsSeen, totalWordsSeen };
|
return { knownWordsSeen, totalWordsSeen };
|
||||||
}
|
}
|
||||||
|
|
||||||
async function enrichSessionsWithKnownWordMetrics(
|
async function enrichSessionsWithKnownWordMetrics<
|
||||||
tracker: ImmersionTrackerService,
|
Session extends {
|
||||||
sessions: Array<{
|
|
||||||
sessionId: number;
|
sessionId: number;
|
||||||
tokensSeen: number;
|
tokensSeen: number;
|
||||||
}>,
|
},
|
||||||
|
>(
|
||||||
|
tracker: ImmersionTrackerService,
|
||||||
|
sessions: Session[],
|
||||||
knownWordsCachePath?: string,
|
knownWordsCachePath?: string,
|
||||||
): Promise<
|
): Promise<
|
||||||
Array<{
|
Array<
|
||||||
sessionId: number;
|
Session & {
|
||||||
tokensSeen: number;
|
knownWordsSeen: number;
|
||||||
knownWordsSeen: number;
|
knownWordRate: number;
|
||||||
knownWordRate: number;
|
}
|
||||||
}>
|
>
|
||||||
> {
|
> {
|
||||||
const knownWordsSet = loadKnownWordsSet(knownWordsCachePath);
|
const knownWordsSet = loadKnownWordsSet(knownWordsCachePath);
|
||||||
if (!knownWordsSet) {
|
if (!knownWordsSet) {
|
||||||
@@ -603,46 +605,48 @@ export function createStatsApp(
|
|||||||
rawSessions,
|
rawSessions,
|
||||||
options?.knownWordCachePath,
|
options?.knownWordCachePath,
|
||||||
);
|
);
|
||||||
return c.json({ sessions, rollups, hints });
|
return c.json(statsJson('overview', { sessions, rollups, hints }));
|
||||||
});
|
});
|
||||||
|
|
||||||
app.get('/api/stats/daily-rollups', async (c) => {
|
app.get('/api/stats/daily-rollups', async (c) => {
|
||||||
const limit = parseIntQuery(c.req.query('limit'), 60, 500);
|
const limit = parseIntQuery(c.req.query('limit'), 60, 500);
|
||||||
const rollups = await tracker.getDailyRollups(limit);
|
const rollups = await tracker.getDailyRollups(limit);
|
||||||
return c.json(rollups);
|
return c.json(statsJson('dailyRollups', rollups));
|
||||||
});
|
});
|
||||||
|
|
||||||
app.get('/api/stats/monthly-rollups', async (c) => {
|
app.get('/api/stats/monthly-rollups', async (c) => {
|
||||||
const limit = parseIntQuery(c.req.query('limit'), 24, 120);
|
const limit = parseIntQuery(c.req.query('limit'), 24, 120);
|
||||||
const rollups = await tracker.getMonthlyRollups(limit);
|
const rollups = await tracker.getMonthlyRollups(limit);
|
||||||
return c.json(rollups);
|
return c.json(statsJson('monthlyRollups', rollups));
|
||||||
});
|
});
|
||||||
|
|
||||||
app.get('/api/stats/streak-calendar', async (c) => {
|
app.get('/api/stats/streak-calendar', async (c) => {
|
||||||
const days = parseIntQuery(c.req.query('days'), 90, 365);
|
const days = parseIntQuery(c.req.query('days'), 90, 365);
|
||||||
return c.json(await tracker.getStreakCalendar(days));
|
return c.json(statsJson('streakCalendar', await tracker.getStreakCalendar(days)));
|
||||||
});
|
});
|
||||||
|
|
||||||
app.get('/api/stats/trends/episodes-per-day', async (c) => {
|
app.get('/api/stats/trends/episodes-per-day', async (c) => {
|
||||||
const limit = parseIntQuery(c.req.query('limit'), 90, 365);
|
const limit = parseIntQuery(c.req.query('limit'), 90, 365);
|
||||||
return c.json(await tracker.getEpisodesPerDay(limit));
|
return c.json(statsJson('episodesPerDay', await tracker.getEpisodesPerDay(limit)));
|
||||||
});
|
});
|
||||||
|
|
||||||
app.get('/api/stats/trends/new-anime-per-day', async (c) => {
|
app.get('/api/stats/trends/new-anime-per-day', async (c) => {
|
||||||
const limit = parseIntQuery(c.req.query('limit'), 90, 365);
|
const limit = parseIntQuery(c.req.query('limit'), 90, 365);
|
||||||
return c.json(await tracker.getNewAnimePerDay(limit));
|
return c.json(statsJson('newAnimePerDay', await tracker.getNewAnimePerDay(limit)));
|
||||||
});
|
});
|
||||||
|
|
||||||
app.get('/api/stats/trends/watch-time-per-anime', async (c) => {
|
app.get('/api/stats/trends/watch-time-per-anime', async (c) => {
|
||||||
const limit = parseIntQuery(c.req.query('limit'), 90, 365);
|
const limit = parseIntQuery(c.req.query('limit'), 90, 365);
|
||||||
return c.json(await tracker.getWatchTimePerAnime(limit));
|
return c.json(statsJson('watchTimePerAnime', await tracker.getWatchTimePerAnime(limit)));
|
||||||
});
|
});
|
||||||
|
|
||||||
app.get('/api/stats/trends/dashboard', async (c) => {
|
app.get('/api/stats/trends/dashboard', async (c) => {
|
||||||
const range = parseTrendRange(c.req.query('range'));
|
const range = parseTrendRange(c.req.query('range'));
|
||||||
const groupBy = parseTrendGroupBy(c.req.query('groupBy'));
|
const groupBy = parseTrendGroupBy(c.req.query('groupBy'));
|
||||||
const fillEmpty = parseTrendFillEmpty(c.req.query('fillEmpty'));
|
const fillEmpty = parseTrendFillEmpty(c.req.query('fillEmpty'));
|
||||||
return c.json(await tracker.getTrendsDashboard(range, groupBy, fillEmpty));
|
return c.json(
|
||||||
|
statsJson('trendsDashboard', await tracker.getTrendsDashboard(range, groupBy, fillEmpty)),
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
app.get('/api/stats/sessions', async (c) => {
|
app.get('/api/stats/sessions', async (c) => {
|
||||||
@@ -653,30 +657,30 @@ export function createStatsApp(
|
|||||||
rawSessions,
|
rawSessions,
|
||||||
options?.knownWordCachePath,
|
options?.knownWordCachePath,
|
||||||
);
|
);
|
||||||
return c.json(sessions);
|
return c.json(statsJson('sessions', sessions));
|
||||||
});
|
});
|
||||||
|
|
||||||
app.get('/api/stats/sessions/:id/timeline', async (c) => {
|
app.get('/api/stats/sessions/:id/timeline', async (c) => {
|
||||||
const id = parseIntQuery(c.req.param('id'), 0);
|
const id = parseIntQuery(c.req.param('id'), 0);
|
||||||
if (id <= 0) return c.json([], 400);
|
if (id <= 0) return c.json(statsJson('sessionTimeline', []), 400);
|
||||||
const rawLimit = c.req.query('limit');
|
const rawLimit = c.req.query('limit');
|
||||||
const limit = rawLimit === undefined ? undefined : parseIntQuery(rawLimit, 200, 1000);
|
const limit = rawLimit === undefined ? undefined : parseIntQuery(rawLimit, 200, 1000);
|
||||||
const timeline = await tracker.getSessionTimeline(id, limit);
|
const timeline = await tracker.getSessionTimeline(id, limit);
|
||||||
return c.json(timeline);
|
return c.json(statsJson('sessionTimeline', timeline));
|
||||||
});
|
});
|
||||||
|
|
||||||
app.get('/api/stats/sessions/:id/events', async (c) => {
|
app.get('/api/stats/sessions/:id/events', async (c) => {
|
||||||
const id = parseIntQuery(c.req.param('id'), 0);
|
const id = parseIntQuery(c.req.param('id'), 0);
|
||||||
if (id <= 0) return c.json([], 400);
|
if (id <= 0) return c.json(statsJson('sessionEvents', []), 400);
|
||||||
const limit = parseIntQuery(c.req.query('limit'), 500, 1000);
|
const limit = parseIntQuery(c.req.query('limit'), 500, 1000);
|
||||||
const eventTypes = parseEventTypesQuery(c.req.query('types'));
|
const eventTypes = parseEventTypesQuery(c.req.query('types'));
|
||||||
const events = await tracker.getSessionEvents(id, limit, eventTypes);
|
const events = await tracker.getSessionEvents(id, limit, eventTypes);
|
||||||
return c.json(events);
|
return c.json(statsJson('sessionEvents', events));
|
||||||
});
|
});
|
||||||
|
|
||||||
app.get('/api/stats/sessions/:id/known-words-timeline', async (c) => {
|
app.get('/api/stats/sessions/:id/known-words-timeline', async (c) => {
|
||||||
const id = parseIntQuery(c.req.param('id'), 0);
|
const id = parseIntQuery(c.req.param('id'), 0);
|
||||||
if (id <= 0) return c.json([], 400);
|
if (id <= 0) return c.json(statsJson('sessionKnownWordsTimeline', []), 400);
|
||||||
|
|
||||||
const knownWordsSet = loadKnownWordsSet(options?.knownWordCachePath) ?? new Set<string>();
|
const knownWordsSet = loadKnownWordsSet(options?.knownWordCachePath) ?? new Set<string>();
|
||||||
|
|
||||||
@@ -720,18 +724,18 @@ export function createStatsApp(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return c.json(knownByLinesSeen);
|
return c.json(statsJson('sessionKnownWordsTimeline', knownByLinesSeen));
|
||||||
});
|
});
|
||||||
|
|
||||||
app.get('/api/stats/vocabulary', async (c) => {
|
app.get('/api/stats/vocabulary', async (c) => {
|
||||||
const limit = parseIntQuery(c.req.query('limit'), 100, 500);
|
const limit = parseIntQuery(c.req.query('limit'), 100, 500);
|
||||||
const excludePos = c.req.query('excludePos')?.split(',').filter(Boolean);
|
const excludePos = c.req.query('excludePos')?.split(',').filter(Boolean);
|
||||||
const vocab = await tracker.getVocabularyStats(limit, excludePos);
|
const vocab = await tracker.getVocabularyStats(limit, excludePos);
|
||||||
return c.json(vocab);
|
return c.json(statsJson('vocabulary', vocab));
|
||||||
});
|
});
|
||||||
|
|
||||||
app.get('/api/stats/excluded-words', async (c) => {
|
app.get('/api/stats/excluded-words', async (c) => {
|
||||||
return c.json(await tracker.getStatsExcludedWords());
|
return c.json(statsJson('excludedWords', await tracker.getStatsExcludedWords()));
|
||||||
});
|
});
|
||||||
|
|
||||||
app.put('/api/stats/excluded-words', async (c) => {
|
app.put('/api/stats/excluded-words', async (c) => {
|
||||||
@@ -739,7 +743,7 @@ export function createStatsApp(
|
|||||||
const words = parseExcludedWordsBody(body);
|
const words = parseExcludedWordsBody(body);
|
||||||
if (!words) return c.body(null, 400);
|
if (!words) return c.body(null, 400);
|
||||||
await tracker.replaceStatsExcludedWords(words);
|
await tracker.replaceStatsExcludedWords(words);
|
||||||
return c.json({ ok: true });
|
return c.json(statsJson('setExcludedWords', { ok: true }));
|
||||||
});
|
});
|
||||||
|
|
||||||
app.get('/api/stats/vocabulary/occurrences', async (c) => {
|
app.get('/api/stats/vocabulary/occurrences', async (c) => {
|
||||||
@@ -747,17 +751,17 @@ export function createStatsApp(
|
|||||||
const word = (c.req.query('word') ?? '').trim();
|
const word = (c.req.query('word') ?? '').trim();
|
||||||
const reading = (c.req.query('reading') ?? '').trim();
|
const reading = (c.req.query('reading') ?? '').trim();
|
||||||
if (!headword || !word) {
|
if (!headword || !word) {
|
||||||
return c.json([], 400);
|
return c.json(statsJson('wordOccurrences', []), 400);
|
||||||
}
|
}
|
||||||
const limit = parseIntQuery(c.req.query('limit'), 50, 500);
|
const limit = parseIntQuery(c.req.query('limit'), 50, 500);
|
||||||
const offset = parseIntQuery(c.req.query('offset'), 0, 10_000);
|
const offset = parseIntQuery(c.req.query('offset'), 0, 10_000);
|
||||||
const occurrences = await tracker.getWordOccurrences(headword, word, reading, limit, offset);
|
const occurrences = await tracker.getWordOccurrences(headword, word, reading, limit, offset);
|
||||||
return c.json(occurrences);
|
return c.json(statsJson('wordOccurrences', occurrences));
|
||||||
});
|
});
|
||||||
|
|
||||||
app.get('/api/stats/sentences/search', async (c) => {
|
app.get('/api/stats/sentences/search', async (c) => {
|
||||||
const query = (c.req.query('q') ?? '').trim();
|
const query = (c.req.query('q') ?? '').trim();
|
||||||
if (!query) return c.json([]);
|
if (!query) return c.json(statsJson('sentenceSearch', []));
|
||||||
const limit = parseIntQuery(c.req.query('limit'), 50, 100);
|
const limit = parseIntQuery(c.req.query('limit'), 50, 100);
|
||||||
const searchByHeadword = parseBooleanQuery(c.req.query('headword'), true);
|
const searchByHeadword = parseBooleanQuery(c.req.query('headword'), true);
|
||||||
const searchOptions = await buildSentenceSearchOptions(
|
const searchOptions = await buildSentenceSearchOptions(
|
||||||
@@ -766,24 +770,24 @@ export function createStatsApp(
|
|||||||
options?.resolveSentenceSearchHeadwords,
|
options?.resolveSentenceSearchHeadwords,
|
||||||
);
|
);
|
||||||
const rows = await tracker.searchSubtitleSentences(query, limit, searchOptions);
|
const rows = await tracker.searchSubtitleSentences(query, limit, searchOptions);
|
||||||
return c.json(rows);
|
return c.json(statsJson('sentenceSearch', rows));
|
||||||
});
|
});
|
||||||
|
|
||||||
app.get('/api/stats/kanji', async (c) => {
|
app.get('/api/stats/kanji', async (c) => {
|
||||||
const limit = parseIntQuery(c.req.query('limit'), 100, 500);
|
const limit = parseIntQuery(c.req.query('limit'), 100, 500);
|
||||||
const kanji = await tracker.getKanjiStats(limit);
|
const kanji = await tracker.getKanjiStats(limit);
|
||||||
return c.json(kanji);
|
return c.json(statsJson('kanji', kanji));
|
||||||
});
|
});
|
||||||
|
|
||||||
app.get('/api/stats/kanji/occurrences', async (c) => {
|
app.get('/api/stats/kanji/occurrences', async (c) => {
|
||||||
const kanji = (c.req.query('kanji') ?? '').trim();
|
const kanji = (c.req.query('kanji') ?? '').trim();
|
||||||
if (!kanji) {
|
if (!kanji) {
|
||||||
return c.json([], 400);
|
return c.json(statsJson('kanjiOccurrences', []), 400);
|
||||||
}
|
}
|
||||||
const limit = parseIntQuery(c.req.query('limit'), 50, 500);
|
const limit = parseIntQuery(c.req.query('limit'), 50, 500);
|
||||||
const offset = parseIntQuery(c.req.query('offset'), 0, 10_000);
|
const offset = parseIntQuery(c.req.query('offset'), 0, 10_000);
|
||||||
const occurrences = await tracker.getKanjiOccurrences(kanji, limit, offset);
|
const occurrences = await tracker.getKanjiOccurrences(kanji, limit, offset);
|
||||||
return c.json(occurrences);
|
return c.json(statsJson('kanjiOccurrences', occurrences));
|
||||||
});
|
});
|
||||||
|
|
||||||
app.get('/api/stats/vocabulary/:wordId/detail', async (c) => {
|
app.get('/api/stats/vocabulary/:wordId/detail', async (c) => {
|
||||||
@@ -793,7 +797,7 @@ export function createStatsApp(
|
|||||||
if (!detail) return c.body(null, 404);
|
if (!detail) return c.body(null, 404);
|
||||||
const animeAppearances = await tracker.getWordAnimeAppearances(wordId);
|
const animeAppearances = await tracker.getWordAnimeAppearances(wordId);
|
||||||
const similarWords = await tracker.getSimilarWords(wordId);
|
const similarWords = await tracker.getSimilarWords(wordId);
|
||||||
return c.json({ detail, animeAppearances, similarWords });
|
return c.json(statsJson('wordDetail', { detail, animeAppearances, similarWords }));
|
||||||
});
|
});
|
||||||
|
|
||||||
app.get('/api/stats/kanji/:kanjiId/detail', async (c) => {
|
app.get('/api/stats/kanji/:kanjiId/detail', async (c) => {
|
||||||
@@ -803,17 +807,17 @@ export function createStatsApp(
|
|||||||
if (!detail) return c.body(null, 404);
|
if (!detail) return c.body(null, 404);
|
||||||
const animeAppearances = await tracker.getKanjiAnimeAppearances(kanjiId);
|
const animeAppearances = await tracker.getKanjiAnimeAppearances(kanjiId);
|
||||||
const words = await tracker.getKanjiWords(kanjiId);
|
const words = await tracker.getKanjiWords(kanjiId);
|
||||||
return c.json({ detail, animeAppearances, words });
|
return c.json(statsJson('kanjiDetail', { detail, animeAppearances, words }));
|
||||||
});
|
});
|
||||||
|
|
||||||
app.get('/api/stats/media', async (c) => {
|
app.get('/api/stats/media', async (c) => {
|
||||||
const library = await tracker.getMediaLibrary();
|
const library = await tracker.getMediaLibrary();
|
||||||
return c.json(library);
|
return c.json(statsJson('mediaLibrary', library));
|
||||||
});
|
});
|
||||||
|
|
||||||
app.get('/api/stats/media/:videoId', async (c) => {
|
app.get('/api/stats/media/:videoId', async (c) => {
|
||||||
const videoId = parseIntQuery(c.req.param('videoId'), 0);
|
const videoId = parseIntQuery(c.req.param('videoId'), 0);
|
||||||
if (videoId <= 0) return c.json(null, 400);
|
if (videoId <= 0) return c.json(statsJson('error', null), 400);
|
||||||
const [detail, rawSessions, rollups] = await Promise.all([
|
const [detail, rawSessions, rollups] = await Promise.all([
|
||||||
tracker.getMediaDetail(videoId),
|
tracker.getMediaDetail(videoId),
|
||||||
tracker.getMediaSessions(videoId, 100),
|
tracker.getMediaSessions(videoId, 100),
|
||||||
@@ -824,12 +828,12 @@ export function createStatsApp(
|
|||||||
rawSessions,
|
rawSessions,
|
||||||
options?.knownWordCachePath,
|
options?.knownWordCachePath,
|
||||||
);
|
);
|
||||||
return c.json({ detail, sessions, rollups });
|
return c.json(statsJson('mediaDetail', { detail, sessions, rollups }));
|
||||||
});
|
});
|
||||||
|
|
||||||
app.get('/api/stats/anime', async (c) => {
|
app.get('/api/stats/anime', async (c) => {
|
||||||
const rows = await tracker.getAnimeLibrary();
|
const rows = await tracker.getAnimeLibrary();
|
||||||
return c.json(rows);
|
return c.json(statsJson('animeLibrary', rows));
|
||||||
});
|
});
|
||||||
|
|
||||||
app.get('/api/stats/anime/:animeId', async (c) => {
|
app.get('/api/stats/anime/:animeId', async (c) => {
|
||||||
@@ -841,21 +845,21 @@ export function createStatsApp(
|
|||||||
tracker.getAnimeEpisodes(animeId),
|
tracker.getAnimeEpisodes(animeId),
|
||||||
tracker.getAnimeAnilistEntries(animeId),
|
tracker.getAnimeAnilistEntries(animeId),
|
||||||
]);
|
]);
|
||||||
return c.json({ detail, episodes, anilistEntries });
|
return c.json(statsJson('animeDetail', { detail, episodes, anilistEntries }));
|
||||||
});
|
});
|
||||||
|
|
||||||
app.get('/api/stats/anime/:animeId/words', async (c) => {
|
app.get('/api/stats/anime/:animeId/words', async (c) => {
|
||||||
const animeId = parseIntQuery(c.req.param('animeId'), 0);
|
const animeId = parseIntQuery(c.req.param('animeId'), 0);
|
||||||
const limit = parseIntQuery(c.req.query('limit'), 50, 200);
|
const limit = parseIntQuery(c.req.query('limit'), 50, 200);
|
||||||
if (animeId <= 0) return c.body(null, 400);
|
if (animeId <= 0) return c.body(null, 400);
|
||||||
return c.json(await tracker.getAnimeWords(animeId, limit));
|
return c.json(statsJson('animeWords', await tracker.getAnimeWords(animeId, limit)));
|
||||||
});
|
});
|
||||||
|
|
||||||
app.get('/api/stats/anime/:animeId/rollups', async (c) => {
|
app.get('/api/stats/anime/:animeId/rollups', async (c) => {
|
||||||
const animeId = parseIntQuery(c.req.param('animeId'), 0);
|
const animeId = parseIntQuery(c.req.param('animeId'), 0);
|
||||||
const limit = parseIntQuery(c.req.query('limit'), 90, 365);
|
const limit = parseIntQuery(c.req.query('limit'), 90, 365);
|
||||||
if (animeId <= 0) return c.body(null, 400);
|
if (animeId <= 0) return c.body(null, 400);
|
||||||
return c.json(await tracker.getAnimeDailyRollups(animeId, limit));
|
return c.json(statsJson('animeRollups', await tracker.getAnimeDailyRollups(animeId, limit)));
|
||||||
});
|
});
|
||||||
|
|
||||||
app.patch('/api/stats/media/:videoId/watched', async (c) => {
|
app.patch('/api/stats/media/:videoId/watched', async (c) => {
|
||||||
@@ -864,7 +868,7 @@ export function createStatsApp(
|
|||||||
const body = await c.req.json().catch(() => null);
|
const body = await c.req.json().catch(() => null);
|
||||||
const watched = typeof body?.watched === 'boolean' ? body.watched : true;
|
const watched = typeof body?.watched === 'boolean' ? body.watched : true;
|
||||||
await tracker.setVideoWatched(videoId, watched);
|
await tracker.setVideoWatched(videoId, watched);
|
||||||
return c.json({ ok: true });
|
return c.json(statsJson('setVideoWatched', { ok: true }));
|
||||||
});
|
});
|
||||||
|
|
||||||
app.delete('/api/stats/sessions', async (c) => {
|
app.delete('/api/stats/sessions', async (c) => {
|
||||||
@@ -874,26 +878,26 @@ export function createStatsApp(
|
|||||||
: [];
|
: [];
|
||||||
if (ids.length === 0) return c.body(null, 400);
|
if (ids.length === 0) return c.body(null, 400);
|
||||||
await tracker.deleteSessions(ids);
|
await tracker.deleteSessions(ids);
|
||||||
return c.json({ ok: true });
|
return c.json(statsJson('deleteSessions', { ok: true }));
|
||||||
});
|
});
|
||||||
|
|
||||||
app.delete('/api/stats/sessions/:sessionId', async (c) => {
|
app.delete('/api/stats/sessions/:sessionId', async (c) => {
|
||||||
const sessionId = parseIntQuery(c.req.param('sessionId'), 0);
|
const sessionId = parseIntQuery(c.req.param('sessionId'), 0);
|
||||||
if (sessionId <= 0) return c.body(null, 400);
|
if (sessionId <= 0) return c.body(null, 400);
|
||||||
await tracker.deleteSession(sessionId);
|
await tracker.deleteSession(sessionId);
|
||||||
return c.json({ ok: true });
|
return c.json(statsJson('deleteSession', { ok: true }));
|
||||||
});
|
});
|
||||||
|
|
||||||
app.delete('/api/stats/media/:videoId', async (c) => {
|
app.delete('/api/stats/media/:videoId', async (c) => {
|
||||||
const videoId = parseIntQuery(c.req.param('videoId'), 0);
|
const videoId = parseIntQuery(c.req.param('videoId'), 0);
|
||||||
if (videoId <= 0) return c.body(null, 400);
|
if (videoId <= 0) return c.body(null, 400);
|
||||||
await tracker.deleteVideo(videoId);
|
await tracker.deleteVideo(videoId);
|
||||||
return c.json({ ok: true });
|
return c.json(statsJson('deleteVideo', { ok: true }));
|
||||||
});
|
});
|
||||||
|
|
||||||
app.get('/api/stats/anilist/search', async (c) => {
|
app.get('/api/stats/anilist/search', async (c) => {
|
||||||
const query = (c.req.query('q') ?? '').trim();
|
const query = (c.req.query('q') ?? '').trim();
|
||||||
if (!query) return c.json([]);
|
if (!query) return c.json(statsJson('anilistSearch', []));
|
||||||
try {
|
try {
|
||||||
await options?.anilistRateLimiter?.acquire();
|
await options?.anilistRateLimiter?.acquire();
|
||||||
const res = await fetch('https://graphql.anilist.co', {
|
const res = await fetch('https://graphql.anilist.co', {
|
||||||
@@ -918,44 +922,66 @@ export function createStatsApp(
|
|||||||
});
|
});
|
||||||
options?.anilistRateLimiter?.recordResponse(res.headers);
|
options?.anilistRateLimiter?.recordResponse(res.headers);
|
||||||
if (res.status === 429) {
|
if (res.status === 429) {
|
||||||
return c.json([]);
|
return c.json(statsJson('anilistSearch', []));
|
||||||
}
|
}
|
||||||
const json = (await res.json()) as { data?: { Page?: { media?: unknown[] } } };
|
const json = (await res.json()) as {
|
||||||
return c.json(json.data?.Page?.media ?? []);
|
data?: { Page?: { media?: StatsAnilistSearchResult[] } };
|
||||||
|
};
|
||||||
|
return c.json(statsJson('anilistSearch', json.data?.Page?.media ?? []));
|
||||||
} catch {
|
} catch {
|
||||||
return c.json([]);
|
return c.json(statsJson('anilistSearch', []));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
app.get('/api/stats/known-words', (c) => {
|
app.get('/api/stats/known-words', (c) => {
|
||||||
const knownWordsSet = loadKnownWordsSet(options?.knownWordCachePath);
|
const knownWordsSet = loadKnownWordsSet(options?.knownWordCachePath);
|
||||||
if (!knownWordsSet) return c.json([]);
|
if (!knownWordsSet) return c.json(statsJson('knownWords', []));
|
||||||
return c.json([...knownWordsSet]);
|
return c.json(statsJson('knownWords', [...knownWordsSet]));
|
||||||
});
|
});
|
||||||
|
|
||||||
app.get('/api/stats/known-words-summary', async (c) => {
|
app.get('/api/stats/known-words-summary', async (c) => {
|
||||||
const knownWordsSet = loadKnownWordsSet(options?.knownWordCachePath);
|
const knownWordsSet = loadKnownWordsSet(options?.knownWordCachePath);
|
||||||
if (!knownWordsSet) return c.json({ totalUniqueWords: 0, knownWordCount: 0 });
|
if (!knownWordsSet) {
|
||||||
|
return c.json(statsJson('knownWordsSummary', { totalUniqueWords: 0, knownWordCount: 0 }));
|
||||||
|
}
|
||||||
const headwords = await tracker.getAllDistinctHeadwords();
|
const headwords = await tracker.getAllDistinctHeadwords();
|
||||||
return c.json(countKnownWords(headwords, knownWordsSet));
|
return c.json(statsJson('knownWordsSummary', countKnownWords(headwords, knownWordsSet)));
|
||||||
});
|
});
|
||||||
|
|
||||||
app.get('/api/stats/anime/:animeId/known-words-summary', async (c) => {
|
app.get('/api/stats/anime/:animeId/known-words-summary', async (c) => {
|
||||||
const animeId = parseIntQuery(c.req.param('animeId'), 0);
|
const animeId = parseIntQuery(c.req.param('animeId'), 0);
|
||||||
if (animeId <= 0) return c.json({ totalUniqueWords: 0, knownWordCount: 0 }, 400);
|
if (animeId <= 0) {
|
||||||
|
return c.json(
|
||||||
|
statsJson('animeKnownWordsSummary', { totalUniqueWords: 0, knownWordCount: 0 }),
|
||||||
|
400,
|
||||||
|
);
|
||||||
|
}
|
||||||
const knownWordsSet = loadKnownWordsSet(options?.knownWordCachePath);
|
const knownWordsSet = loadKnownWordsSet(options?.knownWordCachePath);
|
||||||
if (!knownWordsSet) return c.json({ totalUniqueWords: 0, knownWordCount: 0 });
|
if (!knownWordsSet) {
|
||||||
|
return c.json(
|
||||||
|
statsJson('animeKnownWordsSummary', { totalUniqueWords: 0, knownWordCount: 0 }),
|
||||||
|
);
|
||||||
|
}
|
||||||
const headwords = await tracker.getAnimeDistinctHeadwords(animeId);
|
const headwords = await tracker.getAnimeDistinctHeadwords(animeId);
|
||||||
return c.json(countKnownWords(headwords, knownWordsSet));
|
return c.json(statsJson('animeKnownWordsSummary', countKnownWords(headwords, knownWordsSet)));
|
||||||
});
|
});
|
||||||
|
|
||||||
app.get('/api/stats/media/:videoId/known-words-summary', async (c) => {
|
app.get('/api/stats/media/:videoId/known-words-summary', async (c) => {
|
||||||
const videoId = parseIntQuery(c.req.param('videoId'), 0);
|
const videoId = parseIntQuery(c.req.param('videoId'), 0);
|
||||||
if (videoId <= 0) return c.json({ totalUniqueWords: 0, knownWordCount: 0 }, 400);
|
if (videoId <= 0) {
|
||||||
|
return c.json(
|
||||||
|
statsJson('mediaKnownWordsSummary', { totalUniqueWords: 0, knownWordCount: 0 }),
|
||||||
|
400,
|
||||||
|
);
|
||||||
|
}
|
||||||
const knownWordsSet = loadKnownWordsSet(options?.knownWordCachePath);
|
const knownWordsSet = loadKnownWordsSet(options?.knownWordCachePath);
|
||||||
if (!knownWordsSet) return c.json({ totalUniqueWords: 0, knownWordCount: 0 });
|
if (!knownWordsSet) {
|
||||||
|
return c.json(
|
||||||
|
statsJson('mediaKnownWordsSummary', { totalUniqueWords: 0, knownWordCount: 0 }),
|
||||||
|
);
|
||||||
|
}
|
||||||
const headwords = await tracker.getMediaDistinctHeadwords(videoId);
|
const headwords = await tracker.getMediaDistinctHeadwords(videoId);
|
||||||
return c.json(countKnownWords(headwords, knownWordsSet));
|
return c.json(statsJson('mediaKnownWordsSummary', countKnownWords(headwords, knownWordsSet)));
|
||||||
});
|
});
|
||||||
|
|
||||||
app.patch('/api/stats/anime/:animeId/anilist', async (c) => {
|
app.patch('/api/stats/anime/:animeId/anilist', async (c) => {
|
||||||
@@ -964,7 +990,7 @@ export function createStatsApp(
|
|||||||
const body = await c.req.json().catch(() => null);
|
const body = await c.req.json().catch(() => null);
|
||||||
if (!body?.anilistId) return c.body(null, 400);
|
if (!body?.anilistId) return c.body(null, 400);
|
||||||
await tracker.reassignAnimeAnilist(animeId, body);
|
await tracker.reassignAnimeAnilist(animeId, body);
|
||||||
return c.json({ ok: true });
|
return c.json(statsJson('reassignAnimeAnilist', { ok: true }));
|
||||||
});
|
});
|
||||||
|
|
||||||
registerStatsCoverRoutes(app, tracker);
|
registerStatsCoverRoutes(app, tracker);
|
||||||
@@ -980,7 +1006,7 @@ export function createStatsApp(
|
|||||||
rawSessions,
|
rawSessions,
|
||||||
options?.knownWordCachePath,
|
options?.knownWordCachePath,
|
||||||
);
|
);
|
||||||
return c.json({ sessions, words, cardEvents });
|
return c.json(statsJson('episodeDetail', { sessions, words, cardEvents }));
|
||||||
});
|
});
|
||||||
|
|
||||||
app.post('/api/stats/anki/browse', async (c) => {
|
app.post('/api/stats/anki/browse', async (c) => {
|
||||||
@@ -998,10 +1024,10 @@ export function createStatsApp(
|
|||||||
params: { query: `nid:${noteId}` },
|
params: { query: `nid:${noteId}` },
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
const result = await response.json();
|
const result = (await response.json()) as StatsAnkiBrowseResponse;
|
||||||
return c.json(result);
|
return c.json(statsJson('ankiBrowse', result));
|
||||||
} catch {
|
} catch {
|
||||||
return c.json({ error: 'Failed to reach AnkiConnect' }, 502);
|
return c.json(statsJson('error', { error: 'Failed to reach AnkiConnect' }), 502);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1012,7 +1038,7 @@ export function createStatsApp(
|
|||||||
(id: unknown): id is number => typeof id === 'number' && Number.isInteger(id) && id > 0,
|
(id: unknown): id is number => typeof id === 'number' && Number.isInteger(id) && id > 0,
|
||||||
)
|
)
|
||||||
: [];
|
: [];
|
||||||
if (noteIds.length === 0) return c.json([]);
|
if (noteIds.length === 0) return c.json(statsJson('ankiNotesInfo', []));
|
||||||
const resolvedNoteIds = Array.from(
|
const resolvedNoteIds = Array.from(
|
||||||
new Set(
|
new Set(
|
||||||
noteIds.map((noteId) => {
|
noteIds.map((noteId) => {
|
||||||
@@ -1039,13 +1065,16 @@ export function createStatsApp(
|
|||||||
result?: Array<{ noteId: number; fields: Record<string, { value: string }> }>;
|
result?: Array<{ noteId: number; fields: Record<string, { value: string }> }>;
|
||||||
};
|
};
|
||||||
return c.json(
|
return c.json(
|
||||||
(result.result ?? []).map((note) => ({
|
statsJson(
|
||||||
...note,
|
'ankiNotesInfo',
|
||||||
preview: buildAnkiNotePreview(note.fields, ankiConfig),
|
(result.result ?? []).map((note) => ({
|
||||||
})),
|
...note,
|
||||||
|
preview: buildAnkiNotePreview(note.fields, ankiConfig),
|
||||||
|
})),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
} catch {
|
} catch {
|
||||||
return c.json([], 502);
|
return c.json(statsJson('ankiNotesInfo', []), 502);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1063,19 +1092,24 @@ export function createStatsApp(
|
|||||||
const mode = rawMode === 'audio' ? 'audio' : rawMode === 'word' ? 'word' : 'sentence';
|
const mode = rawMode === 'audio' ? 'audio' : rawMode === 'word' ? 'word' : 'sentence';
|
||||||
|
|
||||||
if (!sourcePath || !sentence || !Number.isFinite(startMs) || !Number.isFinite(endMs)) {
|
if (!sourcePath || !sentence || !Number.isFinite(startMs) || !Number.isFinite(endMs)) {
|
||||||
return c.json({ error: 'sourcePath, sentence, startMs, and endMs are required' }, 400);
|
return c.json(
|
||||||
|
statsJson('mineCard', {
|
||||||
|
error: 'sourcePath, sentence, startMs, and endMs are required',
|
||||||
|
}),
|
||||||
|
400,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
if (endMs <= startMs) {
|
if (endMs <= startMs) {
|
||||||
return c.json({ error: 'endMs must be greater than startMs' }, 400);
|
return c.json(statsJson('mineCard', { error: 'endMs must be greater than startMs' }), 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!existsSync(sourcePath)) {
|
if (!existsSync(sourcePath)) {
|
||||||
return c.json({ error: 'File not found' }, 404);
|
return c.json(statsJson('mineCard', { error: 'File not found' }), 404);
|
||||||
}
|
}
|
||||||
|
|
||||||
const ankiConfig = getAnkiConnectConfig();
|
const ankiConfig = getAnkiConnectConfig();
|
||||||
if (!ankiConfig) {
|
if (!ankiConfig) {
|
||||||
return c.json({ error: 'AnkiConnect is not configured' }, 500);
|
return c.json(statsJson('mineCard', { error: 'AnkiConnect is not configured' }), 500);
|
||||||
}
|
}
|
||||||
const secondarySubtitleLanguages = getSecondarySubtitleLanguages();
|
const secondarySubtitleLanguages = getSecondarySubtitleLanguages();
|
||||||
let retimedSecondaryText = '';
|
let retimedSecondaryText = '';
|
||||||
@@ -1203,7 +1237,7 @@ export function createStatsApp(
|
|||||||
|
|
||||||
if (mode === 'word') {
|
if (mode === 'word') {
|
||||||
if (!options?.addYomitanNote) {
|
if (!options?.addYomitanNote) {
|
||||||
return c.json({ error: 'Yomitan bridge not available' }, 500);
|
return c.json(statsJson('mineCard', { error: 'Yomitan bridge not available' }), 500);
|
||||||
}
|
}
|
||||||
|
|
||||||
const [yomitanResult, audioResult, imageResult] = await Promise.allSettled([
|
const [yomitanResult, audioResult, imageResult] = await Promise.allSettled([
|
||||||
@@ -1219,9 +1253,9 @@ export function createStatsApp(
|
|||||||
|
|
||||||
if (yomitanResult.status === 'rejected' || !yomitanResult.value) {
|
if (yomitanResult.status === 'rejected' || !yomitanResult.value) {
|
||||||
return c.json(
|
return c.json(
|
||||||
{
|
statsJson('mineCard', {
|
||||||
error: `Yomitan failed to create note: ${yomitanResult.status === 'rejected' ? (yomitanResult.reason as Error).message : 'no result'}`,
|
error: `Yomitan failed to create note: ${yomitanResult.status === 'rejected' ? (yomitanResult.reason as Error).message : 'no result'}`,
|
||||||
},
|
}),
|
||||||
502,
|
502,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1336,7 +1370,7 @@ export function createStatsApp(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return c.json({ noteId, ...(errors.length > 0 ? { errors } : {}) });
|
return c.json(statsJson('mineCard', { noteId, ...(errors.length > 0 ? { errors } : {}) }));
|
||||||
}
|
}
|
||||||
|
|
||||||
const wordFieldName = getConfiguredWordFieldName(ankiConfig);
|
const wordFieldName = getConfiguredWordFieldName(ankiConfig);
|
||||||
@@ -1394,7 +1428,9 @@ export function createStatsApp(
|
|||||||
|
|
||||||
if (addNoteResult.status === 'rejected') {
|
if (addNoteResult.status === 'rejected') {
|
||||||
return c.json(
|
return c.json(
|
||||||
{ error: `Failed to add note: ${(addNoteResult.reason as Error).message}` },
|
statsJson('mineCard', {
|
||||||
|
error: `Failed to add note: ${(addNoteResult.reason as Error).message}`,
|
||||||
|
}),
|
||||||
502,
|
502,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1470,7 +1506,7 @@ export function createStatsApp(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return c.json({ noteId, ...(errors.length > 0 ? { errors } : {}) });
|
return c.json(statsJson('mineCard', { noteId, ...(errors.length > 0 ? { errors } : {}) }));
|
||||||
});
|
});
|
||||||
|
|
||||||
if (options?.staticDir) {
|
if (options?.staticDir) {
|
||||||
|
|||||||
@@ -100,7 +100,7 @@ test(
|
|||||||
'#!/bin/sh',
|
'#!/bin/sh',
|
||||||
'if [ "${1:-}" = "--appimage-mount" ]; then',
|
'if [ "${1:-}" = "--appimage-mount" ]; then',
|
||||||
` echo "${mountDir}"`,
|
` echo "${mountDir}"`,
|
||||||
` trap 'date +%s%N > "${resultsDir}/holder-released"; exit 0' TERM INT`,
|
` trap ': > "${resultsDir}/holder-released"; sleep 0.1; date +%s%N > "${resultsDir}/holder-released"; exit 0' TERM INT`,
|
||||||
' while :; do sleep 0.05; done',
|
' while :; do sleep 0.05; done',
|
||||||
'fi',
|
'fi',
|
||||||
`date +%s%N > "${resultsDir}/direct-run"`,
|
`date +%s%N > "${resultsDir}/direct-run"`,
|
||||||
@@ -120,15 +120,20 @@ test(
|
|||||||
// (the real runtime unmounts on its own after the signal), so poll.
|
// (the real runtime unmounts on its own after the signal), so poll.
|
||||||
const releasedMarker = path.join(resultsDir, 'holder-released');
|
const releasedMarker = path.join(resultsDir, 'holder-released');
|
||||||
const pollDeadline = Date.now() + 2000;
|
const pollDeadline = Date.now() + 2000;
|
||||||
while (!fs.existsSync(releasedMarker) && Date.now() < pollDeadline) {
|
let holderReleased: number | null = null;
|
||||||
|
while (holderReleased === null && Date.now() < pollDeadline) {
|
||||||
|
if (fs.existsSync(releasedMarker)) {
|
||||||
|
const timestamp = fs.readFileSync(releasedMarker, 'utf8').trim();
|
||||||
|
if (/^\d+$/.test(timestamp)) holderReleased = Number(timestamp);
|
||||||
|
}
|
||||||
|
if (holderReleased !== null) break;
|
||||||
await new Promise((r) => setTimeout(r, 25));
|
await new Promise((r) => setTimeout(r, 25));
|
||||||
}
|
}
|
||||||
assert.ok(fs.existsSync(releasedMarker), 'holder must be released');
|
assert.ok(holderReleased !== null, 'holder release timestamp must be recorded');
|
||||||
|
|
||||||
const appRunExited = Number(
|
const appRunExited = Number(
|
||||||
fs.readFileSync(path.join(resultsDir, 'apprun-exited'), 'utf8').trim(),
|
fs.readFileSync(path.join(resultsDir, 'apprun-exited'), 'utf8').trim(),
|
||||||
);
|
);
|
||||||
const holderReleased = Number(fs.readFileSync(releasedMarker, 'utf8').trim());
|
|
||||||
const drainNs = holderReleased - appRunExited;
|
const drainNs = holderReleased - appRunExited;
|
||||||
assert.ok(
|
assert.ok(
|
||||||
drainNs >= 0.8e9,
|
drainNs >= 0.8e9,
|
||||||
|
|||||||
@@ -2,48 +2,6 @@ import { contextBridge, ipcRenderer } from 'electron';
|
|||||||
import { IPC_CHANNELS } from './shared/ipc/contracts';
|
import { IPC_CHANNELS } from './shared/ipc/contracts';
|
||||||
|
|
||||||
const statsAPI = {
|
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);
|
|
||||||
},
|
|
||||||
|
|
||||||
confirmNativeDialog: (message: string): boolean => {
|
confirmNativeDialog: (message: string): boolean => {
|
||||||
return ipcRenderer.sendSync(IPC_CHANNELS.command.statsNativeConfirmDialog, message) === true;
|
return ipcRenderer.sendSync(IPC_CHANNELS.command.statsNativeConfirmDialog, message) === true;
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -101,19 +101,6 @@ export const IPC_CHANNELS = {
|
|||||||
animetoshoDownloadFile: 'animetosho:download-file',
|
animetoshoDownloadFile: 'animetosho:download-file',
|
||||||
animetoshoGetSecondaryLanguages: 'animetosho:get-secondary-languages',
|
animetoshoGetSecondaryLanguages: 'animetosho:get-secondary-languages',
|
||||||
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',
|
|
||||||
getConfigSettingsSnapshot: 'config:get-settings-snapshot',
|
getConfigSettingsSnapshot: 'config:get-settings-snapshot',
|
||||||
saveConfigSettingsPatch: 'config:save-settings-patch',
|
saveConfigSettingsPatch: 'config:save-settings-patch',
|
||||||
openConfigSettingsFile: 'config:open-settings-file',
|
openConfigSettingsFile: 'config:open-settings-file',
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
import test from 'node:test';
|
||||||
|
|
||||||
|
const root = process.cwd();
|
||||||
|
|
||||||
|
function read(relativePath: string): string {
|
||||||
|
return fs.readFileSync(path.join(root, relativePath), 'utf8');
|
||||||
|
}
|
||||||
|
|
||||||
|
test('stats data uses the shared HTTP contract while native dialogs retain IPC', () => {
|
||||||
|
assert.equal(fs.existsSync(path.join(root, 'stats/src/lib/ipc-client.ts')), false);
|
||||||
|
|
||||||
|
const preload = read('src/preload-stats.ts');
|
||||||
|
assert.doesNotMatch(preload, /statsGet[A-Z]/);
|
||||||
|
assert.match(preload, /statsNativeConfirmDialog/);
|
||||||
|
|
||||||
|
const ipcContracts = read('src/shared/ipc/contracts.ts');
|
||||||
|
assert.doesNotMatch(ipcContracts, /statsGet[A-Z]/);
|
||||||
|
|
||||||
|
const ipcHandlers = read('src/core/services/ipc.ts');
|
||||||
|
assert.doesNotMatch(ipcHandlers, /statsGet[A-Z]/);
|
||||||
|
|
||||||
|
const apiClient = read('stats/src/lib/api-client.ts');
|
||||||
|
const statsServer = read('src/core/services/stats-server.ts');
|
||||||
|
assert.match(apiClient, /StatsHttpClient/);
|
||||||
|
assert.match(statsServer, /statsJson/);
|
||||||
|
});
|
||||||
@@ -0,0 +1,231 @@
|
|||||||
|
import type {
|
||||||
|
AnimeDetailData,
|
||||||
|
AnimeLibraryItem,
|
||||||
|
AnimeWord,
|
||||||
|
DailyRollup,
|
||||||
|
EpisodeDetailData,
|
||||||
|
EpisodesPerDay,
|
||||||
|
KanjiDetailData,
|
||||||
|
KanjiEntry,
|
||||||
|
MediaDetailData,
|
||||||
|
MediaLibraryItem,
|
||||||
|
MonthlyRollup,
|
||||||
|
NewAnimePerDay,
|
||||||
|
OverviewData,
|
||||||
|
SentenceSearchResult,
|
||||||
|
SessionEvent,
|
||||||
|
SessionSummary,
|
||||||
|
SessionTimelinePoint,
|
||||||
|
StatsAnkiNoteInfo,
|
||||||
|
StatsCoverImagesData,
|
||||||
|
StatsExcludedWord,
|
||||||
|
StreakCalendarDay,
|
||||||
|
TrendsDashboardData,
|
||||||
|
VocabularyEntry,
|
||||||
|
VocabularyOccurrenceEntry,
|
||||||
|
WatchTimePerAnime,
|
||||||
|
WordDetailData,
|
||||||
|
} from './stats-wire';
|
||||||
|
|
||||||
|
export type StatsTrendRange = '7d' | '30d' | '90d' | '365d' | 'all';
|
||||||
|
export type StatsTrendGroupBy = 'day' | 'month';
|
||||||
|
export type StatsMineMode = 'word' | 'sentence' | 'audio';
|
||||||
|
|
||||||
|
export interface StatsSessionKnownWordsTimelinePoint {
|
||||||
|
linesSeen: number;
|
||||||
|
knownWordsSeen: number;
|
||||||
|
totalWordsSeen: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StatsKnownWordsSummary {
|
||||||
|
totalUniqueWords: number;
|
||||||
|
knownWordCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StatsAnilistSearchResult {
|
||||||
|
id: number;
|
||||||
|
episodes: number | null;
|
||||||
|
season: string | null;
|
||||||
|
seasonYear: number | null;
|
||||||
|
description: string | null;
|
||||||
|
coverImage: { large: string | null; medium: string | null } | null;
|
||||||
|
title: { romaji: string | null; english: string | null; native: string | null } | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StatsAnilistAssignment {
|
||||||
|
anilistId: number;
|
||||||
|
titleRomaji?: string | null;
|
||||||
|
titleEnglish?: string | null;
|
||||||
|
titleNative?: string | null;
|
||||||
|
episodesTotal?: number | null;
|
||||||
|
description?: string | null;
|
||||||
|
coverUrl?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StatsMineCardParams {
|
||||||
|
sourcePath: string;
|
||||||
|
startMs: number;
|
||||||
|
endMs: number;
|
||||||
|
sentence: string;
|
||||||
|
word: string;
|
||||||
|
secondaryText?: string | null;
|
||||||
|
videoTitle: string;
|
||||||
|
mode: StatsMineMode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StatsMineCardResponse {
|
||||||
|
noteId?: number;
|
||||||
|
error?: string;
|
||||||
|
errors?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StatsCoverImagesRequest {
|
||||||
|
animeIds: number[];
|
||||||
|
videoIds: number[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StatsExcludedWordsRequest {
|
||||||
|
words: StatsExcludedWord[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StatsVideoWatchedRequest {
|
||||||
|
watched: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StatsDeleteSessionsRequest {
|
||||||
|
sessionIds: number[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StatsAnkiNotesInfoRequest {
|
||||||
|
noteIds: number[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StatsOkResponse {
|
||||||
|
ok: true;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StatsErrorResponse {
|
||||||
|
error: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StatsAnkiBrowseResponse {
|
||||||
|
result?: number[] | null;
|
||||||
|
error?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StatsJsonResponseMap {
|
||||||
|
error: StatsErrorResponse | null;
|
||||||
|
overview: OverviewData;
|
||||||
|
dailyRollups: DailyRollup[];
|
||||||
|
monthlyRollups: MonthlyRollup[];
|
||||||
|
sessions: SessionSummary[];
|
||||||
|
sessionTimeline: SessionTimelinePoint[];
|
||||||
|
sessionEvents: SessionEvent[];
|
||||||
|
sessionKnownWordsTimeline: StatsSessionKnownWordsTimelinePoint[];
|
||||||
|
vocabulary: VocabularyEntry[];
|
||||||
|
excludedWords: StatsExcludedWord[];
|
||||||
|
setExcludedWords: StatsOkResponse;
|
||||||
|
wordOccurrences: VocabularyOccurrenceEntry[];
|
||||||
|
sentenceSearch: SentenceSearchResult[];
|
||||||
|
kanji: KanjiEntry[];
|
||||||
|
kanjiOccurrences: VocabularyOccurrenceEntry[];
|
||||||
|
wordDetail: WordDetailData;
|
||||||
|
kanjiDetail: KanjiDetailData;
|
||||||
|
mediaLibrary: MediaLibraryItem[];
|
||||||
|
mediaDetail: MediaDetailData;
|
||||||
|
animeLibrary: AnimeLibraryItem[];
|
||||||
|
animeDetail: AnimeDetailData;
|
||||||
|
animeWords: AnimeWord[];
|
||||||
|
animeRollups: DailyRollup[];
|
||||||
|
setVideoWatched: StatsOkResponse;
|
||||||
|
deleteSessions: StatsOkResponse;
|
||||||
|
deleteSession: StatsOkResponse;
|
||||||
|
deleteVideo: StatsOkResponse;
|
||||||
|
anilistSearch: StatsAnilistSearchResult[];
|
||||||
|
knownWords: string[];
|
||||||
|
knownWordsSummary: StatsKnownWordsSummary;
|
||||||
|
animeKnownWordsSummary: StatsKnownWordsSummary;
|
||||||
|
mediaKnownWordsSummary: StatsKnownWordsSummary;
|
||||||
|
reassignAnimeAnilist: StatsOkResponse;
|
||||||
|
coverImages: StatsCoverImagesData;
|
||||||
|
episodeDetail: EpisodeDetailData;
|
||||||
|
ankiBrowse: StatsAnkiBrowseResponse;
|
||||||
|
ankiNotesInfo: StatsAnkiNoteInfo[];
|
||||||
|
mineCard: StatsMineCardResponse;
|
||||||
|
streakCalendar: StreakCalendarDay[];
|
||||||
|
episodesPerDay: EpisodesPerDay[];
|
||||||
|
newAnimePerDay: NewAnimePerDay[];
|
||||||
|
watchTimePerAnime: WatchTimePerAnime[];
|
||||||
|
trendsDashboard: TrendsDashboardData;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function statsJson<Key extends keyof StatsJsonResponseMap>(
|
||||||
|
_key: Key,
|
||||||
|
payload: StatsJsonResponseMap[Key],
|
||||||
|
): StatsJsonResponseMap[Key] {
|
||||||
|
return payload;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StatsHttpClient {
|
||||||
|
getOverview: () => Promise<OverviewData>;
|
||||||
|
getDailyRollups: (limit?: number) => Promise<DailyRollup[]>;
|
||||||
|
getMonthlyRollups: (limit?: number) => Promise<MonthlyRollup[]>;
|
||||||
|
getSessions: (limit?: number) => Promise<SessionSummary[]>;
|
||||||
|
getSessionTimeline: (id: number, limit?: number) => Promise<SessionTimelinePoint[]>;
|
||||||
|
getSessionEvents: (id: number, limit?: number, eventTypes?: number[]) => Promise<SessionEvent[]>;
|
||||||
|
getSessionKnownWordsTimeline: (id: number) => Promise<StatsSessionKnownWordsTimelinePoint[]>;
|
||||||
|
getVocabulary: (limit?: number) => Promise<VocabularyEntry[]>;
|
||||||
|
getExcludedWords: () => Promise<StatsExcludedWord[]>;
|
||||||
|
setExcludedWords: (words: StatsExcludedWord[]) => Promise<void>;
|
||||||
|
getWordOccurrences: (
|
||||||
|
headword: string,
|
||||||
|
word: string,
|
||||||
|
reading: string,
|
||||||
|
limit?: number,
|
||||||
|
offset?: number,
|
||||||
|
) => Promise<VocabularyOccurrenceEntry[]>;
|
||||||
|
searchSentences: (
|
||||||
|
query: string,
|
||||||
|
limit?: number,
|
||||||
|
searchByHeadword?: boolean,
|
||||||
|
) => Promise<SentenceSearchResult[]>;
|
||||||
|
getKanji: (limit?: number) => Promise<KanjiEntry[]>;
|
||||||
|
getKanjiOccurrences: (
|
||||||
|
kanji: string,
|
||||||
|
limit?: number,
|
||||||
|
offset?: number,
|
||||||
|
) => Promise<VocabularyOccurrenceEntry[]>;
|
||||||
|
getMediaLibrary: () => Promise<MediaLibraryItem[]>;
|
||||||
|
getMediaDetail: (videoId: number) => Promise<MediaDetailData>;
|
||||||
|
getAnimeLibrary: () => Promise<AnimeLibraryItem[]>;
|
||||||
|
getAnimeDetail: (animeId: number) => Promise<AnimeDetailData>;
|
||||||
|
getAnimeWords: (animeId: number, limit?: number) => Promise<AnimeWord[]>;
|
||||||
|
getAnimeRollups: (animeId: number, limit?: number) => Promise<DailyRollup[]>;
|
||||||
|
getAnimeCoverUrl: (animeId: number, retryToken?: number) => string;
|
||||||
|
getCoverImages: (params: StatsCoverImagesRequest) => Promise<StatsCoverImagesData>;
|
||||||
|
getStreakCalendar: (days?: number) => Promise<StreakCalendarDay[]>;
|
||||||
|
getEpisodesPerDay: (limit?: number) => Promise<EpisodesPerDay[]>;
|
||||||
|
getNewAnimePerDay: (limit?: number) => Promise<NewAnimePerDay[]>;
|
||||||
|
getWatchTimePerAnime: (limit?: number) => Promise<WatchTimePerAnime[]>;
|
||||||
|
getTrendsDashboard: (
|
||||||
|
range: StatsTrendRange,
|
||||||
|
groupBy: StatsTrendGroupBy,
|
||||||
|
fillEmpty?: boolean,
|
||||||
|
) => Promise<TrendsDashboardData>;
|
||||||
|
getWordDetail: (wordId: number) => Promise<WordDetailData>;
|
||||||
|
getKanjiDetail: (kanjiId: number) => Promise<KanjiDetailData>;
|
||||||
|
getEpisodeDetail: (videoId: number) => Promise<EpisodeDetailData>;
|
||||||
|
setVideoWatched: (videoId: number, watched: boolean) => Promise<void>;
|
||||||
|
deleteSession: (sessionId: number) => Promise<void>;
|
||||||
|
deleteSessions: (sessionIds: number[]) => Promise<void>;
|
||||||
|
deleteVideo: (videoId: number) => Promise<void>;
|
||||||
|
getKnownWords: () => Promise<string[]>;
|
||||||
|
getKnownWordsSummary: () => Promise<StatsKnownWordsSummary>;
|
||||||
|
getAnimeKnownWordsSummary: (animeId: number) => Promise<StatsKnownWordsSummary>;
|
||||||
|
getMediaKnownWordsSummary: (videoId: number) => Promise<StatsKnownWordsSummary>;
|
||||||
|
searchAnilist: (query: string) => Promise<StatsAnilistSearchResult[]>;
|
||||||
|
reassignAnimeAnilist: (animeId: number, info: StatsAnilistAssignment) => Promise<void>;
|
||||||
|
mineCard: (params: StatsMineCardParams) => Promise<StatsMineCardResponse>;
|
||||||
|
ankiBrowse: (noteId: number) => Promise<void>;
|
||||||
|
ankiNotesInfo: (noteIds: number[]) => Promise<StatsAnkiNoteInfo[]>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,430 @@
|
|||||||
|
export interface SessionSummary {
|
||||||
|
sessionId: number;
|
||||||
|
canonicalTitle: string | null;
|
||||||
|
videoId: number | null;
|
||||||
|
animeId: number | null;
|
||||||
|
animeTitle: string | null;
|
||||||
|
startedAtMs: number;
|
||||||
|
endedAtMs: number | null;
|
||||||
|
totalWatchedMs: number;
|
||||||
|
activeWatchedMs: number;
|
||||||
|
linesSeen: number;
|
||||||
|
tokensSeen: number;
|
||||||
|
cardsMined: number;
|
||||||
|
lookupCount: number;
|
||||||
|
lookupHits: number;
|
||||||
|
yomitanLookupCount: number;
|
||||||
|
knownWordsSeen: number;
|
||||||
|
knownWordRate: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DailyRollup {
|
||||||
|
rollupDayOrMonth: number;
|
||||||
|
videoId: number | null;
|
||||||
|
totalSessions: number;
|
||||||
|
totalActiveMin: number;
|
||||||
|
totalLinesSeen: number;
|
||||||
|
totalTokensSeen: number;
|
||||||
|
totalCards: number;
|
||||||
|
cardsPerHour: number | null;
|
||||||
|
tokensPerMin: number | null;
|
||||||
|
lookupHitRate: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type MonthlyRollup = DailyRollup;
|
||||||
|
|
||||||
|
export interface SessionTimelinePoint {
|
||||||
|
sampleMs: number;
|
||||||
|
totalWatchedMs: number;
|
||||||
|
activeWatchedMs: number;
|
||||||
|
linesSeen: number;
|
||||||
|
tokensSeen: number;
|
||||||
|
cardsMined: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SessionEvent {
|
||||||
|
eventType: number;
|
||||||
|
tsMs: number;
|
||||||
|
payload: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AnkiNotePreview {
|
||||||
|
word: string;
|
||||||
|
sentence: string;
|
||||||
|
translation: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StatsAnkiNoteInfo {
|
||||||
|
noteId: number;
|
||||||
|
fields: Record<string, { value: string }>;
|
||||||
|
preview?: AnkiNotePreview;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface VocabularyEntry {
|
||||||
|
wordId: number;
|
||||||
|
headword: string;
|
||||||
|
word: string;
|
||||||
|
reading: string;
|
||||||
|
partOfSpeech: string | null;
|
||||||
|
pos1: string | null;
|
||||||
|
pos2: string | null;
|
||||||
|
pos3: string | null;
|
||||||
|
frequency: number;
|
||||||
|
frequencyRank: number | null;
|
||||||
|
animeCount: number;
|
||||||
|
firstSeen: number;
|
||||||
|
lastSeen: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StatsExcludedWord {
|
||||||
|
headword: string;
|
||||||
|
word: string;
|
||||||
|
reading: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StatsCoverImage {
|
||||||
|
contentType: string;
|
||||||
|
dataUrl: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StatsCoverImagesData {
|
||||||
|
anime: Record<number, StatsCoverImage | null>;
|
||||||
|
media: Record<number, StatsCoverImage | null>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface KanjiEntry {
|
||||||
|
kanjiId: number;
|
||||||
|
kanji: string;
|
||||||
|
frequency: number;
|
||||||
|
firstSeen: number;
|
||||||
|
lastSeen: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface VocabularyOccurrenceEntry {
|
||||||
|
animeId: number | null;
|
||||||
|
animeTitle: string | null;
|
||||||
|
videoId: number;
|
||||||
|
videoTitle: string;
|
||||||
|
sourcePath: string | null;
|
||||||
|
secondaryText: string | null;
|
||||||
|
sessionId: number;
|
||||||
|
lineIndex: number;
|
||||||
|
segmentStartMs: number | null;
|
||||||
|
segmentEndMs: number | null;
|
||||||
|
text: string;
|
||||||
|
occurrenceCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SentenceSearchResult {
|
||||||
|
animeId: number | null;
|
||||||
|
animeTitle: string | null;
|
||||||
|
videoId: number;
|
||||||
|
videoTitle: string;
|
||||||
|
sourcePath: string | null;
|
||||||
|
secondaryText: string | null;
|
||||||
|
sessionId: number;
|
||||||
|
lineIndex: number;
|
||||||
|
segmentStartMs: number | null;
|
||||||
|
segmentEndMs: number | null;
|
||||||
|
text: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OverviewData {
|
||||||
|
sessions: SessionSummary[];
|
||||||
|
rollups: DailyRollup[];
|
||||||
|
hints: {
|
||||||
|
totalSessions: number;
|
||||||
|
activeSessions: number;
|
||||||
|
episodesToday: number;
|
||||||
|
activeAnimeCount: number;
|
||||||
|
totalEpisodesWatched: number;
|
||||||
|
totalAnimeCompleted: number;
|
||||||
|
totalActiveMin: number;
|
||||||
|
activeDays: number;
|
||||||
|
totalCards?: number;
|
||||||
|
totalTokensSeen: number;
|
||||||
|
totalLookupCount: number;
|
||||||
|
totalLookupHits: number;
|
||||||
|
totalYomitanLookupCount: number;
|
||||||
|
newWordsToday: number;
|
||||||
|
newWordsThisWeek: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MediaLibraryItem {
|
||||||
|
videoId: number;
|
||||||
|
canonicalTitle: string;
|
||||||
|
totalSessions: number;
|
||||||
|
totalActiveMs: number;
|
||||||
|
totalCards: number;
|
||||||
|
totalTokensSeen: number;
|
||||||
|
lastWatchedMs: number;
|
||||||
|
hasCoverArt: number;
|
||||||
|
youtubeVideoId?: string | null;
|
||||||
|
videoUrl?: string | null;
|
||||||
|
videoTitle?: string | null;
|
||||||
|
videoThumbnailUrl?: string | null;
|
||||||
|
channelId?: string | null;
|
||||||
|
channelName?: string | null;
|
||||||
|
channelUrl?: string | null;
|
||||||
|
channelThumbnailUrl?: string | null;
|
||||||
|
uploaderId?: string | null;
|
||||||
|
uploaderUrl?: string | null;
|
||||||
|
description?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MediaDetailData {
|
||||||
|
detail: {
|
||||||
|
videoId: number;
|
||||||
|
canonicalTitle: string;
|
||||||
|
animeId: number | null;
|
||||||
|
totalSessions: number;
|
||||||
|
totalActiveMs: number;
|
||||||
|
totalCards: number;
|
||||||
|
totalTokensSeen: number;
|
||||||
|
totalLinesSeen: number;
|
||||||
|
totalLookupCount: number;
|
||||||
|
totalLookupHits: number;
|
||||||
|
totalYomitanLookupCount: number;
|
||||||
|
youtubeVideoId?: string | null;
|
||||||
|
videoUrl?: string | null;
|
||||||
|
videoTitle?: string | null;
|
||||||
|
videoThumbnailUrl?: string | null;
|
||||||
|
channelId?: string | null;
|
||||||
|
channelName?: string | null;
|
||||||
|
channelUrl?: string | null;
|
||||||
|
channelThumbnailUrl?: string | null;
|
||||||
|
uploaderId?: string | null;
|
||||||
|
uploaderUrl?: string | null;
|
||||||
|
description?: string | null;
|
||||||
|
} | null;
|
||||||
|
sessions: SessionSummary[];
|
||||||
|
rollups: DailyRollup[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const EventType = {
|
||||||
|
SUBTITLE_LINE: 1,
|
||||||
|
MEDIA_BUFFER: 2,
|
||||||
|
LOOKUP: 3,
|
||||||
|
CARD_MINED: 4,
|
||||||
|
SEEK_FORWARD: 5,
|
||||||
|
SEEK_BACKWARD: 6,
|
||||||
|
PAUSE_START: 7,
|
||||||
|
PAUSE_END: 8,
|
||||||
|
YOMITAN_LOOKUP: 9,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export type EventType = (typeof EventType)[keyof typeof EventType];
|
||||||
|
|
||||||
|
export interface AnimeLibraryItem {
|
||||||
|
animeId: number;
|
||||||
|
canonicalTitle: string;
|
||||||
|
anilistId: number | null;
|
||||||
|
totalSessions: number;
|
||||||
|
totalActiveMs: number;
|
||||||
|
totalCards: number;
|
||||||
|
totalTokensSeen: number;
|
||||||
|
episodeCount: number;
|
||||||
|
episodesTotal: number | null;
|
||||||
|
lastWatchedMs: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AnilistEntry {
|
||||||
|
anilistId: number;
|
||||||
|
titleRomaji: string | null;
|
||||||
|
titleEnglish: string | null;
|
||||||
|
season: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AnimeDetailData {
|
||||||
|
detail: {
|
||||||
|
animeId: number;
|
||||||
|
canonicalTitle: string;
|
||||||
|
anilistId: number | null;
|
||||||
|
titleRomaji: string | null;
|
||||||
|
titleEnglish: string | null;
|
||||||
|
titleNative: string | null;
|
||||||
|
description: string | null;
|
||||||
|
totalSessions: number;
|
||||||
|
totalActiveMs: number;
|
||||||
|
totalCards: number;
|
||||||
|
totalTokensSeen: number;
|
||||||
|
totalLinesSeen: number;
|
||||||
|
totalLookupCount: number;
|
||||||
|
totalLookupHits: number;
|
||||||
|
totalYomitanLookupCount: number;
|
||||||
|
episodeCount: number;
|
||||||
|
lastWatchedMs: number;
|
||||||
|
};
|
||||||
|
episodes: AnimeEpisode[];
|
||||||
|
anilistEntries: AnilistEntry[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AnimeEpisode {
|
||||||
|
videoId: number;
|
||||||
|
episode: number | null;
|
||||||
|
season: number | null;
|
||||||
|
durationMs: number;
|
||||||
|
endedMediaMs: number | null;
|
||||||
|
watched: number;
|
||||||
|
canonicalTitle: string;
|
||||||
|
totalSessions: number;
|
||||||
|
totalActiveMs: number;
|
||||||
|
totalCards: number;
|
||||||
|
totalTokensSeen: number;
|
||||||
|
totalYomitanLookupCount: number;
|
||||||
|
lastWatchedMs: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AnimeWord {
|
||||||
|
wordId: number;
|
||||||
|
headword: string;
|
||||||
|
word: string;
|
||||||
|
reading: string;
|
||||||
|
partOfSpeech: string | null;
|
||||||
|
frequency: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StreakCalendarDay {
|
||||||
|
epochDay: number;
|
||||||
|
totalActiveMin: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EpisodesPerDay {
|
||||||
|
epochDay: number;
|
||||||
|
episodeCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NewAnimePerDay {
|
||||||
|
epochDay: number;
|
||||||
|
newAnimeCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WatchTimePerAnime {
|
||||||
|
epochDay: number;
|
||||||
|
animeId: number;
|
||||||
|
animeTitle: string;
|
||||||
|
totalActiveMin: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TrendChartPoint {
|
||||||
|
label: string;
|
||||||
|
value: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TrendPerAnimePoint {
|
||||||
|
epochDay: number;
|
||||||
|
animeTitle: string;
|
||||||
|
value: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LibrarySummaryRow {
|
||||||
|
title: string;
|
||||||
|
watchTimeMin: number;
|
||||||
|
videos: number;
|
||||||
|
sessions: number;
|
||||||
|
cards: number;
|
||||||
|
words: number;
|
||||||
|
lookups: number;
|
||||||
|
lookupsPerHundred: number | null;
|
||||||
|
firstWatched: number;
|
||||||
|
lastWatched: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TrendsDashboardData {
|
||||||
|
activity: {
|
||||||
|
watchTime: TrendChartPoint[];
|
||||||
|
cards: TrendChartPoint[];
|
||||||
|
words: TrendChartPoint[];
|
||||||
|
sessions: TrendChartPoint[];
|
||||||
|
};
|
||||||
|
progress: {
|
||||||
|
watchTime: TrendChartPoint[];
|
||||||
|
sessions: TrendChartPoint[];
|
||||||
|
words: TrendChartPoint[];
|
||||||
|
newWords: TrendChartPoint[];
|
||||||
|
cards: TrendChartPoint[];
|
||||||
|
episodes: TrendChartPoint[];
|
||||||
|
lookups: TrendChartPoint[];
|
||||||
|
};
|
||||||
|
ratios: {
|
||||||
|
lookupsPerHundred: TrendChartPoint[];
|
||||||
|
cardsPerHour: TrendChartPoint[];
|
||||||
|
readingSpeed: TrendChartPoint[];
|
||||||
|
};
|
||||||
|
librarySummary: LibrarySummaryRow[];
|
||||||
|
animeCumulative: {
|
||||||
|
watchTime: TrendPerAnimePoint[];
|
||||||
|
episodes: TrendPerAnimePoint[];
|
||||||
|
cards: TrendPerAnimePoint[];
|
||||||
|
words: TrendPerAnimePoint[];
|
||||||
|
};
|
||||||
|
patterns: {
|
||||||
|
watchTimeByDayOfWeek: TrendChartPoint[];
|
||||||
|
watchTimeByHour: TrendChartPoint[];
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WordDetailData {
|
||||||
|
detail: {
|
||||||
|
wordId: number;
|
||||||
|
headword: string;
|
||||||
|
word: string;
|
||||||
|
reading: string;
|
||||||
|
partOfSpeech: string | null;
|
||||||
|
pos1: string | null;
|
||||||
|
pos2: string | null;
|
||||||
|
pos3: string | null;
|
||||||
|
frequency: number;
|
||||||
|
firstSeen: number;
|
||||||
|
lastSeen: number;
|
||||||
|
};
|
||||||
|
animeAppearances: Array<{
|
||||||
|
animeId: number;
|
||||||
|
animeTitle: string;
|
||||||
|
occurrenceCount: number;
|
||||||
|
}>;
|
||||||
|
similarWords: Array<{
|
||||||
|
wordId: number;
|
||||||
|
headword: string;
|
||||||
|
word: string;
|
||||||
|
reading: string;
|
||||||
|
frequency: number;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EpisodeCardEvent {
|
||||||
|
eventId: number;
|
||||||
|
sessionId: number;
|
||||||
|
tsMs: number;
|
||||||
|
cardsDelta: number;
|
||||||
|
noteIds: number[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EpisodeDetailData {
|
||||||
|
sessions: SessionSummary[];
|
||||||
|
words: AnimeWord[];
|
||||||
|
cardEvents: EpisodeCardEvent[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface KanjiDetailData {
|
||||||
|
detail: {
|
||||||
|
kanjiId: number;
|
||||||
|
kanji: string;
|
||||||
|
frequency: number;
|
||||||
|
firstSeen: number;
|
||||||
|
lastSeen: number;
|
||||||
|
};
|
||||||
|
animeAppearances: Array<{
|
||||||
|
animeId: number;
|
||||||
|
animeTitle: string;
|
||||||
|
occurrenceCount: number;
|
||||||
|
}>;
|
||||||
|
words: Array<{
|
||||||
|
wordId: number;
|
||||||
|
headword: string;
|
||||||
|
word: string;
|
||||||
|
reading: string;
|
||||||
|
frequency: number;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
|
import { readFile } from 'node:fs/promises';
|
||||||
import test from 'node:test';
|
import test from 'node:test';
|
||||||
import { renderToStaticMarkup } from 'react-dom/server';
|
import { renderToStaticMarkup } from 'react-dom/server';
|
||||||
import { AnimeVisibilityFilter } from './TrendsTab';
|
import { AnimeVisibilityFilter } from './TrendsTab';
|
||||||
@@ -81,7 +82,7 @@ test('AnimeVisibilityFilter keeps the ranking mode selectable even when showing
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('TrendsTab source labels words per minute without reading speed wording', async () => {
|
test('TrendsTab source labels words per minute without reading speed wording', async () => {
|
||||||
const source = await Bun.file(new URL('./TrendsTab.tsx', import.meta.url)).text();
|
const source = await readFile(new URL('./TrendsTab.tsx', import.meta.url), 'utf8');
|
||||||
|
|
||||||
assert.match(source, /title="Words \/ Min"/);
|
assert.match(source, /title="Words \/ Min"/);
|
||||||
assert.doesNotMatch(source, /Reading Speed/);
|
assert.doesNotMatch(source, /Reading Speed/);
|
||||||
|
|||||||
+62
-105
@@ -1,30 +1,17 @@
|
|||||||
import type {
|
import type {
|
||||||
OverviewData,
|
|
||||||
DailyRollup,
|
|
||||||
MonthlyRollup,
|
|
||||||
SessionSummary,
|
|
||||||
SessionTimelinePoint,
|
|
||||||
SessionEvent,
|
|
||||||
VocabularyEntry,
|
|
||||||
SentenceSearchResult,
|
|
||||||
KanjiEntry,
|
|
||||||
VocabularyOccurrenceEntry,
|
|
||||||
MediaLibraryItem,
|
|
||||||
MediaDetailData,
|
|
||||||
AnimeLibraryItem,
|
|
||||||
AnimeDetailData,
|
|
||||||
AnimeWord,
|
|
||||||
StreakCalendarDay,
|
|
||||||
EpisodesPerDay,
|
|
||||||
NewAnimePerDay,
|
|
||||||
WatchTimePerAnime,
|
|
||||||
TrendsDashboardData,
|
|
||||||
WordDetailData,
|
|
||||||
KanjiDetailData,
|
|
||||||
EpisodeDetailData,
|
|
||||||
StatsAnkiNoteInfo,
|
StatsAnkiNoteInfo,
|
||||||
StatsExcludedWord,
|
StatsExcludedWord,
|
||||||
StatsCoverImagesData,
|
StatsCoverImagesData,
|
||||||
|
StatsAnilistAssignment,
|
||||||
|
StatsAnkiNotesInfoRequest,
|
||||||
|
StatsCoverImagesRequest,
|
||||||
|
StatsDeleteSessionsRequest,
|
||||||
|
StatsExcludedWordsRequest,
|
||||||
|
StatsHttpClient,
|
||||||
|
StatsJsonResponseMap,
|
||||||
|
StatsTrendGroupBy,
|
||||||
|
StatsTrendRange,
|
||||||
|
StatsVideoWatchedRequest,
|
||||||
} from '../types/stats';
|
} from '../types/stats';
|
||||||
import type { StatsMineCardParams, StatsMineCardResponse } from './mining';
|
import type { StatsMineCardParams, StatsMineCardResponse } from './mining';
|
||||||
import { appendCoverRetryToken } from './cover-retry';
|
import { appendCoverRetryToken } from './cover-retry';
|
||||||
@@ -64,9 +51,12 @@ async function fetchResponse(path: string, init?: RequestInit): Promise<Response
|
|||||||
return res;
|
return res;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetchJson<T>(path: string): Promise<T> {
|
async function fetchJson<Key extends keyof StatsJsonResponseMap>(
|
||||||
|
_key: Key,
|
||||||
|
path: string,
|
||||||
|
): Promise<StatsJsonResponseMap[Key]> {
|
||||||
const res = await fetchResponse(path);
|
const res = await fetchResponse(path);
|
||||||
return res.json() as Promise<T>;
|
return res.json() as Promise<StatsJsonResponseMap[Key]>;
|
||||||
}
|
}
|
||||||
|
|
||||||
function uniquePositiveIds(ids: number[]): number[] {
|
function uniquePositiveIds(ids: number[]): number[] {
|
||||||
@@ -80,14 +70,15 @@ function uniquePositiveIds(ids: number[]): number[] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const apiClient = {
|
export const apiClient = {
|
||||||
getOverview: () => fetchJson<OverviewData>('/api/stats/overview'),
|
getOverview: () => fetchJson('overview', '/api/stats/overview'),
|
||||||
getDailyRollups: (limit = 60) =>
|
getDailyRollups: (limit = 60) =>
|
||||||
fetchJson<DailyRollup[]>(`/api/stats/daily-rollups?limit=${limit}`),
|
fetchJson('dailyRollups', `/api/stats/daily-rollups?limit=${limit}`),
|
||||||
getMonthlyRollups: (limit = 24) =>
|
getMonthlyRollups: (limit = 24) =>
|
||||||
fetchJson<MonthlyRollup[]>(`/api/stats/monthly-rollups?limit=${limit}`),
|
fetchJson('monthlyRollups', `/api/stats/monthly-rollups?limit=${limit}`),
|
||||||
getSessions: (limit = 50) => fetchJson<SessionSummary[]>(`/api/stats/sessions?limit=${limit}`),
|
getSessions: (limit = 50) => fetchJson('sessions', `/api/stats/sessions?limit=${limit}`),
|
||||||
getSessionTimeline: (id: number, limit?: number) =>
|
getSessionTimeline: (id: number, limit?: number) =>
|
||||||
fetchJson<SessionTimelinePoint[]>(
|
fetchJson(
|
||||||
|
'sessionTimeline',
|
||||||
limit === undefined
|
limit === undefined
|
||||||
? `/api/stats/sessions/${id}/timeline`
|
? `/api/stats/sessions/${id}/timeline`
|
||||||
: `/api/stats/sessions/${id}/timeline?limit=${limit}`,
|
: `/api/stats/sessions/${id}/timeline?limit=${limit}`,
|
||||||
@@ -97,90 +88,84 @@ export const apiClient = {
|
|||||||
if (eventTypes && eventTypes.length > 0) {
|
if (eventTypes && eventTypes.length > 0) {
|
||||||
params.set('types', eventTypes.join(','));
|
params.set('types', eventTypes.join(','));
|
||||||
}
|
}
|
||||||
return fetchJson<SessionEvent[]>(`/api/stats/sessions/${id}/events?${params.toString()}`);
|
return fetchJson('sessionEvents', `/api/stats/sessions/${id}/events?${params.toString()}`);
|
||||||
},
|
},
|
||||||
getSessionKnownWordsTimeline: (id: number) =>
|
getSessionKnownWordsTimeline: (id: number) =>
|
||||||
fetchJson<Array<{ linesSeen: number; knownWordsSeen: number; totalWordsSeen: number }>>(
|
fetchJson('sessionKnownWordsTimeline', `/api/stats/sessions/${id}/known-words-timeline`),
|
||||||
`/api/stats/sessions/${id}/known-words-timeline`,
|
getVocabulary: (limit = 100) => fetchJson('vocabulary', `/api/stats/vocabulary?limit=${limit}`),
|
||||||
),
|
getExcludedWords: () => fetchJson('excludedWords', '/api/stats/excluded-words'),
|
||||||
getVocabulary: (limit = 100) =>
|
|
||||||
fetchJson<VocabularyEntry[]>(`/api/stats/vocabulary?limit=${limit}`),
|
|
||||||
getExcludedWords: () => fetchJson<StatsExcludedWord[]>('/api/stats/excluded-words'),
|
|
||||||
setExcludedWords: async (words: StatsExcludedWord[]): Promise<void> => {
|
setExcludedWords: async (words: StatsExcludedWord[]): Promise<void> => {
|
||||||
await fetchResponse('/api/stats/excluded-words', {
|
await fetchResponse('/api/stats/excluded-words', {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ words }),
|
body: JSON.stringify({ words } satisfies StatsExcludedWordsRequest),
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
getWordOccurrences: (headword: string, word: string, reading: string, limit = 50, offset = 0) =>
|
getWordOccurrences: (headword: string, word: string, reading: string, limit = 50, offset = 0) =>
|
||||||
fetchJson<VocabularyOccurrenceEntry[]>(
|
fetchJson(
|
||||||
|
'wordOccurrences',
|
||||||
`/api/stats/vocabulary/occurrences?headword=${encodeURIComponent(headword)}&word=${encodeURIComponent(word)}&reading=${encodeURIComponent(reading)}&limit=${limit}&offset=${offset}`,
|
`/api/stats/vocabulary/occurrences?headword=${encodeURIComponent(headword)}&word=${encodeURIComponent(word)}&reading=${encodeURIComponent(reading)}&limit=${limit}&offset=${offset}`,
|
||||||
),
|
),
|
||||||
searchSentences: (query: string, limit = 50, searchByHeadword = true) =>
|
searchSentences: (query: string, limit = 50, searchByHeadword = true) =>
|
||||||
fetchJson<SentenceSearchResult[]>(
|
fetchJson(
|
||||||
|
'sentenceSearch',
|
||||||
`/api/stats/sentences/search?${new URLSearchParams({
|
`/api/stats/sentences/search?${new URLSearchParams({
|
||||||
q: query,
|
q: query,
|
||||||
limit: String(limit),
|
limit: String(limit),
|
||||||
headword: String(searchByHeadword),
|
headword: String(searchByHeadword),
|
||||||
}).toString()}`,
|
}).toString()}`,
|
||||||
),
|
),
|
||||||
getKanji: (limit = 100) => fetchJson<KanjiEntry[]>(`/api/stats/kanji?limit=${limit}`),
|
getKanji: (limit = 100) => fetchJson('kanji', `/api/stats/kanji?limit=${limit}`),
|
||||||
getKanjiOccurrences: (kanji: string, limit = 50, offset = 0) =>
|
getKanjiOccurrences: (kanji: string, limit = 50, offset = 0) =>
|
||||||
fetchJson<VocabularyOccurrenceEntry[]>(
|
fetchJson(
|
||||||
|
'kanjiOccurrences',
|
||||||
`/api/stats/kanji/occurrences?kanji=${encodeURIComponent(kanji)}&limit=${limit}&offset=${offset}`,
|
`/api/stats/kanji/occurrences?kanji=${encodeURIComponent(kanji)}&limit=${limit}&offset=${offset}`,
|
||||||
),
|
),
|
||||||
getMediaLibrary: () => fetchJson<MediaLibraryItem[]>('/api/stats/media'),
|
getMediaLibrary: () => fetchJson('mediaLibrary', '/api/stats/media'),
|
||||||
getMediaDetail: (videoId: number) => fetchJson<MediaDetailData>(`/api/stats/media/${videoId}`),
|
getMediaDetail: (videoId: number) => fetchJson('mediaDetail', `/api/stats/media/${videoId}`),
|
||||||
getAnimeLibrary: () => fetchJson<AnimeLibraryItem[]>('/api/stats/anime'),
|
getAnimeLibrary: () => fetchJson('animeLibrary', '/api/stats/anime'),
|
||||||
getAnimeDetail: (animeId: number) => fetchJson<AnimeDetailData>(`/api/stats/anime/${animeId}`),
|
getAnimeDetail: (animeId: number) => fetchJson('animeDetail', `/api/stats/anime/${animeId}`),
|
||||||
getAnimeWords: (animeId: number, limit = 50) =>
|
getAnimeWords: (animeId: number, limit = 50) =>
|
||||||
fetchJson<AnimeWord[]>(`/api/stats/anime/${animeId}/words?limit=${limit}`),
|
fetchJson('animeWords', `/api/stats/anime/${animeId}/words?limit=${limit}`),
|
||||||
getAnimeRollups: (animeId: number, limit = 90) =>
|
getAnimeRollups: (animeId: number, limit = 90) =>
|
||||||
fetchJson<DailyRollup[]>(`/api/stats/anime/${animeId}/rollups?limit=${limit}`),
|
fetchJson('animeRollups', `/api/stats/anime/${animeId}/rollups?limit=${limit}`),
|
||||||
getAnimeCoverUrl: (animeId: number, retryToken = 0) =>
|
getAnimeCoverUrl: (animeId: number, retryToken = 0) =>
|
||||||
appendCoverRetryToken(`${BASE_URL}/api/stats/anime/${animeId}/cover`, retryToken),
|
appendCoverRetryToken(`${BASE_URL}/api/stats/anime/${animeId}/cover`, retryToken),
|
||||||
getCoverImages: async (params: {
|
getCoverImages: async (params: StatsCoverImagesRequest): Promise<StatsCoverImagesData> => {
|
||||||
animeIds: number[];
|
|
||||||
videoIds: number[];
|
|
||||||
}): Promise<StatsCoverImagesData> => {
|
|
||||||
const res = await fetchResponse('/api/stats/covers', {
|
const res = await fetchResponse('/api/stats/covers', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
animeIds: uniquePositiveIds(params.animeIds),
|
animeIds: uniquePositiveIds(params.animeIds),
|
||||||
videoIds: uniquePositiveIds(params.videoIds),
|
videoIds: uniquePositiveIds(params.videoIds),
|
||||||
}),
|
} satisfies StatsCoverImagesRequest),
|
||||||
});
|
});
|
||||||
return res.json() as Promise<StatsCoverImagesData>;
|
return res.json() as Promise<StatsJsonResponseMap['coverImages']>;
|
||||||
},
|
},
|
||||||
getStreakCalendar: (days = 90) =>
|
getStreakCalendar: (days = 90) =>
|
||||||
fetchJson<StreakCalendarDay[]>(`/api/stats/streak-calendar?days=${days}`),
|
fetchJson('streakCalendar', `/api/stats/streak-calendar?days=${days}`),
|
||||||
getEpisodesPerDay: (limit = 90) =>
|
getEpisodesPerDay: (limit = 90) =>
|
||||||
fetchJson<EpisodesPerDay[]>(`/api/stats/trends/episodes-per-day?limit=${limit}`),
|
fetchJson('episodesPerDay', `/api/stats/trends/episodes-per-day?limit=${limit}`),
|
||||||
getNewAnimePerDay: (limit = 90) =>
|
getNewAnimePerDay: (limit = 90) =>
|
||||||
fetchJson<NewAnimePerDay[]>(`/api/stats/trends/new-anime-per-day?limit=${limit}`),
|
fetchJson('newAnimePerDay', `/api/stats/trends/new-anime-per-day?limit=${limit}`),
|
||||||
getWatchTimePerAnime: (limit = 90) =>
|
getWatchTimePerAnime: (limit = 90) =>
|
||||||
fetchJson<WatchTimePerAnime[]>(`/api/stats/trends/watch-time-per-anime?limit=${limit}`),
|
fetchJson('watchTimePerAnime', `/api/stats/trends/watch-time-per-anime?limit=${limit}`),
|
||||||
getTrendsDashboard: (
|
getTrendsDashboard: (range: StatsTrendRange, groupBy: StatsTrendGroupBy, fillEmpty = true) =>
|
||||||
range: '7d' | '30d' | '90d' | '365d' | 'all',
|
fetchJson(
|
||||||
groupBy: 'day' | 'month',
|
'trendsDashboard',
|
||||||
fillEmpty = true,
|
|
||||||
) =>
|
|
||||||
fetchJson<TrendsDashboardData>(
|
|
||||||
`/api/stats/trends/dashboard?range=${encodeURIComponent(range)}&groupBy=${encodeURIComponent(groupBy)}&fillEmpty=${fillEmpty ? 'true' : 'false'}`,
|
`/api/stats/trends/dashboard?range=${encodeURIComponent(range)}&groupBy=${encodeURIComponent(groupBy)}&fillEmpty=${fillEmpty ? 'true' : 'false'}`,
|
||||||
),
|
),
|
||||||
getWordDetail: (wordId: number) =>
|
getWordDetail: (wordId: number) =>
|
||||||
fetchJson<WordDetailData>(`/api/stats/vocabulary/${wordId}/detail`),
|
fetchJson('wordDetail', `/api/stats/vocabulary/${wordId}/detail`),
|
||||||
getKanjiDetail: (kanjiId: number) =>
|
getKanjiDetail: (kanjiId: number) =>
|
||||||
fetchJson<KanjiDetailData>(`/api/stats/kanji/${kanjiId}/detail`),
|
fetchJson('kanjiDetail', `/api/stats/kanji/${kanjiId}/detail`),
|
||||||
getEpisodeDetail: (videoId: number) =>
|
getEpisodeDetail: (videoId: number) =>
|
||||||
fetchJson<EpisodeDetailData>(`/api/stats/episode/${videoId}/detail`),
|
fetchJson('episodeDetail', `/api/stats/episode/${videoId}/detail`),
|
||||||
setVideoWatched: async (videoId: number, watched: boolean): Promise<void> => {
|
setVideoWatched: async (videoId: number, watched: boolean): Promise<void> => {
|
||||||
await fetchResponse(`/api/stats/media/${videoId}/watched`, {
|
await fetchResponse(`/api/stats/media/${videoId}/watched`, {
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ watched }),
|
body: JSON.stringify({ watched } satisfies StatsVideoWatchedRequest),
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
deleteSession: async (sessionId: number): Promise<void> => {
|
deleteSession: async (sessionId: number): Promise<void> => {
|
||||||
@@ -190,49 +175,21 @@ export const apiClient = {
|
|||||||
await fetchResponse('/api/stats/sessions', {
|
await fetchResponse('/api/stats/sessions', {
|
||||||
method: 'DELETE',
|
method: 'DELETE',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ sessionIds }),
|
body: JSON.stringify({ sessionIds } satisfies StatsDeleteSessionsRequest),
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
deleteVideo: async (videoId: number): Promise<void> => {
|
deleteVideo: async (videoId: number): Promise<void> => {
|
||||||
await fetchResponse(`/api/stats/media/${videoId}`, { method: 'DELETE' });
|
await fetchResponse(`/api/stats/media/${videoId}`, { method: 'DELETE' });
|
||||||
},
|
},
|
||||||
getKnownWords: () => fetchJson<string[]>('/api/stats/known-words'),
|
getKnownWords: () => fetchJson('knownWords', '/api/stats/known-words'),
|
||||||
getKnownWordsSummary: () =>
|
getKnownWordsSummary: () => fetchJson('knownWordsSummary', '/api/stats/known-words-summary'),
|
||||||
fetchJson<{ totalUniqueWords: number; knownWordCount: number }>(
|
|
||||||
'/api/stats/known-words-summary',
|
|
||||||
),
|
|
||||||
getAnimeKnownWordsSummary: (animeId: number) =>
|
getAnimeKnownWordsSummary: (animeId: number) =>
|
||||||
fetchJson<{ totalUniqueWords: number; knownWordCount: number }>(
|
fetchJson('animeKnownWordsSummary', `/api/stats/anime/${animeId}/known-words-summary`),
|
||||||
`/api/stats/anime/${animeId}/known-words-summary`,
|
|
||||||
),
|
|
||||||
getMediaKnownWordsSummary: (videoId: number) =>
|
getMediaKnownWordsSummary: (videoId: number) =>
|
||||||
fetchJson<{ totalUniqueWords: number; knownWordCount: number }>(
|
fetchJson('mediaKnownWordsSummary', `/api/stats/media/${videoId}/known-words-summary`),
|
||||||
`/api/stats/media/${videoId}/known-words-summary`,
|
|
||||||
),
|
|
||||||
searchAnilist: (query: string) =>
|
searchAnilist: (query: string) =>
|
||||||
fetchJson<
|
fetchJson('anilistSearch', `/api/stats/anilist/search?q=${encodeURIComponent(query)}`),
|
||||||
Array<{
|
reassignAnimeAnilist: async (animeId: number, info: StatsAnilistAssignment): Promise<void> => {
|
||||||
id: number;
|
|
||||||
episodes: number | null;
|
|
||||||
season: string | null;
|
|
||||||
seasonYear: number | null;
|
|
||||||
description: string | null;
|
|
||||||
coverImage: { large: string | null; medium: string | null } | null;
|
|
||||||
title: { romaji: string | null; english: string | null; native: string | null } | null;
|
|
||||||
}>
|
|
||||||
>(`/api/stats/anilist/search?q=${encodeURIComponent(query)}`),
|
|
||||||
reassignAnimeAnilist: async (
|
|
||||||
animeId: number,
|
|
||||||
info: {
|
|
||||||
anilistId: number;
|
|
||||||
titleRomaji?: string | null;
|
|
||||||
titleEnglish?: string | null;
|
|
||||||
titleNative?: string | null;
|
|
||||||
episodesTotal?: number | null;
|
|
||||||
description?: string | null;
|
|
||||||
coverUrl?: string | null;
|
|
||||||
},
|
|
||||||
): Promise<void> => {
|
|
||||||
await fetchResponse(`/api/stats/anime/${animeId}/anilist`, {
|
await fetchResponse(`/api/stats/anime/${animeId}/anilist`, {
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
@@ -254,9 +211,9 @@ export const apiClient = {
|
|||||||
const res = await fetch(`${BASE_URL}/api/stats/anki/notesInfo`, {
|
const res = await fetch(`${BASE_URL}/api/stats/anki/notesInfo`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ noteIds }),
|
body: JSON.stringify({ noteIds } satisfies StatsAnkiNotesInfoRequest),
|
||||||
});
|
});
|
||||||
if (!res.ok) throw new Error(`Stats API error: ${res.status}`);
|
if (!res.ok) throw new Error(`Stats API error: ${res.status}`);
|
||||||
return res.json();
|
return res.json();
|
||||||
},
|
},
|
||||||
};
|
} satisfies StatsHttpClient;
|
||||||
|
|||||||
@@ -1,112 +0,0 @@
|
|||||||
import type {
|
|
||||||
OverviewData,
|
|
||||||
DailyRollup,
|
|
||||||
MonthlyRollup,
|
|
||||||
SessionSummary,
|
|
||||||
SessionTimelinePoint,
|
|
||||||
SessionEvent,
|
|
||||||
VocabularyEntry,
|
|
||||||
KanjiEntry,
|
|
||||||
VocabularyOccurrenceEntry,
|
|
||||||
MediaLibraryItem,
|
|
||||||
MediaDetailData,
|
|
||||||
AnimeLibraryItem,
|
|
||||||
AnimeDetailData,
|
|
||||||
AnimeWord,
|
|
||||||
StreakCalendarDay,
|
|
||||||
EpisodesPerDay,
|
|
||||||
NewAnimePerDay,
|
|
||||||
WatchTimePerAnime,
|
|
||||||
WordDetailData,
|
|
||||||
KanjiDetailData,
|
|
||||||
EpisodeDetailData,
|
|
||||||
StatsAnkiNoteInfo,
|
|
||||||
} from '../types/stats';
|
|
||||||
|
|
||||||
interface StatsElectronAPI {
|
|
||||||
stats: {
|
|
||||||
getOverview: () => Promise<OverviewData>;
|
|
||||||
getDailyRollups: (limit?: number) => Promise<DailyRollup[]>;
|
|
||||||
getMonthlyRollups: (limit?: number) => Promise<MonthlyRollup[]>;
|
|
||||||
getSessions: (limit?: number) => Promise<SessionSummary[]>;
|
|
||||||
getSessionTimeline: (id: number, limit?: number) => Promise<SessionTimelinePoint[]>;
|
|
||||||
getSessionEvents: (id: number, limit?: number) => Promise<SessionEvent[]>;
|
|
||||||
getVocabulary: (limit?: number) => Promise<VocabularyEntry[]>;
|
|
||||||
getWordOccurrences: (
|
|
||||||
headword: string,
|
|
||||||
word: string,
|
|
||||||
reading: string,
|
|
||||||
limit?: number,
|
|
||||||
offset?: number,
|
|
||||||
) => Promise<VocabularyOccurrenceEntry[]>;
|
|
||||||
getKanji: (limit?: number) => Promise<KanjiEntry[]>;
|
|
||||||
getKanjiOccurrences: (
|
|
||||||
kanji: string,
|
|
||||||
limit?: number,
|
|
||||||
offset?: number,
|
|
||||||
) => Promise<VocabularyOccurrenceEntry[]>;
|
|
||||||
getMediaLibrary: () => Promise<MediaLibraryItem[]>;
|
|
||||||
getMediaDetail: (videoId: number) => Promise<MediaDetailData>;
|
|
||||||
getAnimeLibrary: () => Promise<AnimeLibraryItem[]>;
|
|
||||||
getAnimeDetail: (animeId: number) => Promise<AnimeDetailData>;
|
|
||||||
getAnimeWords: (animeId: number, limit?: number) => Promise<AnimeWord[]>;
|
|
||||||
getAnimeRollups: (animeId: number, limit?: number) => Promise<DailyRollup[]>;
|
|
||||||
getAnimeCoverUrl: (animeId: number) => string;
|
|
||||||
getStreakCalendar: (days?: number) => Promise<StreakCalendarDay[]>;
|
|
||||||
getEpisodesPerDay: (limit?: number) => Promise<EpisodesPerDay[]>;
|
|
||||||
getNewAnimePerDay: (limit?: number) => Promise<NewAnimePerDay[]>;
|
|
||||||
getWatchTimePerAnime: (limit?: number) => Promise<WatchTimePerAnime[]>;
|
|
||||||
getWordDetail: (wordId: number) => Promise<WordDetailData>;
|
|
||||||
getKanjiDetail: (kanjiId: number) => Promise<KanjiDetailData>;
|
|
||||||
getEpisodeDetail: (videoId: number) => Promise<EpisodeDetailData>;
|
|
||||||
ankiBrowse: (noteId: number) => Promise<void>;
|
|
||||||
ankiNotesInfo: (noteIds: number[]) => Promise<StatsAnkiNoteInfo[]>;
|
|
||||||
hideOverlay: () => void;
|
|
||||||
confirmNativeDialog?: (message: string) => boolean;
|
|
||||||
beginNativeDialog?: () => void;
|
|
||||||
endNativeDialog?: () => void;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
declare global {
|
|
||||||
interface Window {
|
|
||||||
electronAPI?: StatsElectronAPI;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function getIpc(): StatsElectronAPI['stats'] {
|
|
||||||
const api = window.electronAPI?.stats;
|
|
||||||
if (!api) throw new Error('Electron IPC not available');
|
|
||||||
return api;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const ipcClient = {
|
|
||||||
getOverview: () => getIpc().getOverview(),
|
|
||||||
getDailyRollups: (limit = 60) => getIpc().getDailyRollups(limit),
|
|
||||||
getMonthlyRollups: (limit = 24) => getIpc().getMonthlyRollups(limit),
|
|
||||||
getSessions: (limit = 50) => getIpc().getSessions(limit),
|
|
||||||
getSessionTimeline: (id: number, limit?: number) => getIpc().getSessionTimeline(id, limit),
|
|
||||||
getSessionEvents: (id: number, limit = 500) => getIpc().getSessionEvents(id, limit),
|
|
||||||
getVocabulary: (limit = 100) => getIpc().getVocabulary(limit),
|
|
||||||
getWordOccurrences: (headword: string, word: string, reading: string, limit = 50, offset = 0) =>
|
|
||||||
getIpc().getWordOccurrences(headword, word, reading, limit, offset),
|
|
||||||
getKanji: (limit = 100) => getIpc().getKanji(limit),
|
|
||||||
getKanjiOccurrences: (kanji: string, limit = 50, offset = 0) =>
|
|
||||||
getIpc().getKanjiOccurrences(kanji, limit, offset),
|
|
||||||
getMediaLibrary: () => getIpc().getMediaLibrary(),
|
|
||||||
getMediaDetail: (videoId: number) => getIpc().getMediaDetail(videoId),
|
|
||||||
getAnimeLibrary: () => getIpc().getAnimeLibrary(),
|
|
||||||
getAnimeDetail: (animeId: number) => getIpc().getAnimeDetail(animeId),
|
|
||||||
getAnimeWords: (animeId: number, limit = 50) => getIpc().getAnimeWords(animeId, limit),
|
|
||||||
getAnimeRollups: (animeId: number, limit = 90) => getIpc().getAnimeRollups(animeId, limit),
|
|
||||||
getAnimeCoverUrl: (animeId: number) => getIpc().getAnimeCoverUrl(animeId),
|
|
||||||
getStreakCalendar: (days = 90) => getIpc().getStreakCalendar(days),
|
|
||||||
getEpisodesPerDay: (limit = 90) => getIpc().getEpisodesPerDay(limit),
|
|
||||||
getNewAnimePerDay: (limit = 90) => getIpc().getNewAnimePerDay(limit),
|
|
||||||
getWatchTimePerAnime: (limit = 90) => getIpc().getWatchTimePerAnime(limit),
|
|
||||||
getWordDetail: (wordId: number) => getIpc().getWordDetail(wordId),
|
|
||||||
getKanjiDetail: (kanjiId: number) => getIpc().getKanjiDetail(kanjiId),
|
|
||||||
getEpisodeDetail: (videoId: number) => getIpc().getEpisodeDetail(videoId),
|
|
||||||
ankiBrowse: (noteId: number) => getIpc().ankiBrowse(noteId),
|
|
||||||
ankiNotesInfo: (noteIds: number[]) => getIpc().ankiNotesInfo(noteIds),
|
|
||||||
};
|
|
||||||
+7
-19
@@ -1,23 +1,11 @@
|
|||||||
import type { SentenceSearchResult } from '../types/stats';
|
import type {
|
||||||
|
SentenceSearchResult,
|
||||||
|
StatsMineCardParams,
|
||||||
|
StatsMineCardResponse,
|
||||||
|
StatsMineMode,
|
||||||
|
} from '../types/stats';
|
||||||
|
|
||||||
export type StatsMineMode = 'word' | 'sentence' | 'audio';
|
export type { StatsMineCardParams, StatsMineCardResponse, StatsMineMode } from '../types/stats';
|
||||||
|
|
||||||
export interface StatsMineCardParams {
|
|
||||||
sourcePath: string;
|
|
||||||
startMs: number;
|
|
||||||
endMs: number;
|
|
||||||
sentence: string;
|
|
||||||
word: string;
|
|
||||||
secondaryText?: string | null;
|
|
||||||
videoTitle: string;
|
|
||||||
mode: StatsMineMode;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface StatsMineCardResponse {
|
|
||||||
noteId?: number;
|
|
||||||
error?: string;
|
|
||||||
errors?: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getStatsMineCardUnavailableReason(
|
export function getStatsMineCardUnavailableReason(
|
||||||
result: Pick<SentenceSearchResult, 'sourcePath' | 'segmentStartMs' | 'segmentEndMs'>,
|
result: Pick<SentenceSearchResult, 'sourcePath' | 'segmentStartMs' | 'segmentEndMs'>,
|
||||||
|
|||||||
+2
-430
@@ -1,430 +1,2 @@
|
|||||||
export interface SessionSummary {
|
export * from '../../../src/types/stats-wire';
|
||||||
sessionId: number;
|
export * from '../../../src/types/stats-http-contract';
|
||||||
canonicalTitle: string | null;
|
|
||||||
videoId: number | null;
|
|
||||||
animeId: number | null;
|
|
||||||
animeTitle: string | null;
|
|
||||||
startedAtMs: number;
|
|
||||||
endedAtMs: number | null;
|
|
||||||
totalWatchedMs: number;
|
|
||||||
activeWatchedMs: number;
|
|
||||||
linesSeen: number;
|
|
||||||
tokensSeen: number;
|
|
||||||
cardsMined: number;
|
|
||||||
lookupCount: number;
|
|
||||||
lookupHits: number;
|
|
||||||
yomitanLookupCount: number;
|
|
||||||
knownWordsSeen: number;
|
|
||||||
knownWordRate: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface DailyRollup {
|
|
||||||
rollupDayOrMonth: number;
|
|
||||||
videoId: number | null;
|
|
||||||
totalSessions: number;
|
|
||||||
totalActiveMin: number;
|
|
||||||
totalLinesSeen: number;
|
|
||||||
totalTokensSeen: number;
|
|
||||||
totalCards: number;
|
|
||||||
cardsPerHour: number | null;
|
|
||||||
tokensPerMin: number | null;
|
|
||||||
lookupHitRate: number | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export type MonthlyRollup = DailyRollup;
|
|
||||||
|
|
||||||
export interface SessionTimelinePoint {
|
|
||||||
sampleMs: number;
|
|
||||||
totalWatchedMs: number;
|
|
||||||
activeWatchedMs: number;
|
|
||||||
linesSeen: number;
|
|
||||||
tokensSeen: number;
|
|
||||||
cardsMined: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface SessionEvent {
|
|
||||||
eventType: EventType;
|
|
||||||
tsMs: number;
|
|
||||||
payload: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface AnkiNotePreview {
|
|
||||||
word: string;
|
|
||||||
sentence: string;
|
|
||||||
translation: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface StatsAnkiNoteInfo {
|
|
||||||
noteId: number;
|
|
||||||
fields: Record<string, { value: string }>;
|
|
||||||
preview?: AnkiNotePreview;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface VocabularyEntry {
|
|
||||||
wordId: number;
|
|
||||||
headword: string;
|
|
||||||
word: string;
|
|
||||||
reading: string;
|
|
||||||
partOfSpeech: string | null;
|
|
||||||
pos1: string | null;
|
|
||||||
pos2: string | null;
|
|
||||||
pos3: string | null;
|
|
||||||
frequency: number;
|
|
||||||
frequencyRank: number | null;
|
|
||||||
animeCount: number;
|
|
||||||
firstSeen: number;
|
|
||||||
lastSeen: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface StatsExcludedWord {
|
|
||||||
headword: string;
|
|
||||||
word: string;
|
|
||||||
reading: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface StatsCoverImage {
|
|
||||||
contentType: string;
|
|
||||||
dataUrl: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface StatsCoverImagesData {
|
|
||||||
anime: Record<number, StatsCoverImage | null>;
|
|
||||||
media: Record<number, StatsCoverImage | null>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface KanjiEntry {
|
|
||||||
kanjiId: number;
|
|
||||||
kanji: string;
|
|
||||||
frequency: number;
|
|
||||||
firstSeen: number;
|
|
||||||
lastSeen: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface VocabularyOccurrenceEntry {
|
|
||||||
animeId: number | null;
|
|
||||||
animeTitle: string | null;
|
|
||||||
videoId: number;
|
|
||||||
videoTitle: string;
|
|
||||||
sourcePath: string | null;
|
|
||||||
secondaryText: string | null;
|
|
||||||
sessionId: number;
|
|
||||||
lineIndex: number;
|
|
||||||
segmentStartMs: number | null;
|
|
||||||
segmentEndMs: number | null;
|
|
||||||
text: string;
|
|
||||||
occurrenceCount: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface SentenceSearchResult {
|
|
||||||
animeId: number | null;
|
|
||||||
animeTitle: string | null;
|
|
||||||
videoId: number;
|
|
||||||
videoTitle: string;
|
|
||||||
sourcePath: string | null;
|
|
||||||
secondaryText: string | null;
|
|
||||||
sessionId: number;
|
|
||||||
lineIndex: number;
|
|
||||||
segmentStartMs: number | null;
|
|
||||||
segmentEndMs: number | null;
|
|
||||||
text: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface OverviewData {
|
|
||||||
sessions: SessionSummary[];
|
|
||||||
rollups: DailyRollup[];
|
|
||||||
hints: {
|
|
||||||
totalSessions: number;
|
|
||||||
activeSessions: number;
|
|
||||||
episodesToday: number;
|
|
||||||
activeAnimeCount: number;
|
|
||||||
totalEpisodesWatched: number;
|
|
||||||
totalAnimeCompleted: number;
|
|
||||||
totalActiveMin: number;
|
|
||||||
activeDays: number;
|
|
||||||
totalCards?: number;
|
|
||||||
totalTokensSeen: number;
|
|
||||||
totalLookupCount: number;
|
|
||||||
totalLookupHits: number;
|
|
||||||
totalYomitanLookupCount: number;
|
|
||||||
newWordsToday: number;
|
|
||||||
newWordsThisWeek: number;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface MediaLibraryItem {
|
|
||||||
videoId: number;
|
|
||||||
canonicalTitle: string;
|
|
||||||
totalSessions: number;
|
|
||||||
totalActiveMs: number;
|
|
||||||
totalCards: number;
|
|
||||||
totalTokensSeen: number;
|
|
||||||
lastWatchedMs: number;
|
|
||||||
hasCoverArt: number;
|
|
||||||
youtubeVideoId?: string | null;
|
|
||||||
videoUrl?: string | null;
|
|
||||||
videoTitle?: string | null;
|
|
||||||
videoThumbnailUrl?: string | null;
|
|
||||||
channelId?: string | null;
|
|
||||||
channelName?: string | null;
|
|
||||||
channelUrl?: string | null;
|
|
||||||
channelThumbnailUrl?: string | null;
|
|
||||||
uploaderId?: string | null;
|
|
||||||
uploaderUrl?: string | null;
|
|
||||||
description?: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface MediaDetailData {
|
|
||||||
detail: {
|
|
||||||
videoId: number;
|
|
||||||
canonicalTitle: string;
|
|
||||||
animeId: number | null;
|
|
||||||
totalSessions: number;
|
|
||||||
totalActiveMs: number;
|
|
||||||
totalCards: number;
|
|
||||||
totalTokensSeen: number;
|
|
||||||
totalLinesSeen: number;
|
|
||||||
totalLookupCount: number;
|
|
||||||
totalLookupHits: number;
|
|
||||||
totalYomitanLookupCount: number;
|
|
||||||
youtubeVideoId?: string | null;
|
|
||||||
videoUrl?: string | null;
|
|
||||||
videoTitle?: string | null;
|
|
||||||
videoThumbnailUrl?: string | null;
|
|
||||||
channelId?: string | null;
|
|
||||||
channelName?: string | null;
|
|
||||||
channelUrl?: string | null;
|
|
||||||
channelThumbnailUrl?: string | null;
|
|
||||||
uploaderId?: string | null;
|
|
||||||
uploaderUrl?: string | null;
|
|
||||||
description?: string | null;
|
|
||||||
} | null;
|
|
||||||
sessions: SessionSummary[];
|
|
||||||
rollups: DailyRollup[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export const EventType = {
|
|
||||||
SUBTITLE_LINE: 1,
|
|
||||||
MEDIA_BUFFER: 2,
|
|
||||||
LOOKUP: 3,
|
|
||||||
CARD_MINED: 4,
|
|
||||||
SEEK_FORWARD: 5,
|
|
||||||
SEEK_BACKWARD: 6,
|
|
||||||
PAUSE_START: 7,
|
|
||||||
PAUSE_END: 8,
|
|
||||||
YOMITAN_LOOKUP: 9,
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
export type EventType = (typeof EventType)[keyof typeof EventType];
|
|
||||||
|
|
||||||
export interface AnimeLibraryItem {
|
|
||||||
animeId: number;
|
|
||||||
canonicalTitle: string;
|
|
||||||
anilistId: number | null;
|
|
||||||
totalSessions: number;
|
|
||||||
totalActiveMs: number;
|
|
||||||
totalCards: number;
|
|
||||||
totalTokensSeen: number;
|
|
||||||
episodeCount: number;
|
|
||||||
episodesTotal: number | null;
|
|
||||||
lastWatchedMs: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface AnilistEntry {
|
|
||||||
anilistId: number;
|
|
||||||
titleRomaji: string | null;
|
|
||||||
titleEnglish: string | null;
|
|
||||||
season: number | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface AnimeDetailData {
|
|
||||||
detail: {
|
|
||||||
animeId: number;
|
|
||||||
canonicalTitle: string;
|
|
||||||
anilistId: number | null;
|
|
||||||
titleRomaji: string | null;
|
|
||||||
titleEnglish: string | null;
|
|
||||||
titleNative: string | null;
|
|
||||||
description: string | null;
|
|
||||||
totalSessions: number;
|
|
||||||
totalActiveMs: number;
|
|
||||||
totalCards: number;
|
|
||||||
totalTokensSeen: number;
|
|
||||||
totalLinesSeen: number;
|
|
||||||
totalLookupCount: number;
|
|
||||||
totalLookupHits: number;
|
|
||||||
totalYomitanLookupCount: number;
|
|
||||||
episodeCount: number;
|
|
||||||
lastWatchedMs: number;
|
|
||||||
};
|
|
||||||
episodes: AnimeEpisode[];
|
|
||||||
anilistEntries: AnilistEntry[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface AnimeEpisode {
|
|
||||||
videoId: number;
|
|
||||||
episode: number | null;
|
|
||||||
season: number | null;
|
|
||||||
durationMs: number;
|
|
||||||
endedMediaMs: number | null;
|
|
||||||
watched: number;
|
|
||||||
canonicalTitle: string;
|
|
||||||
totalSessions: number;
|
|
||||||
totalActiveMs: number;
|
|
||||||
totalCards: number;
|
|
||||||
totalTokensSeen: number;
|
|
||||||
totalYomitanLookupCount: number;
|
|
||||||
lastWatchedMs: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface AnimeWord {
|
|
||||||
wordId: number;
|
|
||||||
headword: string;
|
|
||||||
word: string;
|
|
||||||
reading: string;
|
|
||||||
partOfSpeech: string | null;
|
|
||||||
frequency: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface StreakCalendarDay {
|
|
||||||
epochDay: number;
|
|
||||||
totalActiveMin: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface EpisodesPerDay {
|
|
||||||
epochDay: number;
|
|
||||||
episodeCount: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface NewAnimePerDay {
|
|
||||||
epochDay: number;
|
|
||||||
newAnimeCount: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface WatchTimePerAnime {
|
|
||||||
epochDay: number;
|
|
||||||
animeId: number;
|
|
||||||
animeTitle: string;
|
|
||||||
totalActiveMin: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface TrendChartPoint {
|
|
||||||
label: string;
|
|
||||||
value: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface TrendPerAnimePoint {
|
|
||||||
epochDay: number;
|
|
||||||
animeTitle: string;
|
|
||||||
value: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface LibrarySummaryRow {
|
|
||||||
title: string;
|
|
||||||
watchTimeMin: number;
|
|
||||||
videos: number;
|
|
||||||
sessions: number;
|
|
||||||
cards: number;
|
|
||||||
words: number;
|
|
||||||
lookups: number;
|
|
||||||
lookupsPerHundred: number | null;
|
|
||||||
firstWatched: number;
|
|
||||||
lastWatched: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface TrendsDashboardData {
|
|
||||||
activity: {
|
|
||||||
watchTime: TrendChartPoint[];
|
|
||||||
cards: TrendChartPoint[];
|
|
||||||
words: TrendChartPoint[];
|
|
||||||
sessions: TrendChartPoint[];
|
|
||||||
};
|
|
||||||
progress: {
|
|
||||||
watchTime: TrendChartPoint[];
|
|
||||||
sessions: TrendChartPoint[];
|
|
||||||
words: TrendChartPoint[];
|
|
||||||
newWords: TrendChartPoint[];
|
|
||||||
cards: TrendChartPoint[];
|
|
||||||
episodes: TrendChartPoint[];
|
|
||||||
lookups: TrendChartPoint[];
|
|
||||||
};
|
|
||||||
ratios: {
|
|
||||||
lookupsPerHundred: TrendChartPoint[];
|
|
||||||
cardsPerHour: TrendChartPoint[];
|
|
||||||
readingSpeed: TrendChartPoint[];
|
|
||||||
};
|
|
||||||
librarySummary: LibrarySummaryRow[];
|
|
||||||
animeCumulative: {
|
|
||||||
watchTime: TrendPerAnimePoint[];
|
|
||||||
episodes: TrendPerAnimePoint[];
|
|
||||||
cards: TrendPerAnimePoint[];
|
|
||||||
words: TrendPerAnimePoint[];
|
|
||||||
};
|
|
||||||
patterns: {
|
|
||||||
watchTimeByDayOfWeek: TrendChartPoint[];
|
|
||||||
watchTimeByHour: TrendChartPoint[];
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface WordDetailData {
|
|
||||||
detail: {
|
|
||||||
wordId: number;
|
|
||||||
headword: string;
|
|
||||||
word: string;
|
|
||||||
reading: string;
|
|
||||||
partOfSpeech: string | null;
|
|
||||||
pos1: string | null;
|
|
||||||
pos2: string | null;
|
|
||||||
pos3: string | null;
|
|
||||||
frequency: number;
|
|
||||||
firstSeen: number;
|
|
||||||
lastSeen: number;
|
|
||||||
};
|
|
||||||
animeAppearances: Array<{
|
|
||||||
animeId: number;
|
|
||||||
animeTitle: string;
|
|
||||||
occurrenceCount: number;
|
|
||||||
}>;
|
|
||||||
similarWords: Array<{
|
|
||||||
wordId: number;
|
|
||||||
headword: string;
|
|
||||||
word: string;
|
|
||||||
reading: string;
|
|
||||||
frequency: number;
|
|
||||||
}>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface EpisodeCardEvent {
|
|
||||||
eventId: number;
|
|
||||||
sessionId: number;
|
|
||||||
tsMs: number;
|
|
||||||
cardsDelta: number;
|
|
||||||
noteIds: number[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface EpisodeDetailData {
|
|
||||||
sessions: SessionSummary[];
|
|
||||||
words: AnimeWord[];
|
|
||||||
cardEvents: EpisodeCardEvent[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface KanjiDetailData {
|
|
||||||
detail: {
|
|
||||||
kanjiId: number;
|
|
||||||
kanji: string;
|
|
||||||
frequency: number;
|
|
||||||
firstSeen: number;
|
|
||||||
lastSeen: number;
|
|
||||||
};
|
|
||||||
animeAppearances: Array<{
|
|
||||||
animeId: number;
|
|
||||||
animeTitle: string;
|
|
||||||
occurrenceCount: number;
|
|
||||||
}>;
|
|
||||||
words: Array<{
|
|
||||||
wordId: number;
|
|
||||||
headword: string;
|
|
||||||
word: string;
|
|
||||||
reading: string;
|
|
||||||
frequency: number;
|
|
||||||
}>;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -9,7 +9,6 @@
|
|||||||
"esModuleInterop": true,
|
"esModuleInterop": true,
|
||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
"outDir": "dist",
|
"outDir": "dist",
|
||||||
"rootDir": "src",
|
|
||||||
"baseUrl": ".",
|
"baseUrl": ".",
|
||||||
"paths": {
|
"paths": {
|
||||||
"@/*": ["src/*"]
|
"@/*": ["src/*"]
|
||||||
|
|||||||
Reference in New Issue
Block a user