diff --git a/changes/fix-stats-cover-refresh.md b/changes/fix-stats-cover-refresh.md new file mode 100644 index 00000000..4898ec90 --- /dev/null +++ b/changes/fix-stats-cover-refresh.md @@ -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. diff --git a/changes/overlay-mining-image-toast.md b/changes/overlay-mining-image-toast.md new file mode 100644 index 00000000..0947fbc3 --- /dev/null +++ b/changes/overlay-mining-image-toast.md @@ -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. diff --git a/changes/stats-delete-progress-indicator.md b/changes/stats-delete-progress-indicator.md new file mode 100644 index 00000000..8652f651 --- /dev/null +++ b/changes/stats-delete-progress-indicator.md @@ -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. diff --git a/changes/stats-session-delete-speed.md b/changes/stats-session-delete-speed.md new file mode 100644 index 00000000..f36a73bc --- /dev/null +++ b/changes/stats-session-delete-speed.md @@ -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. diff --git a/docs-site/anki-integration.md b/docs-site/anki-integration.md index 49f16ba3..a0f89ab3 100644 --- a/docs-site/anki-integration.md +++ b/docs-site/anki-integration.md @@ -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 diff --git a/src/anki-integration.test.ts b/src/anki-integration.test.ts index 03bf4b37..f79c0858 100644 --- a/src/anki-integration.test.ts +++ b/src/anki-integration.test.ts @@ -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; + }; + showNotification: ( + noteId: number, + label: string | number, + errorSuffix?: string, + ) => Promise; + }; + 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(); diff --git a/src/anki-integration.ts b/src/anki-integration.ts index 5a890c5c..22eff7fc 100644 --- a/src/anki-integration.ts +++ b/src/anki-integration.ts @@ -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(); @@ -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; } diff --git a/src/core/services/__tests__/stats-server.test.ts b/src/core/services/__tests__/stats-server.test.ts index 2063055f..b7aa3656 100644 --- a/src/core/services/__tests__/stats-server.test.ts +++ b/src/core/services/__tests__/stats-server.test.ts @@ -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( diff --git a/src/core/services/immersion-tracker/__tests__/query-split-modules.test.ts b/src/core/services/immersion-tracker/__tests__/query-split-modules.test.ts index dc78c125..a7543a72 100644 --- a/src/core/services/immersion-tracker/__tests__/query-split-modules.test.ts +++ b/src/core/services/immersion-tracker/__tests__/query-split-modules.test.ts @@ -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(); diff --git a/src/core/services/immersion-tracker/lifetime.ts b/src/core/services/immersion-tracker/lifetime.ts index 5d422ec2..d3ba121a 100644 --- a/src/core/services/immersion-tracker/lifetime.ts +++ b/src/core/services/immersion-tracker/lifetime.ts @@ -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 & { - 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, }; } diff --git a/src/core/services/immersion-tracker/maintenance.ts b/src/core/services/immersion-tracker/maintenance.ts index 1e52ee4a..69f1bdbb 100644 --- a/src/core/services/immersion-tracker/maintenance.ts +++ b/src/core/services/immersion-tracker/maintenance.ts @@ -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, diff --git a/src/core/services/immersion-tracker/query-maintenance.ts b/src/core/services/immersion-tracker/query-maintenance.ts index e94aeeb5..56152a1e 100644 --- a/src/core/services/immersion-tracker/query-maintenance.ts +++ b/src/core/services/immersion-tracker/query-maintenance.ts @@ -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'); diff --git a/src/core/services/stats-server.ts b/src/core/services/stats-server.ts index c6aac6d9..78fab283 100644 --- a/src/core/services/stats-server.ts +++ b/src/core/services/stats-server.ts @@ -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(); + 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 = {}; + const media: Record = {}; + + 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); diff --git a/src/main.ts b/src/main.ts index c761ee86..3e0b4d0e 100644 --- a/src/main.ts +++ b/src/main.ts @@ -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, diff --git a/src/preload.ts b/src/preload.ts index 7cf33083..fb8a2a18 100644 --- a/src/preload.ts +++ b/src/preload.ts @@ -51,6 +51,7 @@ import type { OverlayContentMeasurement, ShortcutsConfig, ConfigHotReloadPayload, + MiningImagePayload, ControllerConfigUpdate, ControllerPreferenceUpdate, ResolvedControllerConfig, @@ -222,6 +223,10 @@ const onSecondarySubtitleModeEvent = createLatestValueIpcListenerWithPayload payload as SecondarySubMode, ); +const onMiningImageEvent = createQueuedIpcListenerWithPayload( + 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); diff --git a/src/renderer/index.html b/src/renderer/index.html index 5b990df4..f1de79f7 100644 --- a/src/renderer/index.html +++ b/src/renderer/index.html @@ -42,6 +42,9 @@ role="status" aria-live="polite" > +
diff --git a/src/renderer/renderer.ts b/src/renderer/renderer.ts index 090f1c47..1ac71b76 100644 --- a/src/renderer/renderer.ts +++ b/src/renderer/renderer.ts @@ -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 | null = null; +let miningImageToastTimeout: ReturnType | 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 { measurementReporter.schedule(); }); }); + window.electronAPI.onMiningImage((payload: MiningImagePayload) => { + runGuarded('mining:image', () => { + showMiningImageToast(payload.image); + }); + }); mouseHandlers.setupDragging(); try { ctx.state.controllerConfig = await window.electronAPI.getControllerConfig(); diff --git a/src/renderer/style.css b/src/renderer/style.css index 3e7f402d..4be1724b 100644 --- a/src/renderer/style.css +++ b/src/renderer/style.css @@ -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; diff --git a/src/renderer/utils/dom.ts b/src/renderer/utils/dom.ts index bbddb2ac..43c94325 100644 --- a/src/renderer/utils/dom.ts +++ b/src/renderer/utils/dom.ts @@ -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('overlay'), controllerStatusToast: getRequiredElement('controllerStatusToast'), overlayErrorToast: getRequiredElement('overlayErrorToast'), + miningImageToast: getRequiredElement('miningImageToast'), + miningImageToastImage: getRequiredElement('miningImageToastImage'), secondarySubContainer: getRequiredElement('secondarySubContainer'), secondarySubRoot: getRequiredElement('secondarySubRoot'), diff --git a/src/shared/ipc/contracts.ts b/src/shared/ipc/contracts.ts index 449eb240..fd2ceed4 100644 --- a/src/shared/ipc/contracts.ts +++ b/src/shared/ipc/contracts.ts @@ -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; diff --git a/src/types/runtime.ts b/src/types/runtime.ts index bd0e82a5..9bcf2133 100644 --- a/src/types/runtime.ts +++ b/src/types/runtime.ts @@ -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 { diff --git a/stats/src/components/anime/AnilistSelector.tsx b/stats/src/components/anime/AnilistSelector.tsx index 8060ccd9..d648d72a 100644 --- a/stats/src/components/anime/AnilistSelector.tsx +++ b/stats/src/components/anime/AnilistSelector.tsx @@ -29,7 +29,7 @@ export function AnilistSelector({ const [loading, setLoading] = useState(false); const [linking, setLinking] = useState(null); const inputRef = useRef(null); - const debounceRef = useRef>(); + const debounceRef = useRef | 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); }; diff --git a/stats/src/components/anime/AnimeCoverImage.tsx b/stats/src/components/anime/AnimeCoverImage.tsx index e3287b6d..cfe7585c 100644 --- a/stats/src/components/anime/AnimeCoverImage.tsx +++ b/stats/src/components/anime/AnimeCoverImage.tsx @@ -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 ( -
- {fallbackChar} -
- ); - } - const src = getStatsClient().getAnimeCoverUrl(animeId); - return ( - {title} setFailed(true)} - /> - ); + return ; } diff --git a/stats/src/components/common/DeleteProgressToast.tsx b/stats/src/components/common/DeleteProgressToast.tsx new file mode 100644 index 00000000..3b9b31ff --- /dev/null +++ b/stats/src/components/common/DeleteProgressToast.tsx @@ -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 ( +
+
+ ); +} diff --git a/stats/src/components/common/RetryingCoverImage.tsx b/stats/src/components/common/RetryingCoverImage.tsx new file mode 100644 index 00000000..1ee01ec8 --- /dev/null +++ b/stats/src/components/common/RetryingCoverImage.tsx @@ -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 ( +
+ {fallbackChar} +
+ ); + } + + return ( + {alt} setFailed(true)} + onLoad={() => setFailed(false)} + /> + ); +} diff --git a/stats/src/components/library/CoverImage.tsx b/stats/src/components/library/CoverImage.tsx index b369b431..81c8f553 100644 --- a/stats/src/components/library/CoverImage.tsx +++ b/stats/src/components/library/CoverImage.tsx @@ -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 ( -
- {fallbackChar} -
- ); - } - return ( - {title} setFailed(true)} - /> + ); } diff --git a/stats/src/components/overview/CoverThumbnail.tsx b/stats/src/components/overview/CoverThumbnail.tsx new file mode 100644 index 00000000..dc03b87a --- /dev/null +++ b/stats/src/components/overview/CoverThumbnail.tsx @@ -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 = ( +
+ {fallbackChar} +
+ ); + + const src = + animeId != null + ? getCoverImageSrc(coverImages, 'anime', animeId) + : getCoverImageSrc(coverImages, 'media', videoId); + + useEffect(() => { + setFailed(false); + }, [src]); + + if (!src || failed) { + return fallback; + } + + return ( + setFailed(true)} + /> + ); +} diff --git a/stats/src/components/overview/OverviewTab.tsx b/stats/src/components/overview/OverviewTab.tsx index f2b95811..89aea439 100644 --- a/stats/src/components/overview/OverviewTab.tsx +++ b/stats/src/components/overview/OverviewTab.tsx @@ -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} /> + + ); } diff --git a/stats/src/components/overview/RecentSessions.tsx b/stats/src/components/overview/RecentSessions.tsx index 92a3244b..e9526113 100644 --- a/stats/src/components/overview/RecentSessions.tsx +++ b/stats/src/components/overview/RecentSessions.tsx @@ -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 ( -
- {fallbackChar} -
- ); - } - - const src = - animeId != null - ? `${BASE_URL}/api/stats/anime/${animeId}/cover` - : `${BASE_URL}/api/stats/media/${videoId}/cover`; - - return ( - 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} />
@@ -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; + 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} />
{displayTitle}
@@ -319,6 +293,7 @@ function AnimeGroupRow({ animeId={s.animeId} videoId={s.videoId} title={s.canonicalTitle ?? 'Unknown'} + coverImages={coverImages} />
@@ -378,6 +353,8 @@ export function RecentSessions({ onDeleteAnimeGroup, deletingIds, }: RecentSessionsProps) { + const coverImages = useCoverImages(sessions); + if (sessions.length === 0) { return (
@@ -422,6 +399,7 @@ export function RecentSessions({ onDeleteSession={onDeleteSession} onDeleteAnimeGroup={(g) => onDeleteAnimeGroup(g.sessions)} deletingIds={deletingIds} + coverImages={coverImages} /> ))}
diff --git a/stats/src/components/sessions/SessionsTab.test.tsx b/stats/src/components/sessions/SessionsTab.test.tsx index 956a49bc..2d92fc5a 100644 --- a/stats/src/components/sessions/SessionsTab.test.tsx +++ b/stats/src/components/sessions/SessionsTab.test.tsx @@ -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; diff --git a/stats/src/components/sessions/SessionsTab.tsx b/stats/src/components/sessions/SessionsTab.tsx index d4cd8593..74fadcae 100644 --- a/stats/src/components/sessions/SessionsTab.tsx +++ b/stats/src/components/sessions/SessionsTab.tsx @@ -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 }; confirm: (title: string, count: number) => boolean | Promise; + /** 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 { - 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(null); const [deletingSessionId, setDeletingSessionId] = useState(null); const [deletingBucketKey, setDeletingBucketKey] = useState(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.'}
)} + +
); } diff --git a/stats/src/hooks/useCoverImages.ts b/stats/src/hooks/useCoverImages.ts new file mode 100644 index 00000000..b0ff4a08 --- /dev/null +++ b/stats/src/hooks/useCoverImages.ts @@ -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({}); + + useEffect(() => { + let cancelled = false; + let timer: ReturnType | null = null; + let cachedImages: CoverImageMap = {}; + const client = getStatsClient(); + + async function load(animeIds: number[], videoIds: number[], attempt: number): Promise { + 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; +} diff --git a/stats/src/lib/api-client.test.ts b/stats/src/lib/api-client.test.ts index b1973b0c..bb9d8254 100644 --- a/stats/src/lib/api-client.test.ts +++ b/stats/src/lib/api-client.test.ts @@ -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 = ''; diff --git a/stats/src/lib/api-client.ts b/stats/src/lib/api-client.ts index 320fd66d..3291fb18 100644 --- a/stats/src/lib/api-client.ts +++ b/stats/src/lib/api-client.ts @@ -23,7 +23,9 @@ import type { EpisodeDetailData, StatsAnkiNoteInfo, StatsExcludedWord, + StatsCoverImagesData, } from '../types/stats'; +import { appendCoverRetryToken } from './cover-retry'; type StatsLocationLike = Pick; @@ -65,6 +67,16 @@ async function fetchJson(path: string): Promise { return res.json() as Promise; } +function uniquePositiveIds(ids: number[]): number[] { + const uniqueIds = new Set(); + 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('/api/stats/overview'), getDailyRollups: (limit = 60) => @@ -116,7 +128,22 @@ export const apiClient = { fetchJson(`/api/stats/anime/${animeId}/words?limit=${limit}`), getAnimeRollups: (animeId: number, limit = 90) => fetchJson(`/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 => { + 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; + }, getStreakCalendar: (days = 90) => fetchJson(`/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; }> diff --git a/stats/src/lib/cover-images.test.ts b/stats/src/lib/cover-images.test.ts new file mode 100644 index 00000000..348a234f --- /dev/null +++ b/stats/src/lib/cover-images.test.ts @@ -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 & { 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'); +}); diff --git a/stats/src/lib/cover-images.ts b/stats/src/lib/cover-images.ts new file mode 100644 index 00000000..aeaa21f8 --- /dev/null +++ b/stats/src/lib/cover-images.ts @@ -0,0 +1,72 @@ +import type { SessionSummary, StatsCoverImagesData } from '../types/stats'; + +export type CoverImageKind = 'anime' | 'media'; +export type CoverImageMap = Record; + +export interface CoverImageRequest { + animeIds: number[]; + videoIds: number[]; +} + +function normalizePositiveIds(ids: Iterable): number[] { + const uniqueIds = new Set(); + 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[], +): 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); +} diff --git a/stats/src/lib/cover-retry.ts b/stats/src/lib/cover-retry.ts new file mode 100644 index 00000000..61c17719 --- /dev/null +++ b/stats/src/lib/cover-retry.ts @@ -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)); +} diff --git a/stats/src/lib/media-library-grouping.test.tsx b/stats/src/lib/media-library-grouping.test.tsx index 7006f57e..e57edf04 100644 --- a/stats/src/lib/media-library-grouping.test.tsx +++ b/stats/src/lib/media-library-grouping.test.tsx @@ -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( {}} />); diff --git a/stats/src/lib/media-library-grouping.ts b/stats/src/lib/media-library-grouping.ts index 46bcd560..ff0a564c 100644 --- a/stats/src/lib/media-library-grouping.ts +++ b/stats/src/lib/media-library-grouping.ts @@ -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[]): { diff --git a/stats/src/lib/reading-utils.test.ts b/stats/src/lib/reading-utils.test.ts index 80f6b78e..8217e892 100644 --- a/stats/src/lib/reading-utils.test.ts +++ b/stats/src/lib/reading-utils.test.ts @@ -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('カズマ様', 'さま'), 'かずまさま'); }); }); diff --git a/stats/src/lib/reading-utils.ts b/stats/src/lib/reading-utils.ts index 6edcee4b..83e5d440 100644 --- a/stats/src/lib/reading-utils.ts +++ b/stats/src/lib/reading-utils.ts @@ -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--; } diff --git a/stats/src/lib/session-grouping.test.ts b/stats/src/lib/session-grouping.test.ts index 3215a447..0a71bc1b 100644 --- a/stats/src/lib/session-grouping.test.ts +++ b/stats/src/lib/session-grouping.test.ts @@ -4,8 +4,9 @@ import type { SessionSummary } from '../types/stats'; import { groupSessionsByVideo } from './session-grouping'; function makeSession(overrides: Partial & { 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 & { sessionId: number }) yomitanLookupCount: 0, knownWordsSeen: 0, knownWordRate: 0, - ...overrides, + ...rest, }; } diff --git a/stats/src/lib/yomitan-lookup.test.tsx b/stats/src/lib/yomitan-lookup.test.tsx index 4c1e31d7..145f39d2 100644 --- a/stats/src/lib/yomitan-lookup.test.tsx +++ b/stats/src/lib/yomitan-lookup.test.tsx @@ -132,7 +132,6 @@ test('AnimeOverviewStats renders aggregate Yomitan lookup metrics', () => { episodeCount: 3, lastWatchedMs: 0, }} - avgSessionMs={20_000} knownWordsSummary={null} />, ); diff --git a/stats/src/styles/globals.test.ts b/stats/src/styles/globals.test.ts index 6b1ac781..7055c73e 100644 --- a/stats/src/styles/globals.test.ts +++ b/stats/src/styles/globals.test.ts @@ -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'); diff --git a/stats/src/types/stats.ts b/stats/src/types/stats.ts index 2686a052..e243c91e 100644 --- a/stats/src/types/stats.ts +++ b/stats/src/types/stats.ts @@ -82,6 +82,16 @@ export interface StatsExcludedWord { reading: string; } +export interface StatsCoverImage { + contentType: string; + dataUrl: string; +} + +export interface StatsCoverImagesData { + anime: Record; + media: Record; +} + export interface KanjiEntry { kanjiId: number; kanji: string;