mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-07-29 19:21:33 -07:00
feat: show mining frame toast on OSD and speed up stats session deletes
- Flash mined-frame screenshot as in-overlay image toast for OSD card notifications - Batch cover art from stored DB blobs via POST /api/stats/covers (no extra AniList fetches) - Show delete progress toast on stats home and sessions pages - Refresh only affected rollups on session delete instead of full rebuild - Rebuild lifetime summaries with aggregate SQL CTEs instead of per-session loop
This commit is contained in:
@@ -0,0 +1,4 @@
|
||||
type: fixed
|
||||
area: stats
|
||||
|
||||
- Retried and batched stats home cover images from stored DB art so AniList art appears without extra AniList lookups or a full page refresh.
|
||||
@@ -0,0 +1,4 @@
|
||||
type: added
|
||||
area: overlay
|
||||
|
||||
- Show a screenshot of the mined frame as an in-overlay image toast alongside the OSD card-mining notification, matching the picture already shown on system notifications.
|
||||
@@ -0,0 +1,4 @@
|
||||
type: added
|
||||
area: stats
|
||||
|
||||
- Showed a progress indicator while sessions are being deleted on the home and sessions pages, so long batch deletes give visible feedback instead of appearing to do nothing.
|
||||
@@ -0,0 +1,4 @@
|
||||
type: fixed
|
||||
area: stats
|
||||
|
||||
- Sped up deleting stats sessions by refreshing only affected rollups and rebuilding lifetime summaries with aggregate SQL.
|
||||
@@ -220,6 +220,8 @@ Animated AVIF requires an AV1 encoder (`libaom-av1`, `libsvtav1`, or `librav1e`)
|
||||
}
|
||||
```
|
||||
|
||||
`notificationType` controls how a mined-card confirmation is shown: `osd` draws an on-screen message over the video, `system` posts a desktop notification, `both` does both, and `none` stays silent. The `osd` (and `both`) path also flashes a small screenshot of the mined frame in the overlay, matching the picture shown on the system notification.
|
||||
|
||||
`overwriteAudio` applies to automatic card updates and duplicate-card enrichment. Manual clipboard subtitle updates (`Ctrl/Cmd+C`, then `Ctrl/Cmd+V`) always replace generated sentence audio, while leaving the word audio field unchanged.
|
||||
|
||||
## AI Translation
|
||||
|
||||
@@ -406,6 +406,47 @@ test('AnkiIntegration marks partial update notifications as failures in OSD mode
|
||||
assert.deepEqual(osdMessages, ['x Updated card: taberu (image failed)']);
|
||||
});
|
||||
|
||||
test('AnkiIntegration emits a mining image data URL to the overlay for OSD notifications', async () => {
|
||||
const osdMessages: string[] = [];
|
||||
const overlayImages: string[] = [];
|
||||
const integration = new AnkiIntegration(
|
||||
{
|
||||
behavior: {
|
||||
notificationType: 'osd',
|
||||
},
|
||||
},
|
||||
{} as never,
|
||||
{ currentVideoPath: '/tmp/video.mkv', currentTimePos: 12 } as never,
|
||||
(text) => {
|
||||
osdMessages.push(text);
|
||||
},
|
||||
);
|
||||
integration.setMiningImageOverlayCallback((image) => {
|
||||
overlayImages.push(image);
|
||||
});
|
||||
|
||||
const internals = integration as unknown as {
|
||||
mediaGenerator: {
|
||||
generateNotificationIcon: (path: string, timestamp: number) => Promise<Buffer>;
|
||||
};
|
||||
showNotification: (
|
||||
noteId: number,
|
||||
label: string | number,
|
||||
errorSuffix?: string,
|
||||
) => Promise<void>;
|
||||
};
|
||||
internals.mediaGenerator = {
|
||||
generateNotificationIcon: async () => Buffer.from('frame-bytes'),
|
||||
} as never;
|
||||
|
||||
await internals.showNotification(7, 'taberu');
|
||||
|
||||
assert.deepEqual(osdMessages, ['✓ Updated card: taberu']);
|
||||
assert.deepEqual(overlayImages, [
|
||||
`data:image/png;base64,${Buffer.from('frame-bytes').toString('base64')}`,
|
||||
]);
|
||||
});
|
||||
|
||||
test('FieldGroupingMergeCollaborator keeps SentenceAudio grouped without overwriting ExpressionAudio', async () => {
|
||||
const collaborator = createFieldGroupingMergeCollaborator();
|
||||
|
||||
|
||||
+44
-25
@@ -148,6 +148,7 @@ export class AnkiIntegration {
|
||||
private runtime: AnkiIntegrationRuntime;
|
||||
private aiConfig: AiConfig;
|
||||
private recordCardsMinedCallback: ((count: number, noteIds?: number[]) => void) | null = null;
|
||||
private miningImageOverlayCallback: ((image: string) => void) | null = null;
|
||||
private knownWordCacheUpdatedCallback: (() => void) | null = null;
|
||||
private consumeSubtitleMiningContextCallback: (() => SubtitleMiningContext | null) | null = null;
|
||||
private noteIdRedirects = new Map<number, number>();
|
||||
@@ -1018,40 +1019,54 @@ export class AnkiIntegration {
|
||||
: `Updated card: ${label}`;
|
||||
|
||||
const type = this.config.behavior?.notificationType || 'osd';
|
||||
const wantsOsd = type === 'osd' || type === 'both';
|
||||
const wantsSystem =
|
||||
(type === 'system' || type === 'both') && this.notificationCallback !== null;
|
||||
const wantsOverlayImage = wantsOsd && this.miningImageOverlayCallback !== null;
|
||||
|
||||
if (type === 'osd' || type === 'both') {
|
||||
if (wantsOsd) {
|
||||
this.showUpdateResult(message, errorSuffix === undefined);
|
||||
} else {
|
||||
this.clearUpdateProgress();
|
||||
}
|
||||
|
||||
if ((type === 'system' || type === 'both') && this.notificationCallback) {
|
||||
let notificationIconPath: string | undefined;
|
||||
if (!wantsSystem && !wantsOverlayImage) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.mpvClient && this.mpvClient.currentVideoPath) {
|
||||
try {
|
||||
const timestamp = this.mpvClient.currentTimePos || 0;
|
||||
const notificationIconSource = await resolveMediaGenerationInputPath(
|
||||
this.mpvClient,
|
||||
'video',
|
||||
);
|
||||
if (!notificationIconSource) {
|
||||
throw new Error('No media source available for notification icon');
|
||||
}
|
||||
const iconBuffer = await this.mediaGenerator.generateNotificationIcon(
|
||||
notificationIconSource,
|
||||
timestamp,
|
||||
);
|
||||
if (iconBuffer && iconBuffer.length > 0) {
|
||||
notificationIconPath = this.mediaGenerator.writeNotificationIconToFile(
|
||||
iconBuffer,
|
||||
noteId,
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
log.warn('Failed to generate notification icon:', (err as Error).message);
|
||||
// Generate the frame screenshot once and reuse it for the system notification
|
||||
// icon and the in-overlay image toast.
|
||||
let iconBuffer: Buffer | undefined;
|
||||
if (this.mpvClient && this.mpvClient.currentVideoPath) {
|
||||
try {
|
||||
const timestamp = this.mpvClient.currentTimePos || 0;
|
||||
const notificationIconSource = await resolveMediaGenerationInputPath(
|
||||
this.mpvClient,
|
||||
'video',
|
||||
);
|
||||
if (!notificationIconSource) {
|
||||
throw new Error('No media source available for notification icon');
|
||||
}
|
||||
const buffer = await this.mediaGenerator.generateNotificationIcon(
|
||||
notificationIconSource,
|
||||
timestamp,
|
||||
);
|
||||
if (buffer && buffer.length > 0) {
|
||||
iconBuffer = buffer;
|
||||
}
|
||||
} catch (err) {
|
||||
log.warn('Failed to generate notification icon:', (err as Error).message);
|
||||
}
|
||||
}
|
||||
|
||||
if (wantsOverlayImage && iconBuffer && this.miningImageOverlayCallback) {
|
||||
this.miningImageOverlayCallback(`data:image/png;base64,${iconBuffer.toString('base64')}`);
|
||||
}
|
||||
|
||||
if (wantsSystem && this.notificationCallback) {
|
||||
const notificationIconPath = iconBuffer
|
||||
? this.mediaGenerator.writeNotificationIconToFile(iconBuffer, noteId)
|
||||
: undefined;
|
||||
|
||||
this.notificationCallback('Anki Card Updated', {
|
||||
body: message,
|
||||
@@ -1364,6 +1379,10 @@ export class AnkiIntegration {
|
||||
this.knownWordCacheUpdatedCallback = callback;
|
||||
}
|
||||
|
||||
setMiningImageOverlayCallback(callback: ((image: string) => void) | null): void {
|
||||
this.miningImageOverlayCallback = callback;
|
||||
}
|
||||
|
||||
setSubtitleMiningContextConsumer(callback: (() => SubtitleMiningContext | null) | null): void {
|
||||
this.consumeSubtitleMiningContextCallback = callback;
|
||||
}
|
||||
|
||||
@@ -833,6 +833,56 @@ describe('stats server API routes', () => {
|
||||
assert.equal(res.status, 404);
|
||||
});
|
||||
|
||||
it('POST /api/stats/covers batches stored cover art without fetching missing art', async () => {
|
||||
let ensureCoverArtCalls = 0;
|
||||
const app = createStatsApp(
|
||||
createMockTracker({
|
||||
getCoverArt: async (videoId: number) =>
|
||||
videoId === 7
|
||||
? {
|
||||
videoId,
|
||||
anilistId: null,
|
||||
coverUrl: null,
|
||||
coverBlob: Buffer.from([0x89, 0x50]),
|
||||
titleRomaji: null,
|
||||
titleEnglish: null,
|
||||
episodesTotal: null,
|
||||
fetchedAtMs: Date.now(),
|
||||
}
|
||||
: null,
|
||||
ensureCoverArt: async () => {
|
||||
ensureCoverArtCalls += 1;
|
||||
return true;
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const res = await app.request('/api/stats/covers', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ animeIds: [1, 99999], videoIds: [7, 99999] }),
|
||||
});
|
||||
|
||||
assert.equal(res.status, 200);
|
||||
assert.deepEqual(await res.json(), {
|
||||
anime: {
|
||||
1: {
|
||||
contentType: 'image/jpeg',
|
||||
dataUrl: 'data:image/jpeg;base64,/9j/2Q==',
|
||||
},
|
||||
99999: null,
|
||||
},
|
||||
media: {
|
||||
7: {
|
||||
contentType: 'image/jpeg',
|
||||
dataUrl: 'data:image/jpeg;base64,iVA=',
|
||||
},
|
||||
99999: null,
|
||||
},
|
||||
});
|
||||
assert.equal(ensureCoverArtCalls, 0);
|
||||
});
|
||||
|
||||
it('GET /api/stats/anime/:animeId/words returns top words for an anime', async () => {
|
||||
let seenArgs: unknown[] = [];
|
||||
const app = createStatsApp(
|
||||
|
||||
@@ -605,6 +605,79 @@ test('split maintenance helpers update anime metadata and watched state', () =>
|
||||
}
|
||||
});
|
||||
|
||||
test('deleteSessions refreshes only rollups affected by deleted sessions', () => {
|
||||
const { db, dbPath } = createDb();
|
||||
|
||||
try {
|
||||
const keepVideoId = getOrCreateVideoRecord(db, 'local:/tmp/rollup-keep.mkv', {
|
||||
canonicalTitle: 'Rollup Keep',
|
||||
sourcePath: '/tmp/rollup-keep.mkv',
|
||||
sourceUrl: null,
|
||||
sourceType: SOURCE_TYPE_LOCAL,
|
||||
});
|
||||
const dropVideoId = getOrCreateVideoRecord(db, 'local:/tmp/rollup-drop.mkv', {
|
||||
canonicalTitle: 'Rollup Drop',
|
||||
sourcePath: '/tmp/rollup-drop.mkv',
|
||||
sourceUrl: null,
|
||||
sourceType: SOURCE_TYPE_LOCAL,
|
||||
});
|
||||
|
||||
const keepStartedAtMs = 1_700_000_000_000;
|
||||
const dropStartedAtMs = 1_700_086_400_000;
|
||||
const keepSessionId = startSessionRecord(db, keepVideoId, keepStartedAtMs).sessionId;
|
||||
const dropSessionId = startSessionRecord(db, dropVideoId, dropStartedAtMs).sessionId;
|
||||
finalizeSessionMetrics(db, keepSessionId, keepStartedAtMs, {
|
||||
activeWatchedMs: 30_000,
|
||||
cardsMined: 1,
|
||||
});
|
||||
finalizeSessionMetrics(db, dropSessionId, dropStartedAtMs, {
|
||||
activeWatchedMs: 60_000,
|
||||
cardsMined: 2,
|
||||
});
|
||||
|
||||
const keepDay = getLocalEpochDay(db, keepStartedAtMs);
|
||||
const dropDay = getLocalEpochDay(db, dropStartedAtMs);
|
||||
const keepMonth = 202311;
|
||||
const dropMonth = 202311;
|
||||
|
||||
const insertDaily = db.prepare(`
|
||||
INSERT INTO imm_daily_rollups (
|
||||
rollup_day, video_id, total_sessions, total_active_min, total_lines_seen,
|
||||
total_tokens_seen, total_cards, CREATED_DATE, LAST_UPDATE_DATE
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`);
|
||||
const insertMonthly = db.prepare(`
|
||||
INSERT INTO imm_monthly_rollups (
|
||||
rollup_month, video_id, total_sessions, total_active_min, total_lines_seen,
|
||||
total_tokens_seen, total_cards, CREATED_DATE, LAST_UPDATE_DATE
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`);
|
||||
insertDaily.run(keepDay, keepVideoId, 1, 0.5, 3, 6, 1, keepStartedAtMs, keepStartedAtMs);
|
||||
insertDaily.run(dropDay, dropVideoId, 1, 1, 3, 6, 2, dropStartedAtMs, dropStartedAtMs);
|
||||
insertMonthly.run(keepMonth, keepVideoId, 1, 0.5, 3, 6, 1, keepStartedAtMs, keepStartedAtMs);
|
||||
insertMonthly.run(dropMonth, dropVideoId, 1, 1, 3, 6, 2, dropStartedAtMs, dropStartedAtMs);
|
||||
|
||||
deleteSessions(db, [dropSessionId]);
|
||||
|
||||
const dailyRows = db
|
||||
.prepare('SELECT rollup_day, video_id, total_cards FROM imm_daily_rollups ORDER BY video_id')
|
||||
.all() as Array<{ rollup_day: number; video_id: number; total_cards: number }>;
|
||||
const monthlyRows = db
|
||||
.prepare(
|
||||
'SELECT rollup_month, video_id, total_cards FROM imm_monthly_rollups ORDER BY video_id',
|
||||
)
|
||||
.all() as Array<{ rollup_month: number; video_id: number; total_cards: number }>;
|
||||
|
||||
assert.deepEqual(dailyRows, [{ rollup_day: keepDay, video_id: keepVideoId, total_cards: 1 }]);
|
||||
assert.deepEqual(monthlyRows, [
|
||||
{ rollup_month: keepMonth, video_id: keepVideoId, total_cards: 1 },
|
||||
]);
|
||||
} finally {
|
||||
db.close();
|
||||
cleanupDbPath(dbPath);
|
||||
}
|
||||
});
|
||||
|
||||
test('split maintenance helpers delete multiple sessions and whole videos with dependent rows', () => {
|
||||
const { db, dbPath, stmts } = createDb();
|
||||
|
||||
|
||||
@@ -60,6 +60,34 @@ interface RetainedSessionRow {
|
||||
mediaBufferEvents: number;
|
||||
}
|
||||
|
||||
const RETAINED_SESSION_METRICS_CTE = `
|
||||
retained_sessions AS (
|
||||
SELECT
|
||||
s.session_id,
|
||||
s.video_id,
|
||||
v.anime_id,
|
||||
s.started_at_ms,
|
||||
s.ended_at_ms,
|
||||
MAX(COALESCE(t.active_watched_ms, s.active_watched_ms, 0), 0) AS active_ms,
|
||||
MAX(COALESCE(t.cards_mined, s.cards_mined, 0), 0) AS cards_mined,
|
||||
MAX(COALESCE(t.lines_seen, s.lines_seen, 0), 0) AS lines_seen,
|
||||
MAX(COALESCE(t.tokens_seen, s.tokens_seen, 0), 0) AS tokens_seen,
|
||||
CASE WHEN v.watched > 0 THEN 1 ELSE 0 END AS completed
|
||||
FROM imm_sessions s
|
||||
JOIN imm_videos v
|
||||
ON v.video_id = s.video_id
|
||||
LEFT JOIN imm_session_telemetry t
|
||||
ON t.telemetry_id = (
|
||||
SELECT telemetry_id
|
||||
FROM imm_session_telemetry
|
||||
WHERE session_id = s.session_id
|
||||
ORDER BY sample_ms DESC, telemetry_id DESC
|
||||
LIMIT 1
|
||||
)
|
||||
WHERE s.ended_at_ms IS NOT NULL
|
||||
)
|
||||
`;
|
||||
|
||||
function hasRetainedPriorSession(
|
||||
db: DatabaseSync,
|
||||
videoId: number,
|
||||
@@ -154,54 +182,150 @@ function rebuildLifetimeSummariesInternal(
|
||||
db: DatabaseSync,
|
||||
rebuiltAtMs: number,
|
||||
): LifetimeRebuildSummary {
|
||||
const rows = db
|
||||
.prepare(
|
||||
`
|
||||
SELECT
|
||||
session_id AS sessionId,
|
||||
video_id AS videoId,
|
||||
started_at_ms AS startedAtMs,
|
||||
ended_at_ms AS endedAtMs,
|
||||
ended_media_ms AS lastMediaMs,
|
||||
total_watched_ms AS totalWatchedMs,
|
||||
active_watched_ms AS activeWatchedMs,
|
||||
lines_seen AS linesSeen,
|
||||
tokens_seen AS tokensSeen,
|
||||
cards_mined AS cardsMined,
|
||||
lookup_count AS lookupCount,
|
||||
lookup_hits AS lookupHits,
|
||||
yomitan_lookup_count AS yomitanLookupCount,
|
||||
pause_count AS pauseCount,
|
||||
pause_ms AS pauseMs,
|
||||
seek_forward_count AS seekForwardCount,
|
||||
seek_backward_count AS seekBackwardCount,
|
||||
media_buffer_events AS mediaBufferEvents
|
||||
FROM imm_sessions
|
||||
WHERE ended_at_ms IS NOT NULL
|
||||
ORDER BY started_at_ms ASC, session_id ASC
|
||||
`,
|
||||
)
|
||||
.all() as Array<
|
||||
Omit<RetainedSessionRow, 'startedAtMs' | 'endedAtMs' | 'lastMediaMs'> & {
|
||||
startedAtMs: number | string;
|
||||
endedAtMs: number | string;
|
||||
lastMediaMs: number | string | null;
|
||||
}
|
||||
>;
|
||||
const sessions = rows.map((row) => ({
|
||||
...row,
|
||||
startedAtMs: row.startedAtMs,
|
||||
endedAtMs: row.endedAtMs,
|
||||
lastMediaMs: row.lastMediaMs === null ? null : Number(row.lastMediaMs),
|
||||
})) as RetainedSessionRow[];
|
||||
const rebuiltAtDbMs = toDbTimestamp(rebuiltAtMs);
|
||||
const appliedSessions = Number(
|
||||
(
|
||||
db
|
||||
.prepare('SELECT COUNT(*) AS total FROM imm_sessions WHERE ended_at_ms IS NOT NULL')
|
||||
.get() as { total: number }
|
||||
).total,
|
||||
);
|
||||
|
||||
resetLifetimeSummaries(db, rebuiltAtMs);
|
||||
for (const session of sessions) {
|
||||
applySessionLifetimeSummary(db, toRebuildSessionState(session), session.endedAtMs);
|
||||
}
|
||||
|
||||
db.prepare(
|
||||
`
|
||||
INSERT INTO imm_lifetime_applied_sessions (
|
||||
session_id,
|
||||
applied_at_ms,
|
||||
CREATED_DATE,
|
||||
LAST_UPDATE_DATE
|
||||
)
|
||||
SELECT
|
||||
session_id,
|
||||
ended_at_ms,
|
||||
?,
|
||||
?
|
||||
FROM imm_sessions
|
||||
WHERE ended_at_ms IS NOT NULL
|
||||
`,
|
||||
).run(rebuiltAtDbMs, rebuiltAtDbMs);
|
||||
|
||||
db.prepare(
|
||||
`
|
||||
WITH ${RETAINED_SESSION_METRICS_CTE}
|
||||
INSERT INTO imm_lifetime_media (
|
||||
video_id,
|
||||
total_sessions,
|
||||
total_active_ms,
|
||||
total_cards,
|
||||
total_lines_seen,
|
||||
total_tokens_seen,
|
||||
completed,
|
||||
first_watched_ms,
|
||||
last_watched_ms,
|
||||
CREATED_DATE,
|
||||
LAST_UPDATE_DATE
|
||||
)
|
||||
SELECT
|
||||
video_id,
|
||||
COUNT(*) AS total_sessions,
|
||||
COALESCE(SUM(active_ms), 0) AS total_active_ms,
|
||||
COALESCE(SUM(cards_mined), 0) AS total_cards,
|
||||
COALESCE(SUM(lines_seen), 0) AS total_lines_seen,
|
||||
COALESCE(SUM(tokens_seen), 0) AS total_tokens_seen,
|
||||
MAX(completed) AS completed,
|
||||
MIN(started_at_ms) AS first_watched_ms,
|
||||
MAX(ended_at_ms) AS last_watched_ms,
|
||||
? AS CREATED_DATE,
|
||||
? AS LAST_UPDATE_DATE
|
||||
FROM retained_sessions
|
||||
GROUP BY video_id
|
||||
`,
|
||||
).run(rebuiltAtDbMs, rebuiltAtDbMs);
|
||||
|
||||
db.prepare(
|
||||
`
|
||||
WITH ${RETAINED_SESSION_METRICS_CTE}
|
||||
INSERT INTO imm_lifetime_anime (
|
||||
anime_id,
|
||||
total_sessions,
|
||||
total_active_ms,
|
||||
total_cards,
|
||||
total_lines_seen,
|
||||
total_tokens_seen,
|
||||
episodes_started,
|
||||
episodes_completed,
|
||||
first_watched_ms,
|
||||
last_watched_ms,
|
||||
CREATED_DATE,
|
||||
LAST_UPDATE_DATE
|
||||
)
|
||||
SELECT
|
||||
anime_id,
|
||||
COUNT(*) AS total_sessions,
|
||||
COALESCE(SUM(active_ms), 0) AS total_active_ms,
|
||||
COALESCE(SUM(cards_mined), 0) AS total_cards,
|
||||
COALESCE(SUM(lines_seen), 0) AS total_lines_seen,
|
||||
COALESCE(SUM(tokens_seen), 0) AS total_tokens_seen,
|
||||
COUNT(DISTINCT video_id) AS episodes_started,
|
||||
COUNT(DISTINCT CASE WHEN completed > 0 THEN video_id END) AS episodes_completed,
|
||||
MIN(started_at_ms) AS first_watched_ms,
|
||||
MAX(ended_at_ms) AS last_watched_ms,
|
||||
? AS CREATED_DATE,
|
||||
? AS LAST_UPDATE_DATE
|
||||
FROM retained_sessions
|
||||
WHERE anime_id IS NOT NULL
|
||||
GROUP BY anime_id
|
||||
`,
|
||||
).run(rebuiltAtDbMs, rebuiltAtDbMs);
|
||||
|
||||
db.prepare(
|
||||
`
|
||||
WITH ${RETAINED_SESSION_METRICS_CTE},
|
||||
anime_completion AS (
|
||||
SELECT
|
||||
rs.anime_id,
|
||||
MAX(a.episodes_total) AS episodes_total,
|
||||
COUNT(DISTINCT CASE WHEN rs.completed > 0 THEN rs.video_id END) AS completed_videos
|
||||
FROM retained_sessions rs
|
||||
JOIN imm_anime a
|
||||
ON a.anime_id = rs.anime_id
|
||||
WHERE rs.anime_id IS NOT NULL
|
||||
GROUP BY rs.anime_id
|
||||
)
|
||||
UPDATE imm_lifetime_global
|
||||
SET
|
||||
total_sessions = (SELECT COUNT(*) FROM retained_sessions),
|
||||
total_active_ms = (SELECT COALESCE(SUM(active_ms), 0) FROM retained_sessions),
|
||||
total_cards = (SELECT COALESCE(SUM(cards_mined), 0) FROM retained_sessions),
|
||||
active_days = (
|
||||
SELECT COUNT(DISTINCT CAST(
|
||||
julianday(CAST(started_at_ms AS REAL) / 1000, 'unixepoch', 'localtime') - 2440587.5
|
||||
AS INTEGER
|
||||
))
|
||||
FROM retained_sessions
|
||||
),
|
||||
episodes_started = (SELECT COUNT(DISTINCT video_id) FROM retained_sessions),
|
||||
episodes_completed = (
|
||||
SELECT COUNT(DISTINCT CASE WHEN completed > 0 THEN video_id END)
|
||||
FROM retained_sessions
|
||||
),
|
||||
anime_completed = (
|
||||
SELECT COUNT(*)
|
||||
FROM anime_completion
|
||||
WHERE episodes_total IS NOT NULL
|
||||
AND episodes_total > 0
|
||||
AND completed_videos >= episodes_total
|
||||
),
|
||||
last_rebuilt_ms = ?,
|
||||
LAST_UPDATE_DATE = ?
|
||||
WHERE global_id = 1
|
||||
`,
|
||||
).run(rebuiltAtDbMs, rebuiltAtDbMs);
|
||||
|
||||
return {
|
||||
appliedSessions: sessions.length,
|
||||
appliedSessions,
|
||||
rebuiltAtMs,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { DatabaseSync } from './sqlite';
|
||||
import { nowMs } from './time';
|
||||
import { subtractDbTimestamp, toDbTimestamp } from './query-shared';
|
||||
import { makePlaceholders, subtractDbTimestamp, toDbTimestamp } from './query-shared';
|
||||
|
||||
const ROLLUP_STATE_KEY = 'last_rollup_sample_ms';
|
||||
const DAILY_MS = 86_400_000;
|
||||
@@ -20,6 +20,12 @@ interface RollupTelemetryResult {
|
||||
maxSampleMs: number | null;
|
||||
}
|
||||
|
||||
export interface RollupGroup {
|
||||
rollupDay: number;
|
||||
rollupMonth: number;
|
||||
videoId: number;
|
||||
}
|
||||
|
||||
interface RawRetentionResult {
|
||||
deletedSessionEvents: number;
|
||||
deletedTelemetryRows: number;
|
||||
@@ -164,6 +170,26 @@ function upsertDailyRollupsForGroups(
|
||||
}
|
||||
|
||||
const upsertStmt = db.prepare(`
|
||||
WITH matching_sessions AS (
|
||||
SELECT *
|
||||
FROM imm_sessions
|
||||
WHERE CAST(julianday(CAST(started_at_ms AS REAL) / 1000, 'unixepoch', 'localtime') - 2440587.5 AS INTEGER) = ?
|
||||
AND video_id = ?
|
||||
),
|
||||
session_metrics AS (
|
||||
SELECT
|
||||
t.session_id,
|
||||
MAX(t.active_watched_ms) AS max_active_ms,
|
||||
MAX(t.lines_seen) AS max_lines,
|
||||
MAX(t.tokens_seen) AS max_tokens,
|
||||
MAX(t.cards_mined) AS max_cards,
|
||||
MAX(t.lookup_count) AS max_lookups,
|
||||
MAX(t.lookup_hits) AS max_hits
|
||||
FROM imm_session_telemetry t
|
||||
JOIN matching_sessions s
|
||||
ON s.session_id = t.session_id
|
||||
GROUP BY t.session_id
|
||||
)
|
||||
INSERT INTO imm_daily_rollups (
|
||||
rollup_day, video_id, total_sessions, total_active_min, total_lines_seen,
|
||||
total_tokens_seen, total_cards, cards_per_hour,
|
||||
@@ -197,20 +223,8 @@ function upsertDailyRollupsForGroups(
|
||||
END AS lookup_hit_rate,
|
||||
? AS CREATED_DATE,
|
||||
? AS LAST_UPDATE_DATE
|
||||
FROM imm_sessions s
|
||||
LEFT JOIN (
|
||||
SELECT
|
||||
t.session_id,
|
||||
MAX(t.active_watched_ms) AS max_active_ms,
|
||||
MAX(t.lines_seen) AS max_lines,
|
||||
MAX(t.tokens_seen) AS max_tokens,
|
||||
MAX(t.cards_mined) AS max_cards,
|
||||
MAX(t.lookup_count) AS max_lookups,
|
||||
MAX(t.lookup_hits) AS max_hits
|
||||
FROM imm_session_telemetry t
|
||||
GROUP BY t.session_id
|
||||
) sm ON s.session_id = sm.session_id
|
||||
WHERE CAST(julianday(s.started_at_ms / 1000, 'unixepoch', 'localtime') - 2440587.5 AS INTEGER) = ? AND s.video_id = ?
|
||||
FROM matching_sessions s
|
||||
LEFT JOIN session_metrics sm ON s.session_id = sm.session_id
|
||||
GROUP BY rollup_day, s.video_id
|
||||
ON CONFLICT (rollup_day, video_id) DO UPDATE SET
|
||||
total_sessions = excluded.total_sessions,
|
||||
@@ -226,7 +240,7 @@ function upsertDailyRollupsForGroups(
|
||||
`);
|
||||
|
||||
for (const { rollupDay, videoId } of groups) {
|
||||
upsertStmt.run(rollupNowMs, rollupNowMs, rollupDay, videoId);
|
||||
upsertStmt.run(rollupDay, videoId, rollupNowMs, rollupNowMs);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -240,6 +254,24 @@ function upsertMonthlyRollupsForGroups(
|
||||
}
|
||||
|
||||
const upsertStmt = db.prepare(`
|
||||
WITH matching_sessions AS (
|
||||
SELECT *
|
||||
FROM imm_sessions
|
||||
WHERE CAST(strftime('%Y%m', CAST(started_at_ms AS REAL) / 1000, 'unixepoch', 'localtime') AS INTEGER) = ?
|
||||
AND video_id = ?
|
||||
),
|
||||
session_metrics AS (
|
||||
SELECT
|
||||
t.session_id,
|
||||
MAX(t.active_watched_ms) AS max_active_ms,
|
||||
MAX(t.lines_seen) AS max_lines,
|
||||
MAX(t.tokens_seen) AS max_tokens,
|
||||
MAX(t.cards_mined) AS max_cards
|
||||
FROM imm_session_telemetry t
|
||||
JOIN matching_sessions s
|
||||
ON s.session_id = t.session_id
|
||||
GROUP BY t.session_id
|
||||
)
|
||||
INSERT INTO imm_monthly_rollups (
|
||||
rollup_month, video_id, total_sessions, total_active_min, total_lines_seen,
|
||||
total_tokens_seen, total_cards, CREATED_DATE, LAST_UPDATE_DATE
|
||||
@@ -254,18 +286,8 @@ function upsertMonthlyRollupsForGroups(
|
||||
COALESCE(SUM(COALESCE(sm.max_cards, s.cards_mined)), 0) AS total_cards,
|
||||
? AS CREATED_DATE,
|
||||
? AS LAST_UPDATE_DATE
|
||||
FROM imm_sessions s
|
||||
LEFT JOIN (
|
||||
SELECT
|
||||
t.session_id,
|
||||
MAX(t.active_watched_ms) AS max_active_ms,
|
||||
MAX(t.lines_seen) AS max_lines,
|
||||
MAX(t.tokens_seen) AS max_tokens,
|
||||
MAX(t.cards_mined) AS max_cards
|
||||
FROM imm_session_telemetry t
|
||||
GROUP BY t.session_id
|
||||
) sm ON s.session_id = sm.session_id
|
||||
WHERE CAST(strftime('%Y%m', s.started_at_ms / 1000, 'unixepoch', 'localtime') AS INTEGER) = ? AND s.video_id = ?
|
||||
FROM matching_sessions s
|
||||
LEFT JOIN session_metrics sm ON s.session_id = sm.session_id
|
||||
GROUP BY rollup_month, s.video_id
|
||||
ON CONFLICT (rollup_month, video_id) DO UPDATE SET
|
||||
total_sessions = excluded.total_sessions,
|
||||
@@ -278,10 +300,82 @@ function upsertMonthlyRollupsForGroups(
|
||||
`);
|
||||
|
||||
for (const { rollupMonth, videoId } of groups) {
|
||||
upsertStmt.run(rollupNowMs, rollupNowMs, rollupMonth, videoId);
|
||||
upsertStmt.run(rollupMonth, videoId, rollupNowMs, rollupNowMs);
|
||||
}
|
||||
}
|
||||
|
||||
export function getRollupGroupsForSessions(db: DatabaseSync, sessionIds: number[]): RollupGroup[] {
|
||||
if (sessionIds.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const placeholders = makePlaceholders(sessionIds);
|
||||
const rows = db
|
||||
.prepare(
|
||||
`
|
||||
SELECT DISTINCT
|
||||
CAST(julianday(CAST(started_at_ms AS REAL) / 1000, 'unixepoch', 'localtime') - 2440587.5 AS INTEGER) AS rollup_day,
|
||||
CAST(strftime('%Y%m', CAST(started_at_ms AS REAL) / 1000, 'unixepoch', 'localtime') AS INTEGER) AS rollup_month,
|
||||
video_id
|
||||
FROM imm_sessions
|
||||
WHERE session_id IN (${placeholders})
|
||||
UNION
|
||||
SELECT DISTINCT
|
||||
CAST(CAST(started_at_ms AS REAL) / 86400000 AS INTEGER) AS rollup_day,
|
||||
CAST(strftime('%Y%m', CAST(started_at_ms AS REAL) / 1000, 'unixepoch') AS INTEGER) AS rollup_month,
|
||||
video_id
|
||||
FROM imm_sessions
|
||||
WHERE session_id IN (${placeholders})
|
||||
`,
|
||||
)
|
||||
.all(...sessionIds, ...sessionIds) as RollupGroupRow[];
|
||||
|
||||
return rows.map((row) => ({
|
||||
rollupDay: row.rollup_day,
|
||||
rollupMonth: row.rollup_month,
|
||||
videoId: row.video_id,
|
||||
}));
|
||||
}
|
||||
|
||||
export function refreshRollupsForGroupsInTransaction(
|
||||
db: DatabaseSync,
|
||||
groups: RollupGroup[],
|
||||
): void {
|
||||
if (groups.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const rollupNowMs = toDbTimestamp(nowMs());
|
||||
const dailyGroups = dedupeGroups(
|
||||
groups.map((group) => ({
|
||||
rollupDay: group.rollupDay,
|
||||
videoId: group.videoId,
|
||||
})),
|
||||
);
|
||||
const monthlyGroups = dedupeGroups(
|
||||
groups.map((group) => ({
|
||||
rollupMonth: group.rollupMonth,
|
||||
videoId: group.videoId,
|
||||
})),
|
||||
);
|
||||
const deleteDailyStmt = db.prepare(
|
||||
'DELETE FROM imm_daily_rollups WHERE rollup_day = ? AND video_id = ?',
|
||||
);
|
||||
const deleteMonthlyStmt = db.prepare(
|
||||
'DELETE FROM imm_monthly_rollups WHERE rollup_month = ? AND video_id = ?',
|
||||
);
|
||||
|
||||
for (const { rollupDay, videoId } of dailyGroups) {
|
||||
deleteDailyStmt.run(rollupDay, videoId);
|
||||
}
|
||||
for (const { rollupMonth, videoId } of monthlyGroups) {
|
||||
deleteMonthlyStmt.run(rollupMonth, videoId);
|
||||
}
|
||||
|
||||
upsertDailyRollupsForGroups(db, dailyGroups, rollupNowMs);
|
||||
upsertMonthlyRollupsForGroups(db, monthlyGroups, rollupNowMs);
|
||||
}
|
||||
|
||||
function getAffectedRollupGroups(
|
||||
db: DatabaseSync,
|
||||
lastRollupSampleMs: number | string,
|
||||
|
||||
@@ -2,7 +2,7 @@ import { createHash } from 'node:crypto';
|
||||
import type { DatabaseSync } from './sqlite';
|
||||
import { buildCoverBlobReference, normalizeCoverBlobBytes } from './storage';
|
||||
import { rebuildLifetimeSummariesInTransaction } from './lifetime';
|
||||
import { rebuildRollupsInTransaction } from './maintenance';
|
||||
import { getRollupGroupsForSessions, refreshRollupsForGroupsInTransaction } from './maintenance';
|
||||
import { nowMs } from './time';
|
||||
import { PartOfSpeech, type MergedToken } from '../../../types';
|
||||
import { shouldExcludeTokenFromVocabularyPersistence } from '../tokenizer/annotation-stage';
|
||||
@@ -474,13 +474,14 @@ export function deleteSession(db: DatabaseSync, sessionId: number): void {
|
||||
const sessionIds = [sessionId];
|
||||
const affectedWordIds = getAffectedWordIdsForSessions(db, sessionIds);
|
||||
const affectedKanjiIds = getAffectedKanjiIdsForSessions(db, sessionIds);
|
||||
const affectedRollupGroups = getRollupGroupsForSessions(db, sessionIds);
|
||||
|
||||
db.exec('BEGIN IMMEDIATE');
|
||||
try {
|
||||
deleteSessionsByIds(db, sessionIds);
|
||||
refreshLexicalAggregates(db, affectedWordIds, affectedKanjiIds);
|
||||
rebuildLifetimeSummariesInTransaction(db);
|
||||
rebuildRollupsInTransaction(db);
|
||||
refreshRollupsForGroupsInTransaction(db, affectedRollupGroups);
|
||||
db.exec('COMMIT');
|
||||
} catch (error) {
|
||||
db.exec('ROLLBACK');
|
||||
@@ -492,13 +493,14 @@ export function deleteSessions(db: DatabaseSync, sessionIds: number[]): void {
|
||||
if (sessionIds.length === 0) return;
|
||||
const affectedWordIds = getAffectedWordIdsForSessions(db, sessionIds);
|
||||
const affectedKanjiIds = getAffectedKanjiIdsForSessions(db, sessionIds);
|
||||
const affectedRollupGroups = getRollupGroupsForSessions(db, sessionIds);
|
||||
|
||||
db.exec('BEGIN IMMEDIATE');
|
||||
try {
|
||||
deleteSessionsByIds(db, sessionIds);
|
||||
refreshLexicalAggregates(db, affectedWordIds, affectedKanjiIds);
|
||||
rebuildLifetimeSummariesInTransaction(db);
|
||||
rebuildRollupsInTransaction(db);
|
||||
refreshRollupsForGroupsInTransaction(db, affectedRollupGroups);
|
||||
db.exec('COMMIT');
|
||||
} catch (error) {
|
||||
db.exec('ROLLBACK');
|
||||
@@ -536,7 +538,6 @@ export function deleteVideo(db: DatabaseSync, videoId: number): void {
|
||||
db.prepare('DELETE FROM imm_videos WHERE video_id = ?').run(videoId);
|
||||
refreshLexicalAggregates(db, affectedWordIds, affectedKanjiIds);
|
||||
rebuildLifetimeSummariesInTransaction(db);
|
||||
rebuildRollupsInTransaction(db);
|
||||
db.exec('COMMIT');
|
||||
} catch (error) {
|
||||
db.exec('ROLLBACK');
|
||||
|
||||
@@ -27,6 +27,16 @@ type StatsExcludedWordPayload = {
|
||||
reading: string;
|
||||
};
|
||||
|
||||
type StatsCoverImagePayload = {
|
||||
contentType: 'image/jpeg';
|
||||
dataUrl: string;
|
||||
} | null;
|
||||
|
||||
type StatsCoverBatchBody = {
|
||||
animeIds?: unknown;
|
||||
videoIds?: unknown;
|
||||
};
|
||||
|
||||
function parseIntQuery(raw: string | undefined, fallback: number, maxLimit?: number): number {
|
||||
if (raw === undefined) return fallback;
|
||||
const n = Number(raw);
|
||||
@@ -73,6 +83,31 @@ function parseExcludedWordsBody(body: unknown): StatsExcludedWordPayload[] | nul
|
||||
return words;
|
||||
}
|
||||
|
||||
function parsePositiveIdList(raw: unknown, maxItems = 100): number[] {
|
||||
if (!Array.isArray(raw)) return [];
|
||||
|
||||
const ids = new Set<number>();
|
||||
for (const rawId of raw) {
|
||||
const id = typeof rawId === 'number' ? rawId : typeof rawId === 'string' ? Number(rawId) : NaN;
|
||||
if (Number.isFinite(id) && id > 0) {
|
||||
ids.add(Math.floor(id));
|
||||
if (ids.size >= maxItems) break;
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(ids).sort((a, b) => a - b);
|
||||
}
|
||||
|
||||
function coverImagePayload(
|
||||
art: { coverBlob?: Uint8Array | null } | null | undefined,
|
||||
): StatsCoverImagePayload {
|
||||
if (!art?.coverBlob) return null;
|
||||
return {
|
||||
contentType: 'image/jpeg',
|
||||
dataUrl: `data:image/jpeg;base64,${Buffer.from(art.coverBlob).toString('base64')}`,
|
||||
};
|
||||
}
|
||||
|
||||
function resolveStatsNoteFieldName(
|
||||
noteInfo: StatsServerNoteInfo,
|
||||
...preferredNames: (string | undefined)[]
|
||||
@@ -707,6 +742,27 @@ export function createStatsApp(
|
||||
return c.json({ ok: true });
|
||||
});
|
||||
|
||||
app.post('/api/stats/covers', async (c) => {
|
||||
const body = (await c.req.json().catch(() => null)) as StatsCoverBatchBody | null;
|
||||
const animeIds = parsePositiveIdList(body?.animeIds);
|
||||
const videoIds = parsePositiveIdList(body?.videoIds);
|
||||
const anime: Record<string, StatsCoverImagePayload> = {};
|
||||
const media: Record<string, StatsCoverImagePayload> = {};
|
||||
|
||||
await Promise.all(
|
||||
animeIds.map(async (animeId) => {
|
||||
anime[String(animeId)] = coverImagePayload(await tracker.getAnimeCoverArt(animeId));
|
||||
}),
|
||||
);
|
||||
await Promise.all(
|
||||
videoIds.map(async (videoId) => {
|
||||
media[String(videoId)] = coverImagePayload(await tracker.getCoverArt(videoId));
|
||||
}),
|
||||
);
|
||||
|
||||
return c.json({ anime, media });
|
||||
});
|
||||
|
||||
app.get('/api/stats/anime/:animeId/cover', async (c) => {
|
||||
const animeId = parseIntQuery(c.req.param('animeId'), 0);
|
||||
if (animeId <= 0) return c.body(null, 404);
|
||||
|
||||
@@ -3266,6 +3266,10 @@ function broadcastToOverlayWindows(channel: string, ...args: unknown[]): void {
|
||||
overlayManager.broadcastToOverlayWindows(channel, ...args);
|
||||
}
|
||||
|
||||
function broadcastMiningImageToOverlay(image: string): void {
|
||||
broadcastToOverlayWindows(IPC_CHANNELS.event.miningImage, { image });
|
||||
}
|
||||
|
||||
const buildBroadcastRuntimeOptionsChangedMainDepsHandler =
|
||||
createBuildBroadcastRuntimeOptionsChangedMainDepsHandler({
|
||||
broadcastRuntimeOptionsChangedRuntime,
|
||||
@@ -5811,6 +5815,7 @@ function initializeOverlayRuntime(): void {
|
||||
refreshCurrentSubtitleAfterKnownWordUpdate,
|
||||
);
|
||||
appState.ankiIntegration?.setSubtitleMiningContextConsumer(consumePendingSubtitleMiningContext);
|
||||
appState.ankiIntegration?.setMiningImageOverlayCallback(broadcastMiningImageToOverlay);
|
||||
syncOverlayMpvSubtitleSuppression();
|
||||
}
|
||||
|
||||
@@ -6896,6 +6901,7 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({
|
||||
appState.ankiIntegration?.setSubtitleMiningContextConsumer(
|
||||
consumePendingSubtitleMiningContext,
|
||||
);
|
||||
appState.ankiIntegration?.setMiningImageOverlayCallback(broadcastMiningImageToOverlay);
|
||||
},
|
||||
getKnownWordCacheStatePath: () => path.join(USER_DATA_PATH, 'known-words-cache.json'),
|
||||
showDesktopNotification,
|
||||
|
||||
@@ -51,6 +51,7 @@ import type {
|
||||
OverlayContentMeasurement,
|
||||
ShortcutsConfig,
|
||||
ConfigHotReloadPayload,
|
||||
MiningImagePayload,
|
||||
ControllerConfigUpdate,
|
||||
ControllerPreferenceUpdate,
|
||||
ResolvedControllerConfig,
|
||||
@@ -222,6 +223,10 @@ const onSecondarySubtitleModeEvent = createLatestValueIpcListenerWithPayload<Sec
|
||||
IPC_CHANNELS.event.secondarySubtitleMode,
|
||||
(payload) => payload as SecondarySubMode,
|
||||
);
|
||||
const onMiningImageEvent = createQueuedIpcListenerWithPayload<MiningImagePayload>(
|
||||
IPC_CHANNELS.event.miningImage,
|
||||
(payload) => payload as MiningImagePayload,
|
||||
);
|
||||
|
||||
const electronAPI: ElectronAPI = {
|
||||
getOverlayLayer: () => overlayLayer,
|
||||
@@ -468,6 +473,9 @@ const electronAPI: ElectronAPI = {
|
||||
},
|
||||
);
|
||||
},
|
||||
onMiningImage: (callback: (payload: MiningImagePayload) => void) => {
|
||||
onMiningImageEvent(callback);
|
||||
},
|
||||
};
|
||||
|
||||
contextBridge.exposeInMainWorld('electronAPI', electronAPI);
|
||||
|
||||
@@ -42,6 +42,9 @@
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
></div>
|
||||
<div id="miningImageToast" class="mining-image-toast hidden" role="status" aria-live="polite">
|
||||
<img id="miningImageToastImage" class="mining-image-toast-image" alt="Mined card preview" />
|
||||
</div>
|
||||
<div id="secondarySubContainer" class="secondary-sub-hidden">
|
||||
<div id="secondarySubRoot"></div>
|
||||
</div>
|
||||
|
||||
@@ -24,6 +24,7 @@ import type {
|
||||
SubtitlePosition,
|
||||
SubsyncManualPayload,
|
||||
ConfigHotReloadPayload,
|
||||
MiningImagePayload,
|
||||
} from '../types';
|
||||
import { createKeyboardHandlers } from './handlers/keyboard.js';
|
||||
import { createGamepadController } from './handlers/gamepad-controller.js';
|
||||
@@ -211,6 +212,7 @@ const keyboardHandlers = createKeyboardHandlers(ctx, {
|
||||
let lastSubtitlePreview = '';
|
||||
let lastSecondarySubtitlePreview = '';
|
||||
let overlayErrorToastTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||
let miningImageToastTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||
let controllerAnimationFrameId: number | null = null;
|
||||
|
||||
function truncateForErrorLog(text: string): string {
|
||||
@@ -439,6 +441,23 @@ function showOverlayErrorToast(message: string): void {
|
||||
}, 3200);
|
||||
}
|
||||
|
||||
function showMiningImageToast(image: string): void {
|
||||
if (!image) {
|
||||
return;
|
||||
}
|
||||
if (miningImageToastTimeout) {
|
||||
clearTimeout(miningImageToastTimeout);
|
||||
miningImageToastTimeout = null;
|
||||
}
|
||||
ctx.dom.miningImageToastImage.src = image;
|
||||
ctx.dom.miningImageToast.classList.remove('hidden');
|
||||
miningImageToastTimeout = setTimeout(() => {
|
||||
ctx.dom.miningImageToast.classList.add('hidden');
|
||||
ctx.dom.miningImageToastImage.removeAttribute('src');
|
||||
miningImageToastTimeout = null;
|
||||
}, 3000);
|
||||
}
|
||||
|
||||
const recovery = createRendererRecoveryController({
|
||||
dismissActiveUi: dismissActiveUiAfterError,
|
||||
restoreOverlayInteraction: restoreOverlayInteractionAfterError,
|
||||
@@ -733,6 +752,11 @@ async function init(): Promise<void> {
|
||||
measurementReporter.schedule();
|
||||
});
|
||||
});
|
||||
window.electronAPI.onMiningImage((payload: MiningImagePayload) => {
|
||||
runGuarded('mining:image', () => {
|
||||
showMiningImageToast(payload.image);
|
||||
});
|
||||
});
|
||||
mouseHandlers.setupDragging();
|
||||
try {
|
||||
ctx.state.controllerConfig = await window.electronAPI.getControllerConfig();
|
||||
|
||||
@@ -146,6 +146,37 @@ body:focus-visible,
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.mining-image-toast {
|
||||
position: absolute;
|
||||
top: 16px;
|
||||
right: 16px;
|
||||
padding: 6px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid rgba(138, 213, 202, 0.45);
|
||||
background: linear-gradient(135deg, rgba(10, 44, 40, 0.94), rgba(8, 28, 33, 0.94));
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.45);
|
||||
pointer-events: none;
|
||||
opacity: 0;
|
||||
transform: translateY(-6px);
|
||||
transition:
|
||||
opacity 160ms ease,
|
||||
transform 160ms ease;
|
||||
z-index: 1300;
|
||||
}
|
||||
|
||||
.mining-image-toast-image {
|
||||
display: block;
|
||||
width: 128px;
|
||||
height: 128px;
|
||||
border-radius: 6px;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.mining-image-toast:not(.hidden) {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.modal {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
|
||||
@@ -4,6 +4,8 @@ export type RendererDom = {
|
||||
overlay: HTMLElement;
|
||||
controllerStatusToast: HTMLDivElement;
|
||||
overlayErrorToast: HTMLDivElement;
|
||||
miningImageToast: HTMLDivElement;
|
||||
miningImageToastImage: HTMLImageElement;
|
||||
secondarySubContainer: HTMLElement;
|
||||
secondarySubRoot: HTMLElement;
|
||||
|
||||
@@ -134,6 +136,8 @@ export function resolveRendererDom(): RendererDom {
|
||||
overlay: getRequiredElement<HTMLElement>('overlay'),
|
||||
controllerStatusToast: getRequiredElement<HTMLDivElement>('controllerStatusToast'),
|
||||
overlayErrorToast: getRequiredElement<HTMLDivElement>('overlayErrorToast'),
|
||||
miningImageToast: getRequiredElement<HTMLDivElement>('miningImageToast'),
|
||||
miningImageToastImage: getRequiredElement<HTMLImageElement>('miningImageToastImage'),
|
||||
secondarySubContainer: getRequiredElement<HTMLElement>('secondarySubContainer'),
|
||||
secondarySubRoot: getRequiredElement<HTMLElement>('secondarySubRoot'),
|
||||
|
||||
|
||||
@@ -144,6 +144,7 @@ export const IPC_CHANNELS = {
|
||||
subtitleSidebarToggle: 'subtitle-sidebar:toggle',
|
||||
primarySubtitleBarToggle: 'primary-subtitle-bar:toggle',
|
||||
configHotReload: 'config:hot-reload',
|
||||
miningImage: 'mining:image',
|
||||
},
|
||||
} as const;
|
||||
|
||||
|
||||
@@ -343,6 +343,11 @@ export interface ClipboardAppendResult {
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface MiningImagePayload {
|
||||
/** Screenshot of the mined frame as a data URL (e.g. `data:image/png;base64,...`). */
|
||||
image: string;
|
||||
}
|
||||
|
||||
export interface ConfigHotReloadPayload {
|
||||
keybindings: Keybinding[];
|
||||
sessionBindings: CompiledSessionBinding[];
|
||||
@@ -541,6 +546,7 @@ export interface ElectronAPI {
|
||||
) => void;
|
||||
reportOverlayContentBounds: (measurement: OverlayContentMeasurement) => void;
|
||||
onConfigHotReload: (callback: (payload: ConfigHotReloadPayload) => void) => void;
|
||||
onMiningImage: (callback: (payload: MiningImagePayload) => void) => void;
|
||||
}
|
||||
|
||||
declare global {
|
||||
|
||||
@@ -29,7 +29,7 @@ export function AnilistSelector({
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [linking, setLinking] = useState<number | null>(null);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const debounceRef = useRef<ReturnType<typeof setTimeout>>();
|
||||
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
inputRef.current?.focus();
|
||||
@@ -53,7 +53,7 @@ export function AnilistSelector({
|
||||
|
||||
const handleInput = (value: string) => {
|
||||
setQuery(value);
|
||||
clearTimeout(debounceRef.current);
|
||||
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||
debounceRef.current = setTimeout(() => doSearch(value), 400);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState } from 'react';
|
||||
import { RetryingCoverImage } from '../common/RetryingCoverImage';
|
||||
import { getStatsClient } from '../../hooks/useStatsApi';
|
||||
|
||||
interface AnimeCoverImageProps {
|
||||
@@ -8,28 +8,7 @@ interface AnimeCoverImageProps {
|
||||
}
|
||||
|
||||
export function AnimeCoverImage({ animeId, title, className = '' }: AnimeCoverImageProps) {
|
||||
const [failed, setFailed] = useState(false);
|
||||
const fallbackChar = title.charAt(0) || '?';
|
||||
|
||||
if (failed) {
|
||||
return (
|
||||
<div
|
||||
className={`bg-ctp-surface2 flex items-center justify-center text-ctp-overlay2 text-2xl font-bold ${className}`}
|
||||
>
|
||||
{fallbackChar}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const src = getStatsClient().getAnimeCoverUrl(animeId);
|
||||
|
||||
return (
|
||||
<img
|
||||
src={src}
|
||||
alt={title}
|
||||
loading="lazy"
|
||||
className={`object-cover bg-ctp-surface2 ${className}`}
|
||||
onError={() => setFailed(true)}
|
||||
/>
|
||||
);
|
||||
return <RetryingCoverImage src={src} alt={title} fallbackLabel={title} className={className} />;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
interface DeleteProgressToastProps {
|
||||
/** Number of sessions currently being deleted. The toast is hidden when 0. */
|
||||
count: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fixed-position toast shown while session deletions are in flight.
|
||||
*
|
||||
* The per-row delete buttons are only visible on hover, so once the confirm
|
||||
* dialog closes the user has no signal that a (potentially slow) batch delete
|
||||
* is still running. This stays on screen, independent of hover, until the work
|
||||
* finishes.
|
||||
*/
|
||||
export function DeleteProgressToast({ count }: DeleteProgressToastProps) {
|
||||
if (count <= 0) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
className="fixed bottom-4 right-4 z-50 flex items-center gap-3 rounded-lg border border-ctp-surface1 bg-ctp-surface0 px-4 py-3 shadow-lg shadow-black/30"
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="h-4 w-4 shrink-0 animate-spin rounded-full border-2 border-ctp-surface2 border-t-ctp-red"
|
||||
/>
|
||||
<span className="text-sm text-ctp-text">
|
||||
Deleting {count} session{count === 1 ? '' : 's'}…
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { appendCoverRetryToken, getCoverRetryDelayMs } from '../../lib/cover-retry';
|
||||
|
||||
interface RetryingCoverImageProps {
|
||||
src: string;
|
||||
alt: string;
|
||||
fallbackLabel: string;
|
||||
className?: string;
|
||||
fallbackTextClassName?: string;
|
||||
loading?: 'eager' | 'lazy';
|
||||
}
|
||||
|
||||
export function RetryingCoverImage({
|
||||
src,
|
||||
alt,
|
||||
fallbackLabel,
|
||||
className = '',
|
||||
fallbackTextClassName = 'text-2xl',
|
||||
loading = 'lazy',
|
||||
}: RetryingCoverImageProps) {
|
||||
const [failed, setFailed] = useState(false);
|
||||
const [retryToken, setRetryToken] = useState(0);
|
||||
const fallbackChar = fallbackLabel.charAt(0) || '?';
|
||||
|
||||
useEffect(() => {
|
||||
setFailed(false);
|
||||
setRetryToken(0);
|
||||
}, [src]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!failed) return;
|
||||
const timer = setTimeout(() => {
|
||||
setRetryToken((value) => value + 1);
|
||||
setFailed(false);
|
||||
}, getCoverRetryDelayMs(retryToken));
|
||||
return () => clearTimeout(timer);
|
||||
}, [failed, retryToken]);
|
||||
|
||||
if (failed) {
|
||||
return (
|
||||
<div
|
||||
className={`bg-ctp-surface2 flex items-center justify-center text-ctp-overlay2 ${fallbackTextClassName} font-bold ${className}`}
|
||||
>
|
||||
{fallbackChar}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<img
|
||||
src={appendCoverRetryToken(src, retryToken)}
|
||||
alt={alt}
|
||||
loading={loading}
|
||||
className={`object-cover bg-ctp-surface2 ${className}`}
|
||||
onError={() => setFailed(true)}
|
||||
onLoad={() => setFailed(false)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { RetryingCoverImage } from '../common/RetryingCoverImage';
|
||||
import { resolveMediaCoverApiUrl } from '../../lib/media-library-grouping';
|
||||
|
||||
interface CoverImageProps {
|
||||
@@ -9,31 +9,9 @@ interface CoverImageProps {
|
||||
}
|
||||
|
||||
export function CoverImage({ videoId, title, src = null, className = '' }: CoverImageProps) {
|
||||
const [failed, setFailed] = useState(false);
|
||||
const fallbackChar = title.charAt(0) || '?';
|
||||
const resolvedSrc = src?.trim() || resolveMediaCoverApiUrl(videoId);
|
||||
|
||||
useEffect(() => {
|
||||
setFailed(false);
|
||||
}, [resolvedSrc]);
|
||||
|
||||
if (failed) {
|
||||
return (
|
||||
<div
|
||||
className={`bg-ctp-surface2 flex items-center justify-center text-ctp-overlay2 text-2xl font-bold ${className}`}
|
||||
>
|
||||
{fallbackChar}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<img
|
||||
src={resolvedSrc}
|
||||
alt={title}
|
||||
loading="lazy"
|
||||
className={`object-cover bg-ctp-surface2 ${className}`}
|
||||
onError={() => setFailed(true)}
|
||||
/>
|
||||
<RetryingCoverImage src={resolvedSrc} alt={title} fallbackLabel={title} className={className} />
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { getCoverImageSrc, type CoverImageMap } from '../../lib/cover-images';
|
||||
|
||||
interface CoverThumbnailProps {
|
||||
animeId: number | null;
|
||||
videoId: number | null;
|
||||
title: string;
|
||||
coverImages: CoverImageMap;
|
||||
}
|
||||
|
||||
export function CoverThumbnail({ animeId, videoId, title, coverImages }: CoverThumbnailProps) {
|
||||
const [failed, setFailed] = useState(false);
|
||||
const fallbackChar = title.charAt(0) || '?';
|
||||
const fallback = (
|
||||
<div className="w-12 h-16 rounded bg-ctp-surface2 flex items-center justify-center text-ctp-overlay2 text-lg font-bold shrink-0">
|
||||
{fallbackChar}
|
||||
</div>
|
||||
);
|
||||
|
||||
const src =
|
||||
animeId != null
|
||||
? getCoverImageSrc(coverImages, 'anime', animeId)
|
||||
: getCoverImageSrc(coverImages, 'media', videoId);
|
||||
|
||||
useEffect(() => {
|
||||
setFailed(false);
|
||||
}, [src]);
|
||||
|
||||
if (!src || failed) {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
return (
|
||||
<img
|
||||
src={src}
|
||||
alt=""
|
||||
className="w-12 h-16 rounded object-cover shrink-0 bg-ctp-surface2"
|
||||
onError={() => setFailed(true)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import { StreakCalendar } from './StreakCalendar';
|
||||
import { RecentSessions } from './RecentSessions';
|
||||
import { TrackingSnapshot } from './TrackingSnapshot';
|
||||
import { TrendChart } from '../trends/TrendChart';
|
||||
import { DeleteProgressToast } from '../common/DeleteProgressToast';
|
||||
import { buildOverviewSummary, buildStreakCalendar } from '../../lib/dashboard-data';
|
||||
import { apiClient } from '../../lib/api-client';
|
||||
import { getStatsClient } from '../../hooks/useStatsApi';
|
||||
@@ -153,6 +154,8 @@ export function OverviewTab({ onNavigateToMediaDetail, onNavigateToSession }: Ov
|
||||
onDeleteAnimeGroup={handleDeleteAnimeGroup}
|
||||
deletingIds={deletingIds}
|
||||
/>
|
||||
|
||||
<DeleteProgressToast count={deletingIds.size} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,7 +5,9 @@ import {
|
||||
formatNumber,
|
||||
formatSessionDayLabel,
|
||||
} from '../../lib/formatters';
|
||||
import { BASE_URL } from '../../lib/api-client';
|
||||
import { CoverThumbnail } from './CoverThumbnail';
|
||||
import { useCoverImages } from '../../hooks/useCoverImages';
|
||||
import type { CoverImageMap } from '../../lib/cover-images';
|
||||
import { getSessionDisplayWordCount } from '../../lib/session-word-count';
|
||||
import { getSessionNavigationTarget } from '../../lib/stats-navigation';
|
||||
import type { SessionSummary } from '../../types/stats';
|
||||
@@ -85,53 +87,20 @@ function groupSessionsByAnime(sessions: SessionSummary[]): AnimeGroup[] {
|
||||
return Array.from(map.values());
|
||||
}
|
||||
|
||||
function CoverThumbnail({
|
||||
animeId,
|
||||
videoId,
|
||||
title,
|
||||
}: {
|
||||
animeId: number | null;
|
||||
videoId: number | null;
|
||||
title: string;
|
||||
}) {
|
||||
const fallbackChar = title.charAt(0) || '?';
|
||||
const [isFallback, setIsFallback] = useState(false);
|
||||
|
||||
if ((!animeId && !videoId) || isFallback) {
|
||||
return (
|
||||
<div className="w-12 h-16 rounded bg-ctp-surface2 flex items-center justify-center text-ctp-overlay2 text-lg font-bold shrink-0">
|
||||
{fallbackChar}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const src =
|
||||
animeId != null
|
||||
? `${BASE_URL}/api/stats/anime/${animeId}/cover`
|
||||
: `${BASE_URL}/api/stats/media/${videoId}/cover`;
|
||||
|
||||
return (
|
||||
<img
|
||||
src={src}
|
||||
alt=""
|
||||
className="w-12 h-16 rounded object-cover shrink-0 bg-ctp-surface2"
|
||||
onError={() => setIsFallback(true)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SessionItem({
|
||||
session,
|
||||
onNavigateToMediaDetail,
|
||||
onNavigateToSession,
|
||||
onDelete,
|
||||
deleteDisabled,
|
||||
coverImages,
|
||||
}: {
|
||||
session: SessionSummary;
|
||||
onNavigateToMediaDetail: (videoId: number, sessionId?: number | null) => void;
|
||||
onNavigateToSession: (sessionId: number) => void;
|
||||
onDelete: () => void;
|
||||
deleteDisabled: boolean;
|
||||
coverImages: CoverImageMap;
|
||||
}) {
|
||||
const displayWordCount = getSessionDisplayWordCount(session);
|
||||
const navigationTarget = getSessionNavigationTarget(session);
|
||||
@@ -153,6 +122,7 @@ function SessionItem({
|
||||
animeId={session.animeId}
|
||||
videoId={session.videoId}
|
||||
title={session.canonicalTitle ?? 'Unknown'}
|
||||
coverImages={coverImages}
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-sm font-medium text-ctp-text truncate">
|
||||
@@ -205,6 +175,7 @@ function AnimeGroupRow({
|
||||
onDeleteSession,
|
||||
onDeleteAnimeGroup,
|
||||
deletingIds,
|
||||
coverImages,
|
||||
}: {
|
||||
group: AnimeGroup;
|
||||
onNavigateToMediaDetail: (videoId: number, sessionId?: number | null) => void;
|
||||
@@ -212,6 +183,7 @@ function AnimeGroupRow({
|
||||
onDeleteSession: (session: SessionSummary) => void;
|
||||
onDeleteAnimeGroup: (group: AnimeGroup) => void;
|
||||
deletingIds: Set<number>;
|
||||
coverImages: CoverImageMap;
|
||||
}) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const groupDeleting = group.sessions.some((s) => deletingIds.has(s.sessionId));
|
||||
@@ -225,6 +197,7 @@ function AnimeGroupRow({
|
||||
onNavigateToSession={onNavigateToSession}
|
||||
onDelete={() => onDeleteSession(s)}
|
||||
deleteDisabled={deletingIds.has(s.sessionId)}
|
||||
coverImages={coverImages}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -247,6 +220,7 @@ function AnimeGroupRow({
|
||||
animeId={group.animeId}
|
||||
videoId={mostRecentSession.videoId}
|
||||
title={displayTitle}
|
||||
coverImages={coverImages}
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-sm font-medium text-ctp-text truncate">{displayTitle}</div>
|
||||
@@ -319,6 +293,7 @@ function AnimeGroupRow({
|
||||
animeId={s.animeId}
|
||||
videoId={s.videoId}
|
||||
title={s.canonicalTitle ?? 'Unknown'}
|
||||
coverImages={coverImages}
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-sm font-medium text-ctp-subtext1 truncate">
|
||||
@@ -378,6 +353,8 @@ export function RecentSessions({
|
||||
onDeleteAnimeGroup,
|
||||
deletingIds,
|
||||
}: RecentSessionsProps) {
|
||||
const coverImages = useCoverImages(sessions);
|
||||
|
||||
if (sessions.length === 0) {
|
||||
return (
|
||||
<div className="bg-ctp-surface0 border border-ctp-surface1 rounded-lg p-4">
|
||||
@@ -422,6 +399,7 @@ export function RecentSessions({
|
||||
onDeleteSession={onDeleteSession}
|
||||
onDeleteAnimeGroup={(g) => onDeleteAnimeGroup(g.sessions)}
|
||||
deletingIds={deletingIds}
|
||||
coverImages={coverImages}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -71,6 +71,63 @@ test('buildBucketDeleteHandler deletes every session in the bucket when confirm
|
||||
assert.equal(onErrorCalled, false);
|
||||
});
|
||||
|
||||
test('buildBucketDeleteHandler signals onStart after confirm, before deleting', async () => {
|
||||
const events: string[] = [];
|
||||
|
||||
const bucket = makeBucket([
|
||||
makeSession({ sessionId: 11 }),
|
||||
makeSession({ sessionId: 22 }),
|
||||
makeSession({ sessionId: 33 }),
|
||||
]);
|
||||
|
||||
const handler = buildBucketDeleteHandler({
|
||||
bucket,
|
||||
apiClient: {
|
||||
deleteSessions: async () => {
|
||||
events.push('delete');
|
||||
},
|
||||
},
|
||||
confirm: () => {
|
||||
events.push('confirm');
|
||||
return true;
|
||||
},
|
||||
onStart: (count) => {
|
||||
events.push(`start:${count}`);
|
||||
},
|
||||
onSuccess: () => {
|
||||
events.push('success');
|
||||
},
|
||||
onError: () => {
|
||||
events.push('error');
|
||||
},
|
||||
});
|
||||
|
||||
await handler();
|
||||
|
||||
assert.deepEqual(events, ['confirm', 'start:3', 'delete', 'success']);
|
||||
});
|
||||
|
||||
test('buildBucketDeleteHandler does not call onStart when confirm returns false', async () => {
|
||||
let startCalled = false;
|
||||
|
||||
const bucket = makeBucket([makeSession({ sessionId: 1 }), makeSession({ sessionId: 2 })]);
|
||||
|
||||
const handler = buildBucketDeleteHandler({
|
||||
bucket,
|
||||
apiClient: { deleteSessions: async () => {} },
|
||||
confirm: () => false,
|
||||
onStart: () => {
|
||||
startCalled = true;
|
||||
},
|
||||
onSuccess: () => {},
|
||||
onError: () => {},
|
||||
});
|
||||
|
||||
await handler();
|
||||
|
||||
assert.equal(startCalled, false);
|
||||
});
|
||||
|
||||
test('buildBucketDeleteHandler is a no-op when confirm returns false', async () => {
|
||||
let deleteCalled = false;
|
||||
let successCalled = false;
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useEffect, useMemo, useState } from 'react';
|
||||
import { useSessions } from '../../hooks/useSessions';
|
||||
import { SessionRow } from './SessionRow';
|
||||
import { SessionDetail } from './SessionDetail';
|
||||
import { DeleteProgressToast } from '../common/DeleteProgressToast';
|
||||
import { apiClient } from '../../lib/api-client';
|
||||
import { confirmBucketDelete, confirmSessionDelete } from '../../lib/delete-confirm';
|
||||
import { formatDuration, formatNumber, formatSessionDayLabel } from '../../lib/formatters';
|
||||
@@ -28,6 +29,8 @@ export interface BucketDeleteDeps {
|
||||
bucket: SessionBucket;
|
||||
apiClient: { deleteSessions: (ids: number[]) => Promise<void> };
|
||||
confirm: (title: string, count: number) => boolean | Promise<boolean>;
|
||||
/** Called once confirmation passes, just before the delete request begins. */
|
||||
onStart?: (count: number) => void;
|
||||
onSuccess: (deletedIds: number[]) => void;
|
||||
onError: (message: string) => void;
|
||||
}
|
||||
@@ -39,12 +42,13 @@ export interface BucketDeleteDeps {
|
||||
* rendering the full SessionsTab or mocking React state.
|
||||
*/
|
||||
export function buildBucketDeleteHandler(deps: BucketDeleteDeps): () => Promise<void> {
|
||||
const { bucket, apiClient: client, confirm, onSuccess, onError } = deps;
|
||||
const { bucket, apiClient: client, confirm, onStart, onSuccess, onError } = deps;
|
||||
return async () => {
|
||||
const title = bucket.representativeSession.canonicalTitle ?? 'this episode';
|
||||
const ids = bucket.sessions.map((s) => s.sessionId);
|
||||
try {
|
||||
if (!(await confirm(title, ids.length))) return;
|
||||
onStart?.(ids.length);
|
||||
await client.deleteSessions(ids);
|
||||
onSuccess(ids);
|
||||
} catch (err) {
|
||||
@@ -72,6 +76,7 @@ export function SessionsTab({
|
||||
const [deleteError, setDeleteError] = useState<string | null>(null);
|
||||
const [deletingSessionId, setDeletingSessionId] = useState<number | null>(null);
|
||||
const [deletingBucketKey, setDeletingBucketKey] = useState<string | null>(null);
|
||||
const [deletingCount, setDeletingCount] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
setVisibleSessions(sessions);
|
||||
@@ -131,6 +136,7 @@ export function SessionsTab({
|
||||
|
||||
setDeleteError(null);
|
||||
setDeletingSessionId(session.sessionId);
|
||||
setDeletingCount(1);
|
||||
try {
|
||||
await apiClient.deleteSession(session.sessionId);
|
||||
setVisibleSessions((prev) => prev.filter((item) => item.sessionId !== session.sessionId));
|
||||
@@ -139,6 +145,7 @@ export function SessionsTab({
|
||||
setDeleteError(err instanceof Error ? err.message : 'Failed to delete session.');
|
||||
} finally {
|
||||
setDeletingSessionId(null);
|
||||
setDeletingCount(0);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -149,6 +156,7 @@ export function SessionsTab({
|
||||
bucket,
|
||||
apiClient,
|
||||
confirm: confirmBucketDelete,
|
||||
onStart: (count) => setDeletingCount(count),
|
||||
onSuccess: (ids) => {
|
||||
const deleted = new Set(ids);
|
||||
setVisibleSessions((prev) => prev.filter((s) => !deleted.has(s.sessionId)));
|
||||
@@ -166,6 +174,7 @@ export function SessionsTab({
|
||||
await handler();
|
||||
} finally {
|
||||
setDeletingBucketKey(null);
|
||||
setDeletingCount(0);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -305,6 +314,8 @@ export function SessionsTab({
|
||||
{search.trim() ? 'No sessions matching your search.' : 'No sessions recorded yet.'}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DeleteProgressToast count={deletingCount} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import {
|
||||
collectSessionCoverRequests,
|
||||
getCoverImageKey,
|
||||
mergeCoverImageData,
|
||||
type CoverImageMap,
|
||||
} from '../lib/cover-images';
|
||||
import { getCoverRetryDelayMs } from '../lib/cover-retry';
|
||||
import type { SessionSummary } from '../types/stats';
|
||||
import { getStatsClient } from './useStatsApi';
|
||||
|
||||
function buildRequestKey(animeIds: number[], videoIds: number[]): string {
|
||||
return `a:${animeIds.join(',')}|m:${videoIds.join(',')}`;
|
||||
}
|
||||
|
||||
export function useCoverImages(sessions: SessionSummary[]): CoverImageMap {
|
||||
const requests = useMemo(() => collectSessionCoverRequests(sessions), [sessions]);
|
||||
const requestKey = useMemo(
|
||||
() => buildRequestKey(requests.animeIds, requests.videoIds),
|
||||
[requests],
|
||||
);
|
||||
const [images, setImages] = useState<CoverImageMap>({});
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
let timer: ReturnType<typeof setTimeout> | null = null;
|
||||
let cachedImages: CoverImageMap = {};
|
||||
const client = getStatsClient();
|
||||
|
||||
async function load(animeIds: number[], videoIds: number[], attempt: number): Promise<void> {
|
||||
if (animeIds.length === 0 && videoIds.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const data = await client.getCoverImages({ animeIds, videoIds });
|
||||
if (cancelled) return;
|
||||
cachedImages = mergeCoverImageData(cachedImages, data);
|
||||
setImages(cachedImages);
|
||||
} catch {
|
||||
if (cancelled) return;
|
||||
}
|
||||
|
||||
const missingAnimeIds = animeIds.filter((id) => !cachedImages[getCoverImageKey('anime', id)]);
|
||||
const missingVideoIds = videoIds.filter((id) => !cachedImages[getCoverImageKey('media', id)]);
|
||||
if (missingAnimeIds.length === 0 && missingVideoIds.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
timer = setTimeout(() => {
|
||||
void load(missingAnimeIds, missingVideoIds, attempt + 1);
|
||||
}, getCoverRetryDelayMs(attempt));
|
||||
}
|
||||
|
||||
setImages({});
|
||||
void load(requests.animeIds, requests.videoIds, 0);
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
if (timer) clearTimeout(timer);
|
||||
};
|
||||
}, [requestKey]);
|
||||
|
||||
return images;
|
||||
}
|
||||
@@ -32,6 +32,43 @@ test('resolveStatsBaseUrl keeps legacy localhost fallback for file mode without
|
||||
assert.equal(baseUrl, 'http://127.0.0.1:6969');
|
||||
});
|
||||
|
||||
test('getAnimeCoverUrl appends retry tokens for late cover refreshes', () => {
|
||||
const getAnimeCoverUrl = apiClient.getAnimeCoverUrl as (
|
||||
animeId: number,
|
||||
retryToken?: number,
|
||||
) => string;
|
||||
|
||||
assert.equal(
|
||||
getAnimeCoverUrl(42, 3),
|
||||
'http://127.0.0.1:6969/api/stats/anime/42/cover?coverRetry=3',
|
||||
);
|
||||
});
|
||||
|
||||
test('getCoverImages batches anime and media cover requests', async () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
let seenUrl = '';
|
||||
let seenMethod = '';
|
||||
let seenBody = '';
|
||||
globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
seenUrl = String(input);
|
||||
seenMethod = init?.method ?? 'GET';
|
||||
seenBody = String(init?.body ?? '');
|
||||
return new Response(JSON.stringify({ anime: {}, media: {} }), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}) as typeof globalThis.fetch;
|
||||
|
||||
try {
|
||||
await apiClient.getCoverImages({ animeIds: [1, 1, 2], videoIds: [7, 7, 8] });
|
||||
assert.equal(seenUrl, `${BASE_URL}/api/stats/covers`);
|
||||
assert.equal(seenMethod, 'POST');
|
||||
assert.deepEqual(JSON.parse(seenBody), { animeIds: [1, 2], videoIds: [7, 8] });
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
|
||||
test('deleteSession sends a DELETE request to the session endpoint', async () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
let seenUrl = '';
|
||||
|
||||
@@ -23,7 +23,9 @@ import type {
|
||||
EpisodeDetailData,
|
||||
StatsAnkiNoteInfo,
|
||||
StatsExcludedWord,
|
||||
StatsCoverImagesData,
|
||||
} from '../types/stats';
|
||||
import { appendCoverRetryToken } from './cover-retry';
|
||||
|
||||
type StatsLocationLike = Pick<Location, 'protocol' | 'origin' | 'search'>;
|
||||
|
||||
@@ -65,6 +67,16 @@ async function fetchJson<T>(path: string): Promise<T> {
|
||||
return res.json() as Promise<T>;
|
||||
}
|
||||
|
||||
function uniquePositiveIds(ids: number[]): number[] {
|
||||
const uniqueIds = new Set<number>();
|
||||
for (const id of ids) {
|
||||
if (Number.isFinite(id) && id > 0) {
|
||||
uniqueIds.add(Math.floor(id));
|
||||
}
|
||||
}
|
||||
return Array.from(uniqueIds).sort((a, b) => a - b);
|
||||
}
|
||||
|
||||
export const apiClient = {
|
||||
getOverview: () => fetchJson<OverviewData>('/api/stats/overview'),
|
||||
getDailyRollups: (limit = 60) =>
|
||||
@@ -116,7 +128,22 @@ export const apiClient = {
|
||||
fetchJson<AnimeWord[]>(`/api/stats/anime/${animeId}/words?limit=${limit}`),
|
||||
getAnimeRollups: (animeId: number, limit = 90) =>
|
||||
fetchJson<DailyRollup[]>(`/api/stats/anime/${animeId}/rollups?limit=${limit}`),
|
||||
getAnimeCoverUrl: (animeId: number) => `${BASE_URL}/api/stats/anime/${animeId}/cover`,
|
||||
getAnimeCoverUrl: (animeId: number, retryToken = 0) =>
|
||||
appendCoverRetryToken(`${BASE_URL}/api/stats/anime/${animeId}/cover`, retryToken),
|
||||
getCoverImages: async (params: {
|
||||
animeIds: number[];
|
||||
videoIds: number[];
|
||||
}): Promise<StatsCoverImagesData> => {
|
||||
const res = await fetchResponse('/api/stats/covers', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
animeIds: uniquePositiveIds(params.animeIds),
|
||||
videoIds: uniquePositiveIds(params.videoIds),
|
||||
}),
|
||||
});
|
||||
return res.json() as Promise<StatsCoverImagesData>;
|
||||
},
|
||||
getStreakCalendar: (days = 90) =>
|
||||
fetchJson<StreakCalendarDay[]>(`/api/stats/streak-calendar?days=${days}`),
|
||||
getEpisodesPerDay: (limit = 90) =>
|
||||
@@ -175,6 +202,7 @@ export const apiClient = {
|
||||
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;
|
||||
}>
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { collectSessionCoverRequests, getCoverImageKey } from './cover-images';
|
||||
import type { SessionSummary } from '../types/stats';
|
||||
|
||||
function makeSession(overrides: Partial<SessionSummary> & { sessionId: number }): SessionSummary {
|
||||
const { sessionId, ...rest } = overrides;
|
||||
return {
|
||||
sessionId,
|
||||
canonicalTitle: null,
|
||||
videoId: null,
|
||||
animeId: null,
|
||||
animeTitle: null,
|
||||
startedAtMs: 0,
|
||||
endedAtMs: null,
|
||||
totalWatchedMs: 0,
|
||||
activeWatchedMs: 0,
|
||||
linesSeen: 0,
|
||||
tokensSeen: 0,
|
||||
cardsMined: 0,
|
||||
lookupCount: 0,
|
||||
lookupHits: 0,
|
||||
yomitanLookupCount: 0,
|
||||
knownWordsSeen: 0,
|
||||
knownWordRate: 0,
|
||||
...rest,
|
||||
};
|
||||
}
|
||||
|
||||
test('collectSessionCoverRequests dedupes anime ids and only requests media for ungrouped sessions', () => {
|
||||
const requests = collectSessionCoverRequests([
|
||||
makeSession({ sessionId: 1, animeId: 10, videoId: 100 }),
|
||||
makeSession({ sessionId: 2, animeId: 10, videoId: 101 }),
|
||||
makeSession({ sessionId: 3, animeId: null, videoId: 200 }),
|
||||
makeSession({ sessionId: 4, animeId: null, videoId: 200 }),
|
||||
]);
|
||||
|
||||
assert.deepEqual(requests, { animeIds: [10], videoIds: [200] });
|
||||
});
|
||||
|
||||
test('getCoverImageKey separates anime and media ids', () => {
|
||||
assert.equal(getCoverImageKey('anime', 1), 'anime:1');
|
||||
assert.equal(getCoverImageKey('media', 1), 'media:1');
|
||||
});
|
||||
@@ -0,0 +1,72 @@
|
||||
import type { SessionSummary, StatsCoverImagesData } from '../types/stats';
|
||||
|
||||
export type CoverImageKind = 'anime' | 'media';
|
||||
export type CoverImageMap = Record<string, string>;
|
||||
|
||||
export interface CoverImageRequest {
|
||||
animeIds: number[];
|
||||
videoIds: number[];
|
||||
}
|
||||
|
||||
function normalizePositiveIds(ids: Iterable<number | null | undefined>): number[] {
|
||||
const uniqueIds = new Set<number>();
|
||||
for (const id of ids) {
|
||||
if (typeof id === 'number' && Number.isFinite(id) && id > 0) {
|
||||
uniqueIds.add(Math.floor(id));
|
||||
}
|
||||
}
|
||||
return Array.from(uniqueIds).sort((a, b) => a - b);
|
||||
}
|
||||
|
||||
export function getCoverImageKey(kind: CoverImageKind, id: number): string {
|
||||
return `${kind}:${id}`;
|
||||
}
|
||||
|
||||
export function collectSessionCoverRequests(
|
||||
sessions: Pick<SessionSummary, 'animeId' | 'videoId'>[],
|
||||
): CoverImageRequest {
|
||||
const animeIds: number[] = [];
|
||||
const videoIds: number[] = [];
|
||||
|
||||
for (const session of sessions) {
|
||||
if (session.animeId != null) {
|
||||
animeIds.push(session.animeId);
|
||||
} else if (session.videoId != null) {
|
||||
videoIds.push(session.videoId);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
animeIds: normalizePositiveIds(animeIds),
|
||||
videoIds: normalizePositiveIds(videoIds),
|
||||
};
|
||||
}
|
||||
|
||||
export function mergeCoverImageData(
|
||||
previous: CoverImageMap,
|
||||
data: StatsCoverImagesData,
|
||||
): CoverImageMap {
|
||||
const next = { ...previous };
|
||||
|
||||
for (const [id, image] of Object.entries(data.anime)) {
|
||||
if (image?.dataUrl) {
|
||||
next[getCoverImageKey('anime', Number(id))] = image.dataUrl;
|
||||
}
|
||||
}
|
||||
|
||||
for (const [id, image] of Object.entries(data.media)) {
|
||||
if (image?.dataUrl) {
|
||||
next[getCoverImageKey('media', Number(id))] = image.dataUrl;
|
||||
}
|
||||
}
|
||||
|
||||
return next;
|
||||
}
|
||||
|
||||
export function getCoverImageSrc(
|
||||
images: CoverImageMap,
|
||||
kind: CoverImageKind,
|
||||
id: number | null,
|
||||
): string | null {
|
||||
return id == null ? null : (images[getCoverImageKey(kind, id)] ?? null);
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
const COVER_RETRY_PARAM = 'coverRetry';
|
||||
|
||||
export function appendCoverRetryToken(src: string, retryToken = 0): string {
|
||||
if (!Number.isFinite(retryToken) || retryToken <= 0) return src;
|
||||
|
||||
const normalizedToken = String(Math.trunc(retryToken));
|
||||
try {
|
||||
const url = new URL(src, 'http://subminer.local');
|
||||
url.searchParams.set(COVER_RETRY_PARAM, normalizedToken);
|
||||
if (src.startsWith('/')) {
|
||||
return `${url.pathname}${url.search}${url.hash}`;
|
||||
}
|
||||
return url.toString();
|
||||
} catch {
|
||||
const separator = src.includes('?') ? '&' : '?';
|
||||
return `${src}${separator}${COVER_RETRY_PARAM}=${encodeURIComponent(normalizedToken)}`;
|
||||
}
|
||||
}
|
||||
|
||||
export function getCoverRetryDelayMs(retryToken: number): number {
|
||||
return Math.min(30_000, 2_000 * 2 ** Math.min(Math.max(retryToken, 0), 4));
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import type { MediaLibraryItem } from '../types/stats';
|
||||
import {
|
||||
groupMediaLibraryItems,
|
||||
resolveMediaArtworkUrl,
|
||||
resolveMediaCoverApiUrl,
|
||||
summarizeMediaLibraryGroups,
|
||||
} from './media-library-grouping';
|
||||
import { CoverImage } from '../components/library/CoverImage';
|
||||
@@ -172,6 +173,13 @@ test('MediaCard uses the proxied cover endpoint instead of metadata artwork urls
|
||||
assert.doesNotMatch(markup, /https:\/\/i\.ytimg\.com\/vi\/yt-1\/hqdefault\.jpg/);
|
||||
});
|
||||
|
||||
test('resolveMediaCoverApiUrl appends retry tokens for late cover refreshes', () => {
|
||||
assert.equal(
|
||||
resolveMediaCoverApiUrl(youtubeEpisodeA.videoId, 2),
|
||||
'http://127.0.0.1:6969/api/stats/media/1/cover?coverRetry=2',
|
||||
);
|
||||
});
|
||||
|
||||
test('MediaCard prefers youtube video title over canonical fallback url slug', () => {
|
||||
const markup = renderToStaticMarkup(<MediaCard item={youtubeEpisodeA} onClick={() => {}} />);
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { BASE_URL } from './api-client';
|
||||
import { appendCoverRetryToken } from './cover-retry';
|
||||
import type { MediaLibraryItem } from '../types/stats';
|
||||
|
||||
export interface MediaLibraryGroup {
|
||||
@@ -22,8 +23,8 @@ export function resolveMediaArtworkUrl(
|
||||
return normalized.length > 0 ? normalized : null;
|
||||
}
|
||||
|
||||
export function resolveMediaCoverApiUrl(videoId: number): string {
|
||||
return `${BASE_URL}/api/stats/media/${videoId}/cover`;
|
||||
export function resolveMediaCoverApiUrl(videoId: number, retryToken = 0): string {
|
||||
return appendCoverRetryToken(`${BASE_URL}/api/stats/media/${videoId}/cover`, retryToken);
|
||||
}
|
||||
|
||||
export function summarizeMediaLibraryGroups(groups: MediaLibraryGroup[]): {
|
||||
|
||||
@@ -1,51 +1,52 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import assert from 'node:assert/strict';
|
||||
import { describe, it } from 'node:test';
|
||||
import { fullReading } from './reading-utils';
|
||||
|
||||
describe('fullReading', () => {
|
||||
it('prefixes leading hiragana from headword', () => {
|
||||
// お前 with reading まえ → おまえ
|
||||
expect(fullReading('お前', 'まえ')).toBe('おまえ');
|
||||
assert.equal(fullReading('お前', 'まえ'), 'おまえ');
|
||||
});
|
||||
|
||||
it('handles katakana stored readings', () => {
|
||||
// お前 with katakana reading マエ → おまえ
|
||||
expect(fullReading('お前', 'マエ')).toBe('おまえ');
|
||||
assert.equal(fullReading('お前', 'マエ'), 'おまえ');
|
||||
});
|
||||
|
||||
it('returns stored reading when it already includes leading kana', () => {
|
||||
// Reading already correct
|
||||
expect(fullReading('お前', 'おまえ')).toBe('おまえ');
|
||||
assert.equal(fullReading('お前', 'おまえ'), 'おまえ');
|
||||
});
|
||||
|
||||
it('handles trailing hiragana', () => {
|
||||
// 隠す with reading かくす — す is trailing hiragana
|
||||
expect(fullReading('隠す', 'かくす')).toBe('かくす');
|
||||
assert.equal(fullReading('隠す', 'かくす'), 'かくす');
|
||||
});
|
||||
|
||||
it('handles pure kanji headwords', () => {
|
||||
expect(fullReading('様', 'さま')).toBe('さま');
|
||||
assert.equal(fullReading('様', 'さま'), 'さま');
|
||||
});
|
||||
|
||||
it('returns empty for empty reading', () => {
|
||||
expect(fullReading('前', '')).toBe('');
|
||||
assert.equal(fullReading('前', ''), '');
|
||||
});
|
||||
|
||||
it('returns empty for empty headword', () => {
|
||||
expect(fullReading('', 'まえ')).toBe('まえ');
|
||||
assert.equal(fullReading('', 'まえ'), 'まえ');
|
||||
});
|
||||
|
||||
it('handles all-kana headword', () => {
|
||||
// Headword is already all hiragana
|
||||
expect(fullReading('いますぐ', 'いますぐ')).toBe('いますぐ');
|
||||
assert.equal(fullReading('いますぐ', 'いますぐ'), 'いますぐ');
|
||||
});
|
||||
|
||||
it('handles mixed leading and trailing kana', () => {
|
||||
// お気に入り: お=leading, に入り=trailing around 気
|
||||
expect(fullReading('お気に入り', 'きにいり')).toBe('おきにいり');
|
||||
assert.equal(fullReading('お気に入り', 'きにいり'), 'おきにいり');
|
||||
});
|
||||
|
||||
it('handles katakana in headword', () => {
|
||||
// カズマ様 — leading katakana + kanji
|
||||
expect(fullReading('カズマ様', 'さま')).toBe('かずまさま');
|
||||
assert.equal(fullReading('カズマ様', 'さま'), 'かずまさま');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -41,8 +41,10 @@ export function fullReading(headword: string, storedReading: string): string {
|
||||
const chars = [...headword];
|
||||
|
||||
let i = 0;
|
||||
while (i < chars.length && (isHiragana(chars[i]) || isKatakana(chars[i]))) {
|
||||
leadingKana.push(katakanaToHiragana(chars[i]));
|
||||
while (i < chars.length) {
|
||||
const ch = chars[i]!;
|
||||
if (!isHiragana(ch) && !isKatakana(ch)) break;
|
||||
leadingKana.push(katakanaToHiragana(ch));
|
||||
i++;
|
||||
}
|
||||
|
||||
@@ -51,8 +53,10 @@ export function fullReading(headword: string, storedReading: string): string {
|
||||
}
|
||||
|
||||
let j = chars.length - 1;
|
||||
while (j > i && (isHiragana(chars[j]) || isKatakana(chars[j]))) {
|
||||
trailingKana.unshift(katakanaToHiragana(chars[j]));
|
||||
while (j > i) {
|
||||
const ch = chars[j]!;
|
||||
if (!isHiragana(ch) && !isKatakana(ch)) break;
|
||||
trailingKana.unshift(katakanaToHiragana(ch));
|
||||
j--;
|
||||
}
|
||||
|
||||
|
||||
@@ -4,8 +4,9 @@ import type { SessionSummary } from '../types/stats';
|
||||
import { groupSessionsByVideo } from './session-grouping';
|
||||
|
||||
function makeSession(overrides: Partial<SessionSummary> & { sessionId: number }): SessionSummary {
|
||||
const { sessionId, ...rest } = overrides;
|
||||
return {
|
||||
sessionId: overrides.sessionId,
|
||||
sessionId,
|
||||
canonicalTitle: null,
|
||||
videoId: null,
|
||||
animeId: null,
|
||||
@@ -22,7 +23,7 @@ function makeSession(overrides: Partial<SessionSummary> & { sessionId: number })
|
||||
yomitanLookupCount: 0,
|
||||
knownWordsSeen: 0,
|
||||
knownWordRate: 0,
|
||||
...overrides,
|
||||
...rest,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -132,7 +132,6 @@ test('AnimeOverviewStats renders aggregate Yomitan lookup metrics', () => {
|
||||
episodeCount: 3,
|
||||
lastWatchedMs: 0,
|
||||
}}
|
||||
avgSessionMs={20_000}
|
||||
knownWordsSummary={null}
|
||||
/>,
|
||||
);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import test from 'node:test';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const css = readFileSync(fileURLToPath(new URL('./globals.css', import.meta.url)), 'utf8');
|
||||
|
||||
@@ -82,6 +82,16 @@ export interface StatsExcludedWord {
|
||||
reading: string;
|
||||
}
|
||||
|
||||
export interface StatsCoverImage {
|
||||
contentType: string;
|
||||
dataUrl: string;
|
||||
}
|
||||
|
||||
export interface StatsCoverImagesData {
|
||||
anime: Record<string, StatsCoverImage | null>;
|
||||
media: Record<string, StatsCoverImage | null>;
|
||||
}
|
||||
|
||||
export interface KanjiEntry {
|
||||
kanjiId: number;
|
||||
kanji: string;
|
||||
|
||||
Reference in New Issue
Block a user