mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-07-30 07:21:32 -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.
|
`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
|
## 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)']);
|
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 () => {
|
test('FieldGroupingMergeCollaborator keeps SentenceAudio grouped without overwriting ExpressionAudio', async () => {
|
||||||
const collaborator = createFieldGroupingMergeCollaborator();
|
const collaborator = createFieldGroupingMergeCollaborator();
|
||||||
|
|
||||||
|
|||||||
+44
-25
@@ -148,6 +148,7 @@ export class AnkiIntegration {
|
|||||||
private runtime: AnkiIntegrationRuntime;
|
private runtime: AnkiIntegrationRuntime;
|
||||||
private aiConfig: AiConfig;
|
private aiConfig: AiConfig;
|
||||||
private recordCardsMinedCallback: ((count: number, noteIds?: number[]) => void) | null = null;
|
private recordCardsMinedCallback: ((count: number, noteIds?: number[]) => void) | null = null;
|
||||||
|
private miningImageOverlayCallback: ((image: string) => void) | null = null;
|
||||||
private knownWordCacheUpdatedCallback: (() => void) | null = null;
|
private knownWordCacheUpdatedCallback: (() => void) | null = null;
|
||||||
private consumeSubtitleMiningContextCallback: (() => SubtitleMiningContext | null) | null = null;
|
private consumeSubtitleMiningContextCallback: (() => SubtitleMiningContext | null) | null = null;
|
||||||
private noteIdRedirects = new Map<number, number>();
|
private noteIdRedirects = new Map<number, number>();
|
||||||
@@ -1018,40 +1019,54 @@ export class AnkiIntegration {
|
|||||||
: `Updated card: ${label}`;
|
: `Updated card: ${label}`;
|
||||||
|
|
||||||
const type = this.config.behavior?.notificationType || 'osd';
|
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);
|
this.showUpdateResult(message, errorSuffix === undefined);
|
||||||
} else {
|
} else {
|
||||||
this.clearUpdateProgress();
|
this.clearUpdateProgress();
|
||||||
}
|
}
|
||||||
|
|
||||||
if ((type === 'system' || type === 'both') && this.notificationCallback) {
|
if (!wantsSystem && !wantsOverlayImage) {
|
||||||
let notificationIconPath: string | undefined;
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (this.mpvClient && this.mpvClient.currentVideoPath) {
|
// Generate the frame screenshot once and reuse it for the system notification
|
||||||
try {
|
// icon and the in-overlay image toast.
|
||||||
const timestamp = this.mpvClient.currentTimePos || 0;
|
let iconBuffer: Buffer | undefined;
|
||||||
const notificationIconSource = await resolveMediaGenerationInputPath(
|
if (this.mpvClient && this.mpvClient.currentVideoPath) {
|
||||||
this.mpvClient,
|
try {
|
||||||
'video',
|
const timestamp = this.mpvClient.currentTimePos || 0;
|
||||||
);
|
const notificationIconSource = await resolveMediaGenerationInputPath(
|
||||||
if (!notificationIconSource) {
|
this.mpvClient,
|
||||||
throw new Error('No media source available for notification icon');
|
'video',
|
||||||
}
|
);
|
||||||
const iconBuffer = await this.mediaGenerator.generateNotificationIcon(
|
if (!notificationIconSource) {
|
||||||
notificationIconSource,
|
throw new Error('No media source available for notification icon');
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
|
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', {
|
this.notificationCallback('Anki Card Updated', {
|
||||||
body: message,
|
body: message,
|
||||||
@@ -1364,6 +1379,10 @@ export class AnkiIntegration {
|
|||||||
this.knownWordCacheUpdatedCallback = callback;
|
this.knownWordCacheUpdatedCallback = callback;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setMiningImageOverlayCallback(callback: ((image: string) => void) | null): void {
|
||||||
|
this.miningImageOverlayCallback = callback;
|
||||||
|
}
|
||||||
|
|
||||||
setSubtitleMiningContextConsumer(callback: (() => SubtitleMiningContext | null) | null): void {
|
setSubtitleMiningContextConsumer(callback: (() => SubtitleMiningContext | null) | null): void {
|
||||||
this.consumeSubtitleMiningContextCallback = callback;
|
this.consumeSubtitleMiningContextCallback = callback;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -833,6 +833,56 @@ describe('stats server API routes', () => {
|
|||||||
assert.equal(res.status, 404);
|
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 () => {
|
it('GET /api/stats/anime/:animeId/words returns top words for an anime', async () => {
|
||||||
let seenArgs: unknown[] = [];
|
let seenArgs: unknown[] = [];
|
||||||
const app = createStatsApp(
|
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', () => {
|
test('split maintenance helpers delete multiple sessions and whole videos with dependent rows', () => {
|
||||||
const { db, dbPath, stmts } = createDb();
|
const { db, dbPath, stmts } = createDb();
|
||||||
|
|
||||||
|
|||||||
@@ -60,6 +60,34 @@ interface RetainedSessionRow {
|
|||||||
mediaBufferEvents: number;
|
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(
|
function hasRetainedPriorSession(
|
||||||
db: DatabaseSync,
|
db: DatabaseSync,
|
||||||
videoId: number,
|
videoId: number,
|
||||||
@@ -154,54 +182,150 @@ function rebuildLifetimeSummariesInternal(
|
|||||||
db: DatabaseSync,
|
db: DatabaseSync,
|
||||||
rebuiltAtMs: number,
|
rebuiltAtMs: number,
|
||||||
): LifetimeRebuildSummary {
|
): LifetimeRebuildSummary {
|
||||||
const rows = db
|
const rebuiltAtDbMs = toDbTimestamp(rebuiltAtMs);
|
||||||
.prepare(
|
const appliedSessions = Number(
|
||||||
`
|
(
|
||||||
SELECT
|
db
|
||||||
session_id AS sessionId,
|
.prepare('SELECT COUNT(*) AS total FROM imm_sessions WHERE ended_at_ms IS NOT NULL')
|
||||||
video_id AS videoId,
|
.get() as { total: number }
|
||||||
started_at_ms AS startedAtMs,
|
).total,
|
||||||
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[];
|
|
||||||
|
|
||||||
resetLifetimeSummaries(db, rebuiltAtMs);
|
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 {
|
return {
|
||||||
appliedSessions: sessions.length,
|
appliedSessions,
|
||||||
rebuiltAtMs,
|
rebuiltAtMs,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { DatabaseSync } from './sqlite';
|
import type { DatabaseSync } from './sqlite';
|
||||||
import { nowMs } from './time';
|
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 ROLLUP_STATE_KEY = 'last_rollup_sample_ms';
|
||||||
const DAILY_MS = 86_400_000;
|
const DAILY_MS = 86_400_000;
|
||||||
@@ -20,6 +20,12 @@ interface RollupTelemetryResult {
|
|||||||
maxSampleMs: number | null;
|
maxSampleMs: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface RollupGroup {
|
||||||
|
rollupDay: number;
|
||||||
|
rollupMonth: number;
|
||||||
|
videoId: number;
|
||||||
|
}
|
||||||
|
|
||||||
interface RawRetentionResult {
|
interface RawRetentionResult {
|
||||||
deletedSessionEvents: number;
|
deletedSessionEvents: number;
|
||||||
deletedTelemetryRows: number;
|
deletedTelemetryRows: number;
|
||||||
@@ -164,6 +170,26 @@ function upsertDailyRollupsForGroups(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const upsertStmt = db.prepare(`
|
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 (
|
INSERT INTO imm_daily_rollups (
|
||||||
rollup_day, video_id, total_sessions, total_active_min, total_lines_seen,
|
rollup_day, video_id, total_sessions, total_active_min, total_lines_seen,
|
||||||
total_tokens_seen, total_cards, cards_per_hour,
|
total_tokens_seen, total_cards, cards_per_hour,
|
||||||
@@ -197,20 +223,8 @@ function upsertDailyRollupsForGroups(
|
|||||||
END AS lookup_hit_rate,
|
END AS lookup_hit_rate,
|
||||||
? AS CREATED_DATE,
|
? AS CREATED_DATE,
|
||||||
? AS LAST_UPDATE_DATE
|
? AS LAST_UPDATE_DATE
|
||||||
FROM imm_sessions s
|
FROM matching_sessions s
|
||||||
LEFT JOIN (
|
LEFT JOIN session_metrics sm ON s.session_id = sm.session_id
|
||||||
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 = ?
|
|
||||||
GROUP BY rollup_day, s.video_id
|
GROUP BY rollup_day, s.video_id
|
||||||
ON CONFLICT (rollup_day, video_id) DO UPDATE SET
|
ON CONFLICT (rollup_day, video_id) DO UPDATE SET
|
||||||
total_sessions = excluded.total_sessions,
|
total_sessions = excluded.total_sessions,
|
||||||
@@ -226,7 +240,7 @@ function upsertDailyRollupsForGroups(
|
|||||||
`);
|
`);
|
||||||
|
|
||||||
for (const { rollupDay, videoId } of groups) {
|
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(`
|
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 (
|
INSERT INTO imm_monthly_rollups (
|
||||||
rollup_month, video_id, total_sessions, total_active_min, total_lines_seen,
|
rollup_month, video_id, total_sessions, total_active_min, total_lines_seen,
|
||||||
total_tokens_seen, total_cards, CREATED_DATE, LAST_UPDATE_DATE
|
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,
|
COALESCE(SUM(COALESCE(sm.max_cards, s.cards_mined)), 0) AS total_cards,
|
||||||
? AS CREATED_DATE,
|
? AS CREATED_DATE,
|
||||||
? AS LAST_UPDATE_DATE
|
? AS LAST_UPDATE_DATE
|
||||||
FROM imm_sessions s
|
FROM matching_sessions s
|
||||||
LEFT JOIN (
|
LEFT JOIN session_metrics sm ON s.session_id = sm.session_id
|
||||||
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 = ?
|
|
||||||
GROUP BY rollup_month, s.video_id
|
GROUP BY rollup_month, s.video_id
|
||||||
ON CONFLICT (rollup_month, video_id) DO UPDATE SET
|
ON CONFLICT (rollup_month, video_id) DO UPDATE SET
|
||||||
total_sessions = excluded.total_sessions,
|
total_sessions = excluded.total_sessions,
|
||||||
@@ -278,10 +300,82 @@ function upsertMonthlyRollupsForGroups(
|
|||||||
`);
|
`);
|
||||||
|
|
||||||
for (const { rollupMonth, videoId } of groups) {
|
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(
|
function getAffectedRollupGroups(
|
||||||
db: DatabaseSync,
|
db: DatabaseSync,
|
||||||
lastRollupSampleMs: number | string,
|
lastRollupSampleMs: number | string,
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { createHash } from 'node:crypto';
|
|||||||
import type { DatabaseSync } from './sqlite';
|
import type { DatabaseSync } from './sqlite';
|
||||||
import { buildCoverBlobReference, normalizeCoverBlobBytes } from './storage';
|
import { buildCoverBlobReference, normalizeCoverBlobBytes } from './storage';
|
||||||
import { rebuildLifetimeSummariesInTransaction } from './lifetime';
|
import { rebuildLifetimeSummariesInTransaction } from './lifetime';
|
||||||
import { rebuildRollupsInTransaction } from './maintenance';
|
import { getRollupGroupsForSessions, refreshRollupsForGroupsInTransaction } from './maintenance';
|
||||||
import { nowMs } from './time';
|
import { nowMs } from './time';
|
||||||
import { PartOfSpeech, type MergedToken } from '../../../types';
|
import { PartOfSpeech, type MergedToken } from '../../../types';
|
||||||
import { shouldExcludeTokenFromVocabularyPersistence } from '../tokenizer/annotation-stage';
|
import { shouldExcludeTokenFromVocabularyPersistence } from '../tokenizer/annotation-stage';
|
||||||
@@ -474,13 +474,14 @@ export function deleteSession(db: DatabaseSync, sessionId: number): void {
|
|||||||
const sessionIds = [sessionId];
|
const sessionIds = [sessionId];
|
||||||
const affectedWordIds = getAffectedWordIdsForSessions(db, sessionIds);
|
const affectedWordIds = getAffectedWordIdsForSessions(db, sessionIds);
|
||||||
const affectedKanjiIds = getAffectedKanjiIdsForSessions(db, sessionIds);
|
const affectedKanjiIds = getAffectedKanjiIdsForSessions(db, sessionIds);
|
||||||
|
const affectedRollupGroups = getRollupGroupsForSessions(db, sessionIds);
|
||||||
|
|
||||||
db.exec('BEGIN IMMEDIATE');
|
db.exec('BEGIN IMMEDIATE');
|
||||||
try {
|
try {
|
||||||
deleteSessionsByIds(db, sessionIds);
|
deleteSessionsByIds(db, sessionIds);
|
||||||
refreshLexicalAggregates(db, affectedWordIds, affectedKanjiIds);
|
refreshLexicalAggregates(db, affectedWordIds, affectedKanjiIds);
|
||||||
rebuildLifetimeSummariesInTransaction(db);
|
rebuildLifetimeSummariesInTransaction(db);
|
||||||
rebuildRollupsInTransaction(db);
|
refreshRollupsForGroupsInTransaction(db, affectedRollupGroups);
|
||||||
db.exec('COMMIT');
|
db.exec('COMMIT');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
db.exec('ROLLBACK');
|
db.exec('ROLLBACK');
|
||||||
@@ -492,13 +493,14 @@ export function deleteSessions(db: DatabaseSync, sessionIds: number[]): void {
|
|||||||
if (sessionIds.length === 0) return;
|
if (sessionIds.length === 0) return;
|
||||||
const affectedWordIds = getAffectedWordIdsForSessions(db, sessionIds);
|
const affectedWordIds = getAffectedWordIdsForSessions(db, sessionIds);
|
||||||
const affectedKanjiIds = getAffectedKanjiIdsForSessions(db, sessionIds);
|
const affectedKanjiIds = getAffectedKanjiIdsForSessions(db, sessionIds);
|
||||||
|
const affectedRollupGroups = getRollupGroupsForSessions(db, sessionIds);
|
||||||
|
|
||||||
db.exec('BEGIN IMMEDIATE');
|
db.exec('BEGIN IMMEDIATE');
|
||||||
try {
|
try {
|
||||||
deleteSessionsByIds(db, sessionIds);
|
deleteSessionsByIds(db, sessionIds);
|
||||||
refreshLexicalAggregates(db, affectedWordIds, affectedKanjiIds);
|
refreshLexicalAggregates(db, affectedWordIds, affectedKanjiIds);
|
||||||
rebuildLifetimeSummariesInTransaction(db);
|
rebuildLifetimeSummariesInTransaction(db);
|
||||||
rebuildRollupsInTransaction(db);
|
refreshRollupsForGroupsInTransaction(db, affectedRollupGroups);
|
||||||
db.exec('COMMIT');
|
db.exec('COMMIT');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
db.exec('ROLLBACK');
|
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);
|
db.prepare('DELETE FROM imm_videos WHERE video_id = ?').run(videoId);
|
||||||
refreshLexicalAggregates(db, affectedWordIds, affectedKanjiIds);
|
refreshLexicalAggregates(db, affectedWordIds, affectedKanjiIds);
|
||||||
rebuildLifetimeSummariesInTransaction(db);
|
rebuildLifetimeSummariesInTransaction(db);
|
||||||
rebuildRollupsInTransaction(db);
|
|
||||||
db.exec('COMMIT');
|
db.exec('COMMIT');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
db.exec('ROLLBACK');
|
db.exec('ROLLBACK');
|
||||||
|
|||||||
@@ -27,6 +27,16 @@ type StatsExcludedWordPayload = {
|
|||||||
reading: string;
|
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 {
|
function parseIntQuery(raw: string | undefined, fallback: number, maxLimit?: number): number {
|
||||||
if (raw === undefined) return fallback;
|
if (raw === undefined) return fallback;
|
||||||
const n = Number(raw);
|
const n = Number(raw);
|
||||||
@@ -73,6 +83,31 @@ function parseExcludedWordsBody(body: unknown): StatsExcludedWordPayload[] | nul
|
|||||||
return words;
|
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(
|
function resolveStatsNoteFieldName(
|
||||||
noteInfo: StatsServerNoteInfo,
|
noteInfo: StatsServerNoteInfo,
|
||||||
...preferredNames: (string | undefined)[]
|
...preferredNames: (string | undefined)[]
|
||||||
@@ -707,6 +742,27 @@ export function createStatsApp(
|
|||||||
return c.json({ ok: true });
|
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) => {
|
app.get('/api/stats/anime/:animeId/cover', async (c) => {
|
||||||
const animeId = parseIntQuery(c.req.param('animeId'), 0);
|
const animeId = parseIntQuery(c.req.param('animeId'), 0);
|
||||||
if (animeId <= 0) return c.body(null, 404);
|
if (animeId <= 0) return c.body(null, 404);
|
||||||
|
|||||||
@@ -3266,6 +3266,10 @@ function broadcastToOverlayWindows(channel: string, ...args: unknown[]): void {
|
|||||||
overlayManager.broadcastToOverlayWindows(channel, ...args);
|
overlayManager.broadcastToOverlayWindows(channel, ...args);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function broadcastMiningImageToOverlay(image: string): void {
|
||||||
|
broadcastToOverlayWindows(IPC_CHANNELS.event.miningImage, { image });
|
||||||
|
}
|
||||||
|
|
||||||
const buildBroadcastRuntimeOptionsChangedMainDepsHandler =
|
const buildBroadcastRuntimeOptionsChangedMainDepsHandler =
|
||||||
createBuildBroadcastRuntimeOptionsChangedMainDepsHandler({
|
createBuildBroadcastRuntimeOptionsChangedMainDepsHandler({
|
||||||
broadcastRuntimeOptionsChangedRuntime,
|
broadcastRuntimeOptionsChangedRuntime,
|
||||||
@@ -5811,6 +5815,7 @@ function initializeOverlayRuntime(): void {
|
|||||||
refreshCurrentSubtitleAfterKnownWordUpdate,
|
refreshCurrentSubtitleAfterKnownWordUpdate,
|
||||||
);
|
);
|
||||||
appState.ankiIntegration?.setSubtitleMiningContextConsumer(consumePendingSubtitleMiningContext);
|
appState.ankiIntegration?.setSubtitleMiningContextConsumer(consumePendingSubtitleMiningContext);
|
||||||
|
appState.ankiIntegration?.setMiningImageOverlayCallback(broadcastMiningImageToOverlay);
|
||||||
syncOverlayMpvSubtitleSuppression();
|
syncOverlayMpvSubtitleSuppression();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -6896,6 +6901,7 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({
|
|||||||
appState.ankiIntegration?.setSubtitleMiningContextConsumer(
|
appState.ankiIntegration?.setSubtitleMiningContextConsumer(
|
||||||
consumePendingSubtitleMiningContext,
|
consumePendingSubtitleMiningContext,
|
||||||
);
|
);
|
||||||
|
appState.ankiIntegration?.setMiningImageOverlayCallback(broadcastMiningImageToOverlay);
|
||||||
},
|
},
|
||||||
getKnownWordCacheStatePath: () => path.join(USER_DATA_PATH, 'known-words-cache.json'),
|
getKnownWordCacheStatePath: () => path.join(USER_DATA_PATH, 'known-words-cache.json'),
|
||||||
showDesktopNotification,
|
showDesktopNotification,
|
||||||
|
|||||||
@@ -51,6 +51,7 @@ import type {
|
|||||||
OverlayContentMeasurement,
|
OverlayContentMeasurement,
|
||||||
ShortcutsConfig,
|
ShortcutsConfig,
|
||||||
ConfigHotReloadPayload,
|
ConfigHotReloadPayload,
|
||||||
|
MiningImagePayload,
|
||||||
ControllerConfigUpdate,
|
ControllerConfigUpdate,
|
||||||
ControllerPreferenceUpdate,
|
ControllerPreferenceUpdate,
|
||||||
ResolvedControllerConfig,
|
ResolvedControllerConfig,
|
||||||
@@ -222,6 +223,10 @@ const onSecondarySubtitleModeEvent = createLatestValueIpcListenerWithPayload<Sec
|
|||||||
IPC_CHANNELS.event.secondarySubtitleMode,
|
IPC_CHANNELS.event.secondarySubtitleMode,
|
||||||
(payload) => payload as SecondarySubMode,
|
(payload) => payload as SecondarySubMode,
|
||||||
);
|
);
|
||||||
|
const onMiningImageEvent = createQueuedIpcListenerWithPayload<MiningImagePayload>(
|
||||||
|
IPC_CHANNELS.event.miningImage,
|
||||||
|
(payload) => payload as MiningImagePayload,
|
||||||
|
);
|
||||||
|
|
||||||
const electronAPI: ElectronAPI = {
|
const electronAPI: ElectronAPI = {
|
||||||
getOverlayLayer: () => overlayLayer,
|
getOverlayLayer: () => overlayLayer,
|
||||||
@@ -468,6 +473,9 @@ const electronAPI: ElectronAPI = {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
onMiningImage: (callback: (payload: MiningImagePayload) => void) => {
|
||||||
|
onMiningImageEvent(callback);
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
contextBridge.exposeInMainWorld('electronAPI', electronAPI);
|
contextBridge.exposeInMainWorld('electronAPI', electronAPI);
|
||||||
|
|||||||
@@ -42,6 +42,9 @@
|
|||||||
role="status"
|
role="status"
|
||||||
aria-live="polite"
|
aria-live="polite"
|
||||||
></div>
|
></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="secondarySubContainer" class="secondary-sub-hidden">
|
||||||
<div id="secondarySubRoot"></div>
|
<div id="secondarySubRoot"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import type {
|
|||||||
SubtitlePosition,
|
SubtitlePosition,
|
||||||
SubsyncManualPayload,
|
SubsyncManualPayload,
|
||||||
ConfigHotReloadPayload,
|
ConfigHotReloadPayload,
|
||||||
|
MiningImagePayload,
|
||||||
} from '../types';
|
} from '../types';
|
||||||
import { createKeyboardHandlers } from './handlers/keyboard.js';
|
import { createKeyboardHandlers } from './handlers/keyboard.js';
|
||||||
import { createGamepadController } from './handlers/gamepad-controller.js';
|
import { createGamepadController } from './handlers/gamepad-controller.js';
|
||||||
@@ -211,6 +212,7 @@ const keyboardHandlers = createKeyboardHandlers(ctx, {
|
|||||||
let lastSubtitlePreview = '';
|
let lastSubtitlePreview = '';
|
||||||
let lastSecondarySubtitlePreview = '';
|
let lastSecondarySubtitlePreview = '';
|
||||||
let overlayErrorToastTimeout: ReturnType<typeof setTimeout> | null = null;
|
let overlayErrorToastTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
let miningImageToastTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||||
let controllerAnimationFrameId: number | null = null;
|
let controllerAnimationFrameId: number | null = null;
|
||||||
|
|
||||||
function truncateForErrorLog(text: string): string {
|
function truncateForErrorLog(text: string): string {
|
||||||
@@ -439,6 +441,23 @@ function showOverlayErrorToast(message: string): void {
|
|||||||
}, 3200);
|
}, 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({
|
const recovery = createRendererRecoveryController({
|
||||||
dismissActiveUi: dismissActiveUiAfterError,
|
dismissActiveUi: dismissActiveUiAfterError,
|
||||||
restoreOverlayInteraction: restoreOverlayInteractionAfterError,
|
restoreOverlayInteraction: restoreOverlayInteractionAfterError,
|
||||||
@@ -733,6 +752,11 @@ async function init(): Promise<void> {
|
|||||||
measurementReporter.schedule();
|
measurementReporter.schedule();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
window.electronAPI.onMiningImage((payload: MiningImagePayload) => {
|
||||||
|
runGuarded('mining:image', () => {
|
||||||
|
showMiningImageToast(payload.image);
|
||||||
|
});
|
||||||
|
});
|
||||||
mouseHandlers.setupDragging();
|
mouseHandlers.setupDragging();
|
||||||
try {
|
try {
|
||||||
ctx.state.controllerConfig = await window.electronAPI.getControllerConfig();
|
ctx.state.controllerConfig = await window.electronAPI.getControllerConfig();
|
||||||
|
|||||||
@@ -146,6 +146,37 @@ body:focus-visible,
|
|||||||
transform: translateY(0);
|
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 {
|
.modal {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ export type RendererDom = {
|
|||||||
overlay: HTMLElement;
|
overlay: HTMLElement;
|
||||||
controllerStatusToast: HTMLDivElement;
|
controllerStatusToast: HTMLDivElement;
|
||||||
overlayErrorToast: HTMLDivElement;
|
overlayErrorToast: HTMLDivElement;
|
||||||
|
miningImageToast: HTMLDivElement;
|
||||||
|
miningImageToastImage: HTMLImageElement;
|
||||||
secondarySubContainer: HTMLElement;
|
secondarySubContainer: HTMLElement;
|
||||||
secondarySubRoot: HTMLElement;
|
secondarySubRoot: HTMLElement;
|
||||||
|
|
||||||
@@ -134,6 +136,8 @@ export function resolveRendererDom(): RendererDom {
|
|||||||
overlay: getRequiredElement<HTMLElement>('overlay'),
|
overlay: getRequiredElement<HTMLElement>('overlay'),
|
||||||
controllerStatusToast: getRequiredElement<HTMLDivElement>('controllerStatusToast'),
|
controllerStatusToast: getRequiredElement<HTMLDivElement>('controllerStatusToast'),
|
||||||
overlayErrorToast: getRequiredElement<HTMLDivElement>('overlayErrorToast'),
|
overlayErrorToast: getRequiredElement<HTMLDivElement>('overlayErrorToast'),
|
||||||
|
miningImageToast: getRequiredElement<HTMLDivElement>('miningImageToast'),
|
||||||
|
miningImageToastImage: getRequiredElement<HTMLImageElement>('miningImageToastImage'),
|
||||||
secondarySubContainer: getRequiredElement<HTMLElement>('secondarySubContainer'),
|
secondarySubContainer: getRequiredElement<HTMLElement>('secondarySubContainer'),
|
||||||
secondarySubRoot: getRequiredElement<HTMLElement>('secondarySubRoot'),
|
secondarySubRoot: getRequiredElement<HTMLElement>('secondarySubRoot'),
|
||||||
|
|
||||||
|
|||||||
@@ -144,6 +144,7 @@ export const IPC_CHANNELS = {
|
|||||||
subtitleSidebarToggle: 'subtitle-sidebar:toggle',
|
subtitleSidebarToggle: 'subtitle-sidebar:toggle',
|
||||||
primarySubtitleBarToggle: 'primary-subtitle-bar:toggle',
|
primarySubtitleBarToggle: 'primary-subtitle-bar:toggle',
|
||||||
configHotReload: 'config:hot-reload',
|
configHotReload: 'config:hot-reload',
|
||||||
|
miningImage: 'mining:image',
|
||||||
},
|
},
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
|
|||||||
@@ -343,6 +343,11 @@ export interface ClipboardAppendResult {
|
|||||||
message: string;
|
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 {
|
export interface ConfigHotReloadPayload {
|
||||||
keybindings: Keybinding[];
|
keybindings: Keybinding[];
|
||||||
sessionBindings: CompiledSessionBinding[];
|
sessionBindings: CompiledSessionBinding[];
|
||||||
@@ -541,6 +546,7 @@ export interface ElectronAPI {
|
|||||||
) => void;
|
) => void;
|
||||||
reportOverlayContentBounds: (measurement: OverlayContentMeasurement) => void;
|
reportOverlayContentBounds: (measurement: OverlayContentMeasurement) => void;
|
||||||
onConfigHotReload: (callback: (payload: ConfigHotReloadPayload) => void) => void;
|
onConfigHotReload: (callback: (payload: ConfigHotReloadPayload) => void) => void;
|
||||||
|
onMiningImage: (callback: (payload: MiningImagePayload) => void) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ export function AnilistSelector({
|
|||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [linking, setLinking] = useState<number | null>(null);
|
const [linking, setLinking] = useState<number | null>(null);
|
||||||
const inputRef = useRef<HTMLInputElement>(null);
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
const debounceRef = useRef<ReturnType<typeof setTimeout>>();
|
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
inputRef.current?.focus();
|
inputRef.current?.focus();
|
||||||
@@ -53,7 +53,7 @@ export function AnilistSelector({
|
|||||||
|
|
||||||
const handleInput = (value: string) => {
|
const handleInput = (value: string) => {
|
||||||
setQuery(value);
|
setQuery(value);
|
||||||
clearTimeout(debounceRef.current);
|
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||||
debounceRef.current = setTimeout(() => doSearch(value), 400);
|
debounceRef.current = setTimeout(() => doSearch(value), 400);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState } from 'react';
|
import { RetryingCoverImage } from '../common/RetryingCoverImage';
|
||||||
import { getStatsClient } from '../../hooks/useStatsApi';
|
import { getStatsClient } from '../../hooks/useStatsApi';
|
||||||
|
|
||||||
interface AnimeCoverImageProps {
|
interface AnimeCoverImageProps {
|
||||||
@@ -8,28 +8,7 @@ interface AnimeCoverImageProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function AnimeCoverImage({ animeId, title, className = '' }: 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);
|
const src = getStatsClient().getAnimeCoverUrl(animeId);
|
||||||
|
|
||||||
return (
|
return <RetryingCoverImage src={src} alt={title} fallbackLabel={title} className={className} />;
|
||||||
<img
|
|
||||||
src={src}
|
|
||||||
alt={title}
|
|
||||||
loading="lazy"
|
|
||||||
className={`object-cover bg-ctp-surface2 ${className}`}
|
|
||||||
onError={() => setFailed(true)}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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';
|
import { resolveMediaCoverApiUrl } from '../../lib/media-library-grouping';
|
||||||
|
|
||||||
interface CoverImageProps {
|
interface CoverImageProps {
|
||||||
@@ -9,31 +9,9 @@ interface CoverImageProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function CoverImage({ videoId, title, src = null, className = '' }: 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);
|
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 (
|
return (
|
||||||
<img
|
<RetryingCoverImage src={resolvedSrc} alt={title} fallbackLabel={title} className={className} />
|
||||||
src={resolvedSrc}
|
|
||||||
alt={title}
|
|
||||||
loading="lazy"
|
|
||||||
className={`object-cover bg-ctp-surface2 ${className}`}
|
|
||||||
onError={() => setFailed(true)}
|
|
||||||
/>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 { RecentSessions } from './RecentSessions';
|
||||||
import { TrackingSnapshot } from './TrackingSnapshot';
|
import { TrackingSnapshot } from './TrackingSnapshot';
|
||||||
import { TrendChart } from '../trends/TrendChart';
|
import { TrendChart } from '../trends/TrendChart';
|
||||||
|
import { DeleteProgressToast } from '../common/DeleteProgressToast';
|
||||||
import { buildOverviewSummary, buildStreakCalendar } from '../../lib/dashboard-data';
|
import { buildOverviewSummary, buildStreakCalendar } from '../../lib/dashboard-data';
|
||||||
import { apiClient } from '../../lib/api-client';
|
import { apiClient } from '../../lib/api-client';
|
||||||
import { getStatsClient } from '../../hooks/useStatsApi';
|
import { getStatsClient } from '../../hooks/useStatsApi';
|
||||||
@@ -153,6 +154,8 @@ export function OverviewTab({ onNavigateToMediaDetail, onNavigateToSession }: Ov
|
|||||||
onDeleteAnimeGroup={handleDeleteAnimeGroup}
|
onDeleteAnimeGroup={handleDeleteAnimeGroup}
|
||||||
deletingIds={deletingIds}
|
deletingIds={deletingIds}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<DeleteProgressToast count={deletingIds.size} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,9 @@ import {
|
|||||||
formatNumber,
|
formatNumber,
|
||||||
formatSessionDayLabel,
|
formatSessionDayLabel,
|
||||||
} from '../../lib/formatters';
|
} 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 { getSessionDisplayWordCount } from '../../lib/session-word-count';
|
||||||
import { getSessionNavigationTarget } from '../../lib/stats-navigation';
|
import { getSessionNavigationTarget } from '../../lib/stats-navigation';
|
||||||
import type { SessionSummary } from '../../types/stats';
|
import type { SessionSummary } from '../../types/stats';
|
||||||
@@ -85,53 +87,20 @@ function groupSessionsByAnime(sessions: SessionSummary[]): AnimeGroup[] {
|
|||||||
return Array.from(map.values());
|
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({
|
function SessionItem({
|
||||||
session,
|
session,
|
||||||
onNavigateToMediaDetail,
|
onNavigateToMediaDetail,
|
||||||
onNavigateToSession,
|
onNavigateToSession,
|
||||||
onDelete,
|
onDelete,
|
||||||
deleteDisabled,
|
deleteDisabled,
|
||||||
|
coverImages,
|
||||||
}: {
|
}: {
|
||||||
session: SessionSummary;
|
session: SessionSummary;
|
||||||
onNavigateToMediaDetail: (videoId: number, sessionId?: number | null) => void;
|
onNavigateToMediaDetail: (videoId: number, sessionId?: number | null) => void;
|
||||||
onNavigateToSession: (sessionId: number) => void;
|
onNavigateToSession: (sessionId: number) => void;
|
||||||
onDelete: () => void;
|
onDelete: () => void;
|
||||||
deleteDisabled: boolean;
|
deleteDisabled: boolean;
|
||||||
|
coverImages: CoverImageMap;
|
||||||
}) {
|
}) {
|
||||||
const displayWordCount = getSessionDisplayWordCount(session);
|
const displayWordCount = getSessionDisplayWordCount(session);
|
||||||
const navigationTarget = getSessionNavigationTarget(session);
|
const navigationTarget = getSessionNavigationTarget(session);
|
||||||
@@ -153,6 +122,7 @@ function SessionItem({
|
|||||||
animeId={session.animeId}
|
animeId={session.animeId}
|
||||||
videoId={session.videoId}
|
videoId={session.videoId}
|
||||||
title={session.canonicalTitle ?? 'Unknown'}
|
title={session.canonicalTitle ?? 'Unknown'}
|
||||||
|
coverImages={coverImages}
|
||||||
/>
|
/>
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<div className="text-sm font-medium text-ctp-text truncate">
|
<div className="text-sm font-medium text-ctp-text truncate">
|
||||||
@@ -205,6 +175,7 @@ function AnimeGroupRow({
|
|||||||
onDeleteSession,
|
onDeleteSession,
|
||||||
onDeleteAnimeGroup,
|
onDeleteAnimeGroup,
|
||||||
deletingIds,
|
deletingIds,
|
||||||
|
coverImages,
|
||||||
}: {
|
}: {
|
||||||
group: AnimeGroup;
|
group: AnimeGroup;
|
||||||
onNavigateToMediaDetail: (videoId: number, sessionId?: number | null) => void;
|
onNavigateToMediaDetail: (videoId: number, sessionId?: number | null) => void;
|
||||||
@@ -212,6 +183,7 @@ function AnimeGroupRow({
|
|||||||
onDeleteSession: (session: SessionSummary) => void;
|
onDeleteSession: (session: SessionSummary) => void;
|
||||||
onDeleteAnimeGroup: (group: AnimeGroup) => void;
|
onDeleteAnimeGroup: (group: AnimeGroup) => void;
|
||||||
deletingIds: Set<number>;
|
deletingIds: Set<number>;
|
||||||
|
coverImages: CoverImageMap;
|
||||||
}) {
|
}) {
|
||||||
const [expanded, setExpanded] = useState(false);
|
const [expanded, setExpanded] = useState(false);
|
||||||
const groupDeleting = group.sessions.some((s) => deletingIds.has(s.sessionId));
|
const groupDeleting = group.sessions.some((s) => deletingIds.has(s.sessionId));
|
||||||
@@ -225,6 +197,7 @@ function AnimeGroupRow({
|
|||||||
onNavigateToSession={onNavigateToSession}
|
onNavigateToSession={onNavigateToSession}
|
||||||
onDelete={() => onDeleteSession(s)}
|
onDelete={() => onDeleteSession(s)}
|
||||||
deleteDisabled={deletingIds.has(s.sessionId)}
|
deleteDisabled={deletingIds.has(s.sessionId)}
|
||||||
|
coverImages={coverImages}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -247,6 +220,7 @@ function AnimeGroupRow({
|
|||||||
animeId={group.animeId}
|
animeId={group.animeId}
|
||||||
videoId={mostRecentSession.videoId}
|
videoId={mostRecentSession.videoId}
|
||||||
title={displayTitle}
|
title={displayTitle}
|
||||||
|
coverImages={coverImages}
|
||||||
/>
|
/>
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<div className="text-sm font-medium text-ctp-text truncate">{displayTitle}</div>
|
<div className="text-sm font-medium text-ctp-text truncate">{displayTitle}</div>
|
||||||
@@ -319,6 +293,7 @@ function AnimeGroupRow({
|
|||||||
animeId={s.animeId}
|
animeId={s.animeId}
|
||||||
videoId={s.videoId}
|
videoId={s.videoId}
|
||||||
title={s.canonicalTitle ?? 'Unknown'}
|
title={s.canonicalTitle ?? 'Unknown'}
|
||||||
|
coverImages={coverImages}
|
||||||
/>
|
/>
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<div className="text-sm font-medium text-ctp-subtext1 truncate">
|
<div className="text-sm font-medium text-ctp-subtext1 truncate">
|
||||||
@@ -378,6 +353,8 @@ export function RecentSessions({
|
|||||||
onDeleteAnimeGroup,
|
onDeleteAnimeGroup,
|
||||||
deletingIds,
|
deletingIds,
|
||||||
}: RecentSessionsProps) {
|
}: RecentSessionsProps) {
|
||||||
|
const coverImages = useCoverImages(sessions);
|
||||||
|
|
||||||
if (sessions.length === 0) {
|
if (sessions.length === 0) {
|
||||||
return (
|
return (
|
||||||
<div className="bg-ctp-surface0 border border-ctp-surface1 rounded-lg p-4">
|
<div className="bg-ctp-surface0 border border-ctp-surface1 rounded-lg p-4">
|
||||||
@@ -422,6 +399,7 @@ export function RecentSessions({
|
|||||||
onDeleteSession={onDeleteSession}
|
onDeleteSession={onDeleteSession}
|
||||||
onDeleteAnimeGroup={(g) => onDeleteAnimeGroup(g.sessions)}
|
onDeleteAnimeGroup={(g) => onDeleteAnimeGroup(g.sessions)}
|
||||||
deletingIds={deletingIds}
|
deletingIds={deletingIds}
|
||||||
|
coverImages={coverImages}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -71,6 +71,63 @@ test('buildBucketDeleteHandler deletes every session in the bucket when confirm
|
|||||||
assert.equal(onErrorCalled, false);
|
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 () => {
|
test('buildBucketDeleteHandler is a no-op when confirm returns false', async () => {
|
||||||
let deleteCalled = false;
|
let deleteCalled = false;
|
||||||
let successCalled = false;
|
let successCalled = false;
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { useEffect, useMemo, useState } from 'react';
|
|||||||
import { useSessions } from '../../hooks/useSessions';
|
import { useSessions } from '../../hooks/useSessions';
|
||||||
import { SessionRow } from './SessionRow';
|
import { SessionRow } from './SessionRow';
|
||||||
import { SessionDetail } from './SessionDetail';
|
import { SessionDetail } from './SessionDetail';
|
||||||
|
import { DeleteProgressToast } from '../common/DeleteProgressToast';
|
||||||
import { apiClient } from '../../lib/api-client';
|
import { apiClient } from '../../lib/api-client';
|
||||||
import { confirmBucketDelete, confirmSessionDelete } from '../../lib/delete-confirm';
|
import { confirmBucketDelete, confirmSessionDelete } from '../../lib/delete-confirm';
|
||||||
import { formatDuration, formatNumber, formatSessionDayLabel } from '../../lib/formatters';
|
import { formatDuration, formatNumber, formatSessionDayLabel } from '../../lib/formatters';
|
||||||
@@ -28,6 +29,8 @@ export interface BucketDeleteDeps {
|
|||||||
bucket: SessionBucket;
|
bucket: SessionBucket;
|
||||||
apiClient: { deleteSessions: (ids: number[]) => Promise<void> };
|
apiClient: { deleteSessions: (ids: number[]) => Promise<void> };
|
||||||
confirm: (title: string, count: number) => boolean | Promise<boolean>;
|
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;
|
onSuccess: (deletedIds: number[]) => void;
|
||||||
onError: (message: string) => void;
|
onError: (message: string) => void;
|
||||||
}
|
}
|
||||||
@@ -39,12 +42,13 @@ export interface BucketDeleteDeps {
|
|||||||
* rendering the full SessionsTab or mocking React state.
|
* rendering the full SessionsTab or mocking React state.
|
||||||
*/
|
*/
|
||||||
export function buildBucketDeleteHandler(deps: BucketDeleteDeps): () => Promise<void> {
|
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 () => {
|
return async () => {
|
||||||
const title = bucket.representativeSession.canonicalTitle ?? 'this episode';
|
const title = bucket.representativeSession.canonicalTitle ?? 'this episode';
|
||||||
const ids = bucket.sessions.map((s) => s.sessionId);
|
const ids = bucket.sessions.map((s) => s.sessionId);
|
||||||
try {
|
try {
|
||||||
if (!(await confirm(title, ids.length))) return;
|
if (!(await confirm(title, ids.length))) return;
|
||||||
|
onStart?.(ids.length);
|
||||||
await client.deleteSessions(ids);
|
await client.deleteSessions(ids);
|
||||||
onSuccess(ids);
|
onSuccess(ids);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -72,6 +76,7 @@ export function SessionsTab({
|
|||||||
const [deleteError, setDeleteError] = useState<string | null>(null);
|
const [deleteError, setDeleteError] = useState<string | null>(null);
|
||||||
const [deletingSessionId, setDeletingSessionId] = useState<number | null>(null);
|
const [deletingSessionId, setDeletingSessionId] = useState<number | null>(null);
|
||||||
const [deletingBucketKey, setDeletingBucketKey] = useState<string | null>(null);
|
const [deletingBucketKey, setDeletingBucketKey] = useState<string | null>(null);
|
||||||
|
const [deletingCount, setDeletingCount] = useState(0);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setVisibleSessions(sessions);
|
setVisibleSessions(sessions);
|
||||||
@@ -131,6 +136,7 @@ export function SessionsTab({
|
|||||||
|
|
||||||
setDeleteError(null);
|
setDeleteError(null);
|
||||||
setDeletingSessionId(session.sessionId);
|
setDeletingSessionId(session.sessionId);
|
||||||
|
setDeletingCount(1);
|
||||||
try {
|
try {
|
||||||
await apiClient.deleteSession(session.sessionId);
|
await apiClient.deleteSession(session.sessionId);
|
||||||
setVisibleSessions((prev) => prev.filter((item) => item.sessionId !== 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.');
|
setDeleteError(err instanceof Error ? err.message : 'Failed to delete session.');
|
||||||
} finally {
|
} finally {
|
||||||
setDeletingSessionId(null);
|
setDeletingSessionId(null);
|
||||||
|
setDeletingCount(0);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -149,6 +156,7 @@ export function SessionsTab({
|
|||||||
bucket,
|
bucket,
|
||||||
apiClient,
|
apiClient,
|
||||||
confirm: confirmBucketDelete,
|
confirm: confirmBucketDelete,
|
||||||
|
onStart: (count) => setDeletingCount(count),
|
||||||
onSuccess: (ids) => {
|
onSuccess: (ids) => {
|
||||||
const deleted = new Set(ids);
|
const deleted = new Set(ids);
|
||||||
setVisibleSessions((prev) => prev.filter((s) => !deleted.has(s.sessionId)));
|
setVisibleSessions((prev) => prev.filter((s) => !deleted.has(s.sessionId)));
|
||||||
@@ -166,6 +174,7 @@ export function SessionsTab({
|
|||||||
await handler();
|
await handler();
|
||||||
} finally {
|
} finally {
|
||||||
setDeletingBucketKey(null);
|
setDeletingBucketKey(null);
|
||||||
|
setDeletingCount(0);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -305,6 +314,8 @@ export function SessionsTab({
|
|||||||
{search.trim() ? 'No sessions matching your search.' : 'No sessions recorded yet.'}
|
{search.trim() ? 'No sessions matching your search.' : 'No sessions recorded yet.'}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<DeleteProgressToast count={deletingCount} />
|
||||||
</div>
|
</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');
|
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 () => {
|
test('deleteSession sends a DELETE request to the session endpoint', async () => {
|
||||||
const originalFetch = globalThis.fetch;
|
const originalFetch = globalThis.fetch;
|
||||||
let seenUrl = '';
|
let seenUrl = '';
|
||||||
|
|||||||
@@ -23,7 +23,9 @@ import type {
|
|||||||
EpisodeDetailData,
|
EpisodeDetailData,
|
||||||
StatsAnkiNoteInfo,
|
StatsAnkiNoteInfo,
|
||||||
StatsExcludedWord,
|
StatsExcludedWord,
|
||||||
|
StatsCoverImagesData,
|
||||||
} from '../types/stats';
|
} from '../types/stats';
|
||||||
|
import { appendCoverRetryToken } from './cover-retry';
|
||||||
|
|
||||||
type StatsLocationLike = Pick<Location, 'protocol' | 'origin' | 'search'>;
|
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>;
|
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 = {
|
export const apiClient = {
|
||||||
getOverview: () => fetchJson<OverviewData>('/api/stats/overview'),
|
getOverview: () => fetchJson<OverviewData>('/api/stats/overview'),
|
||||||
getDailyRollups: (limit = 60) =>
|
getDailyRollups: (limit = 60) =>
|
||||||
@@ -116,7 +128,22 @@ export const apiClient = {
|
|||||||
fetchJson<AnimeWord[]>(`/api/stats/anime/${animeId}/words?limit=${limit}`),
|
fetchJson<AnimeWord[]>(`/api/stats/anime/${animeId}/words?limit=${limit}`),
|
||||||
getAnimeRollups: (animeId: number, limit = 90) =>
|
getAnimeRollups: (animeId: number, limit = 90) =>
|
||||||
fetchJson<DailyRollup[]>(`/api/stats/anime/${animeId}/rollups?limit=${limit}`),
|
fetchJson<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) =>
|
getStreakCalendar: (days = 90) =>
|
||||||
fetchJson<StreakCalendarDay[]>(`/api/stats/streak-calendar?days=${days}`),
|
fetchJson<StreakCalendarDay[]>(`/api/stats/streak-calendar?days=${days}`),
|
||||||
getEpisodesPerDay: (limit = 90) =>
|
getEpisodesPerDay: (limit = 90) =>
|
||||||
@@ -175,6 +202,7 @@ export const apiClient = {
|
|||||||
episodes: number | null;
|
episodes: number | null;
|
||||||
season: string | null;
|
season: string | null;
|
||||||
seasonYear: number | null;
|
seasonYear: number | null;
|
||||||
|
description: string | null;
|
||||||
coverImage: { large: string | null; medium: string | null } | null;
|
coverImage: { large: string | null; medium: string | null } | null;
|
||||||
title: { romaji: string | null; english: string | null; native: 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 {
|
import {
|
||||||
groupMediaLibraryItems,
|
groupMediaLibraryItems,
|
||||||
resolveMediaArtworkUrl,
|
resolveMediaArtworkUrl,
|
||||||
|
resolveMediaCoverApiUrl,
|
||||||
summarizeMediaLibraryGroups,
|
summarizeMediaLibraryGroups,
|
||||||
} from './media-library-grouping';
|
} from './media-library-grouping';
|
||||||
import { CoverImage } from '../components/library/CoverImage';
|
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/);
|
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', () => {
|
test('MediaCard prefers youtube video title over canonical fallback url slug', () => {
|
||||||
const markup = renderToStaticMarkup(<MediaCard item={youtubeEpisodeA} onClick={() => {}} />);
|
const markup = renderToStaticMarkup(<MediaCard item={youtubeEpisodeA} onClick={() => {}} />);
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { BASE_URL } from './api-client';
|
import { BASE_URL } from './api-client';
|
||||||
|
import { appendCoverRetryToken } from './cover-retry';
|
||||||
import type { MediaLibraryItem } from '../types/stats';
|
import type { MediaLibraryItem } from '../types/stats';
|
||||||
|
|
||||||
export interface MediaLibraryGroup {
|
export interface MediaLibraryGroup {
|
||||||
@@ -22,8 +23,8 @@ export function resolveMediaArtworkUrl(
|
|||||||
return normalized.length > 0 ? normalized : null;
|
return normalized.length > 0 ? normalized : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function resolveMediaCoverApiUrl(videoId: number): string {
|
export function resolveMediaCoverApiUrl(videoId: number, retryToken = 0): string {
|
||||||
return `${BASE_URL}/api/stats/media/${videoId}/cover`;
|
return appendCoverRetryToken(`${BASE_URL}/api/stats/media/${videoId}/cover`, retryToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function summarizeMediaLibraryGroups(groups: MediaLibraryGroup[]): {
|
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';
|
import { fullReading } from './reading-utils';
|
||||||
|
|
||||||
describe('fullReading', () => {
|
describe('fullReading', () => {
|
||||||
it('prefixes leading hiragana from headword', () => {
|
it('prefixes leading hiragana from headword', () => {
|
||||||
// お前 with reading まえ → おまえ
|
// お前 with reading まえ → おまえ
|
||||||
expect(fullReading('お前', 'まえ')).toBe('おまえ');
|
assert.equal(fullReading('お前', 'まえ'), 'おまえ');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('handles katakana stored readings', () => {
|
it('handles katakana stored readings', () => {
|
||||||
// お前 with katakana reading マエ → おまえ
|
// お前 with katakana reading マエ → おまえ
|
||||||
expect(fullReading('お前', 'マエ')).toBe('おまえ');
|
assert.equal(fullReading('お前', 'マエ'), 'おまえ');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns stored reading when it already includes leading kana', () => {
|
it('returns stored reading when it already includes leading kana', () => {
|
||||||
// Reading already correct
|
// Reading already correct
|
||||||
expect(fullReading('お前', 'おまえ')).toBe('おまえ');
|
assert.equal(fullReading('お前', 'おまえ'), 'おまえ');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('handles trailing hiragana', () => {
|
it('handles trailing hiragana', () => {
|
||||||
// 隠す with reading かくす — す is trailing hiragana
|
// 隠す with reading かくす — す is trailing hiragana
|
||||||
expect(fullReading('隠す', 'かくす')).toBe('かくす');
|
assert.equal(fullReading('隠す', 'かくす'), 'かくす');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('handles pure kanji headwords', () => {
|
it('handles pure kanji headwords', () => {
|
||||||
expect(fullReading('様', 'さま')).toBe('さま');
|
assert.equal(fullReading('様', 'さま'), 'さま');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns empty for empty reading', () => {
|
it('returns empty for empty reading', () => {
|
||||||
expect(fullReading('前', '')).toBe('');
|
assert.equal(fullReading('前', ''), '');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns empty for empty headword', () => {
|
it('returns empty for empty headword', () => {
|
||||||
expect(fullReading('', 'まえ')).toBe('まえ');
|
assert.equal(fullReading('', 'まえ'), 'まえ');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('handles all-kana headword', () => {
|
it('handles all-kana headword', () => {
|
||||||
// Headword is already all hiragana
|
// Headword is already all hiragana
|
||||||
expect(fullReading('いますぐ', 'いますぐ')).toBe('いますぐ');
|
assert.equal(fullReading('いますぐ', 'いますぐ'), 'いますぐ');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('handles mixed leading and trailing kana', () => {
|
it('handles mixed leading and trailing kana', () => {
|
||||||
// お気に入り: お=leading, に入り=trailing around 気
|
// お気に入り: お=leading, に入り=trailing around 気
|
||||||
expect(fullReading('お気に入り', 'きにいり')).toBe('おきにいり');
|
assert.equal(fullReading('お気に入り', 'きにいり'), 'おきにいり');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('handles katakana in headword', () => {
|
it('handles katakana in headword', () => {
|
||||||
// カズマ様 — leading katakana + kanji
|
// カズマ様 — leading katakana + kanji
|
||||||
expect(fullReading('カズマ様', 'さま')).toBe('かずまさま');
|
assert.equal(fullReading('カズマ様', 'さま'), 'かずまさま');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -41,8 +41,10 @@ export function fullReading(headword: string, storedReading: string): string {
|
|||||||
const chars = [...headword];
|
const chars = [...headword];
|
||||||
|
|
||||||
let i = 0;
|
let i = 0;
|
||||||
while (i < chars.length && (isHiragana(chars[i]) || isKatakana(chars[i]))) {
|
while (i < chars.length) {
|
||||||
leadingKana.push(katakanaToHiragana(chars[i]));
|
const ch = chars[i]!;
|
||||||
|
if (!isHiragana(ch) && !isKatakana(ch)) break;
|
||||||
|
leadingKana.push(katakanaToHiragana(ch));
|
||||||
i++;
|
i++;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -51,8 +53,10 @@ export function fullReading(headword: string, storedReading: string): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let j = chars.length - 1;
|
let j = chars.length - 1;
|
||||||
while (j > i && (isHiragana(chars[j]) || isKatakana(chars[j]))) {
|
while (j > i) {
|
||||||
trailingKana.unshift(katakanaToHiragana(chars[j]));
|
const ch = chars[j]!;
|
||||||
|
if (!isHiragana(ch) && !isKatakana(ch)) break;
|
||||||
|
trailingKana.unshift(katakanaToHiragana(ch));
|
||||||
j--;
|
j--;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,8 +4,9 @@ import type { SessionSummary } from '../types/stats';
|
|||||||
import { groupSessionsByVideo } from './session-grouping';
|
import { groupSessionsByVideo } from './session-grouping';
|
||||||
|
|
||||||
function makeSession(overrides: Partial<SessionSummary> & { sessionId: number }): SessionSummary {
|
function makeSession(overrides: Partial<SessionSummary> & { sessionId: number }): SessionSummary {
|
||||||
|
const { sessionId, ...rest } = overrides;
|
||||||
return {
|
return {
|
||||||
sessionId: overrides.sessionId,
|
sessionId,
|
||||||
canonicalTitle: null,
|
canonicalTitle: null,
|
||||||
videoId: null,
|
videoId: null,
|
||||||
animeId: null,
|
animeId: null,
|
||||||
@@ -22,7 +23,7 @@ function makeSession(overrides: Partial<SessionSummary> & { sessionId: number })
|
|||||||
yomitanLookupCount: 0,
|
yomitanLookupCount: 0,
|
||||||
knownWordsSeen: 0,
|
knownWordsSeen: 0,
|
||||||
knownWordRate: 0,
|
knownWordRate: 0,
|
||||||
...overrides,
|
...rest,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -132,7 +132,6 @@ test('AnimeOverviewStats renders aggregate Yomitan lookup metrics', () => {
|
|||||||
episodeCount: 3,
|
episodeCount: 3,
|
||||||
lastWatchedMs: 0,
|
lastWatchedMs: 0,
|
||||||
}}
|
}}
|
||||||
avgSessionMs={20_000}
|
|
||||||
knownWordsSummary={null}
|
knownWordsSummary={null}
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import { readFileSync } from 'node:fs';
|
import { readFileSync } from 'node:fs';
|
||||||
|
import test from 'node:test';
|
||||||
import { fileURLToPath } from 'node:url';
|
import { fileURLToPath } from 'node:url';
|
||||||
|
|
||||||
const css = readFileSync(fileURLToPath(new URL('./globals.css', import.meta.url)), 'utf8');
|
const css = readFileSync(fileURLToPath(new URL('./globals.css', import.meta.url)), 'utf8');
|
||||||
|
|||||||
@@ -82,6 +82,16 @@ export interface StatsExcludedWord {
|
|||||||
reading: string;
|
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 {
|
export interface KanjiEntry {
|
||||||
kanjiId: number;
|
kanjiId: number;
|
||||||
kanji: string;
|
kanji: string;
|
||||||
|
|||||||
Reference in New Issue
Block a user