mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-07-07 13:08:54 -07:00
feat(stats): speed up session maintenance and improve stats UI (#111)
This commit is contained in:
@@ -32,6 +32,43 @@ test('resolveStatsBaseUrl keeps legacy localhost fallback for file mode without
|
||||
assert.equal(baseUrl, 'http://127.0.0.1:6969');
|
||||
});
|
||||
|
||||
test('getAnimeCoverUrl appends retry tokens for late cover refreshes', () => {
|
||||
const getAnimeCoverUrl = apiClient.getAnimeCoverUrl as (
|
||||
animeId: number,
|
||||
retryToken?: number,
|
||||
) => string;
|
||||
|
||||
assert.equal(
|
||||
getAnimeCoverUrl(42, 3),
|
||||
'http://127.0.0.1:6969/api/stats/anime/42/cover?coverRetry=3',
|
||||
);
|
||||
});
|
||||
|
||||
test('getCoverImages batches anime and media cover requests', async () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
let seenUrl = '';
|
||||
let seenMethod = '';
|
||||
let seenBody = '';
|
||||
globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
seenUrl = String(input);
|
||||
seenMethod = init?.method ?? 'GET';
|
||||
seenBody = String(init?.body ?? '');
|
||||
return new Response(JSON.stringify({ anime: {}, media: {} }), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}) as typeof globalThis.fetch;
|
||||
|
||||
try {
|
||||
await apiClient.getCoverImages({ animeIds: [1, 1, 2], videoIds: [7, 7, 8] });
|
||||
assert.equal(seenUrl, `${BASE_URL}/api/stats/covers`);
|
||||
assert.equal(seenMethod, 'POST');
|
||||
assert.deepEqual(JSON.parse(seenBody), { animeIds: [1, 2], videoIds: [7, 8] });
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
|
||||
test('deleteSession sends a DELETE request to the session endpoint', async () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
let seenUrl = '';
|
||||
@@ -51,6 +88,34 @@ test('deleteSession sends a DELETE request to the session endpoint', async () =>
|
||||
}
|
||||
});
|
||||
|
||||
test('searchSentences encodes realtime sentence search requests', async () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
let seenUrl = '';
|
||||
globalThis.fetch = (async (input: RequestInfo | URL) => {
|
||||
seenUrl = String(input);
|
||||
return new Response(JSON.stringify([]), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}) as typeof globalThis.fetch;
|
||||
|
||||
try {
|
||||
await apiClient.searchSentences('猫 食べる', 25);
|
||||
assert.equal(
|
||||
seenUrl,
|
||||
`${BASE_URL}/api/stats/sentences/search?q=%E7%8C%AB+%E9%A3%9F%E3%81%B9%E3%82%8B&limit=25&headword=true`,
|
||||
);
|
||||
|
||||
await apiClient.searchSentences('猫 食べる', 25, false);
|
||||
assert.equal(
|
||||
seenUrl,
|
||||
`${BASE_URL}/api/stats/sentences/search?q=%E7%8C%AB+%E9%A3%9F%E3%81%B9%E3%82%8B&limit=25&headword=false`,
|
||||
);
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
|
||||
test('deleteSession throws when the stats API delete request fails', async () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
globalThis.fetch = (async () =>
|
||||
|
||||
+40
-11
@@ -6,6 +6,7 @@ import type {
|
||||
SessionTimelinePoint,
|
||||
SessionEvent,
|
||||
VocabularyEntry,
|
||||
SentenceSearchResult,
|
||||
KanjiEntry,
|
||||
VocabularyOccurrenceEntry,
|
||||
MediaLibraryItem,
|
||||
@@ -23,7 +24,10 @@ import type {
|
||||
EpisodeDetailData,
|
||||
StatsAnkiNoteInfo,
|
||||
StatsExcludedWord,
|
||||
StatsCoverImagesData,
|
||||
} from '../types/stats';
|
||||
import type { StatsMineCardParams, StatsMineCardResponse } from './mining';
|
||||
import { appendCoverRetryToken } from './cover-retry';
|
||||
|
||||
type StatsLocationLike = Pick<Location, 'protocol' | 'origin' | 'search'>;
|
||||
|
||||
@@ -65,6 +69,16 @@ async function fetchJson<T>(path: string): Promise<T> {
|
||||
return res.json() as Promise<T>;
|
||||
}
|
||||
|
||||
function uniquePositiveIds(ids: number[]): number[] {
|
||||
const uniqueIds = new Set<number>();
|
||||
for (const id of ids) {
|
||||
if (Number.isFinite(id) && id > 0) {
|
||||
uniqueIds.add(Math.floor(id));
|
||||
}
|
||||
}
|
||||
return Array.from(uniqueIds).sort((a, b) => a - b);
|
||||
}
|
||||
|
||||
export const apiClient = {
|
||||
getOverview: () => fetchJson<OverviewData>('/api/stats/overview'),
|
||||
getDailyRollups: (limit = 60) =>
|
||||
@@ -103,6 +117,14 @@ export const apiClient = {
|
||||
fetchJson<VocabularyOccurrenceEntry[]>(
|
||||
`/api/stats/vocabulary/occurrences?headword=${encodeURIComponent(headword)}&word=${encodeURIComponent(word)}&reading=${encodeURIComponent(reading)}&limit=${limit}&offset=${offset}`,
|
||||
),
|
||||
searchSentences: (query: string, limit = 50, searchByHeadword = true) =>
|
||||
fetchJson<SentenceSearchResult[]>(
|
||||
`/api/stats/sentences/search?${new URLSearchParams({
|
||||
q: query,
|
||||
limit: String(limit),
|
||||
headword: String(searchByHeadword),
|
||||
}).toString()}`,
|
||||
),
|
||||
getKanji: (limit = 100) => fetchJson<KanjiEntry[]>(`/api/stats/kanji?limit=${limit}`),
|
||||
getKanjiOccurrences: (kanji: string, limit = 50, offset = 0) =>
|
||||
fetchJson<VocabularyOccurrenceEntry[]>(
|
||||
@@ -116,7 +138,22 @@ export const apiClient = {
|
||||
fetchJson<AnimeWord[]>(`/api/stats/anime/${animeId}/words?limit=${limit}`),
|
||||
getAnimeRollups: (animeId: number, limit = 90) =>
|
||||
fetchJson<DailyRollup[]>(`/api/stats/anime/${animeId}/rollups?limit=${limit}`),
|
||||
getAnimeCoverUrl: (animeId: number) => `${BASE_URL}/api/stats/anime/${animeId}/cover`,
|
||||
getAnimeCoverUrl: (animeId: number, retryToken = 0) =>
|
||||
appendCoverRetryToken(`${BASE_URL}/api/stats/anime/${animeId}/cover`, retryToken),
|
||||
getCoverImages: async (params: {
|
||||
animeIds: number[];
|
||||
videoIds: number[];
|
||||
}): Promise<StatsCoverImagesData> => {
|
||||
const res = await fetchResponse('/api/stats/covers', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
animeIds: uniquePositiveIds(params.animeIds),
|
||||
videoIds: uniquePositiveIds(params.videoIds),
|
||||
}),
|
||||
});
|
||||
return res.json() as Promise<StatsCoverImagesData>;
|
||||
},
|
||||
getStreakCalendar: (days = 90) =>
|
||||
fetchJson<StreakCalendarDay[]>(`/api/stats/streak-calendar?days=${days}`),
|
||||
getEpisodesPerDay: (limit = 90) =>
|
||||
@@ -175,6 +212,7 @@ export const apiClient = {
|
||||
episodes: number | null;
|
||||
season: string | null;
|
||||
seasonYear: number | null;
|
||||
description: string | null;
|
||||
coverImage: { large: string | null; medium: string | null } | null;
|
||||
title: { romaji: string | null; english: string | null; native: string | null } | null;
|
||||
}>
|
||||
@@ -197,16 +235,7 @@ export const apiClient = {
|
||||
body: JSON.stringify(info),
|
||||
});
|
||||
},
|
||||
mineCard: async (params: {
|
||||
sourcePath: string;
|
||||
startMs: number;
|
||||
endMs: number;
|
||||
sentence: string;
|
||||
word: string;
|
||||
secondaryText?: string | null;
|
||||
videoTitle: string;
|
||||
mode: 'word' | 'sentence' | 'audio';
|
||||
}): Promise<{ noteId?: number; error?: string; errors?: string[] }> => {
|
||||
mineCard: async (params: StatsMineCardParams): Promise<StatsMineCardResponse> => {
|
||||
const res = await fetch(`${BASE_URL}/api/stats/mine-card?mode=${params.mode}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
|
||||
@@ -13,6 +13,7 @@ test('App lazy-loads non-overview tabs and detail surfaces behind Suspense bound
|
||||
assert.match(source, /lazy\(\(\) =>\s*import\('\.\/components\/anime\/AnimeTab'\)/);
|
||||
assert.match(source, /lazy\(\(\) =>\s*import\('\.\/components\/trends\/TrendsTab'\)/);
|
||||
assert.match(source, /lazy\(\(\) =>\s*import\('\.\/components\/vocabulary\/VocabularyTab'\)/);
|
||||
assert.match(source, /lazy\(\(\) =>\s*import\('\.\/components\/search\/SearchTab'\)/);
|
||||
assert.match(source, /lazy\(\(\) =>\s*import\('\.\/components\/sessions\/SessionsTab'\)/);
|
||||
assert.match(source, /lazy\(\(\) =>\s*import\('\.\/components\/library\/MediaDetailView'\)/);
|
||||
assert.match(source, /lazy\(\(\) =>\s*import\('\.\/components\/vocabulary\/WordDetailPanel'\)/);
|
||||
@@ -23,6 +24,7 @@ test('App lazy-loads non-overview tabs and detail surfaces behind Suspense bound
|
||||
source,
|
||||
/import \{ VocabularyTab \} from '\.\/components\/vocabulary\/VocabularyTab';/,
|
||||
);
|
||||
assert.doesNotMatch(source, /import \{ SearchTab \} from '\.\/components\/search\/SearchTab';/);
|
||||
assert.doesNotMatch(
|
||||
source,
|
||||
/import \{ SessionsTab \} from '\.\/components\/sessions\/SessionsTab';/,
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import {
|
||||
buildCoverImageRequestKey,
|
||||
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');
|
||||
});
|
||||
|
||||
test('buildCoverImageRequestKey changes when callers force a cover refresh', () => {
|
||||
assert.notEqual(buildCoverImageRequestKey([10], [], 0), buildCoverImageRequestKey([10], [], 1));
|
||||
});
|
||||
@@ -0,0 +1,80 @@
|
||||
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 buildCoverImageRequestKey(
|
||||
animeIds: number[],
|
||||
videoIds: number[],
|
||||
refreshToken = 0,
|
||||
): string {
|
||||
return `a:${animeIds.join(',')}|m:${videoIds.join(',')}|r:${refreshToken}`;
|
||||
}
|
||||
|
||||
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,23 @@
|
||||
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 {
|
||||
// Dummy base lets URL parse relative API paths; it is never returned as a real host.
|
||||
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));
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
const KANA_ONLY_TEXT = /^[\p{Script=Hiragana}\p{Script=Katakana}\u30fc\u309d\u309e\u30fd\u30fe]+$/u;
|
||||
|
||||
export function isKanaOnlyTokenText(text: string): boolean {
|
||||
const trimmed = text.trim();
|
||||
return trimmed.length > 0 && KANA_ONLY_TEXT.test(trimmed);
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import {
|
||||
DEFAULT_LIBRARY_CARD_SIZE,
|
||||
LIBRARY_CARD_SIZE_STORAGE_KEY,
|
||||
getLibraryCardSizeStorage,
|
||||
readLibraryCardSizePreference,
|
||||
writeLibraryCardSizePreference,
|
||||
} from './library-card-size';
|
||||
|
||||
function createStorage(initial: Record<string, string | null> = {}): Storage {
|
||||
const values = new Map(
|
||||
Object.entries(initial).filter((entry): entry is [string, string] => {
|
||||
return entry[1] !== null;
|
||||
}),
|
||||
);
|
||||
|
||||
return {
|
||||
get length() {
|
||||
return values.size;
|
||||
},
|
||||
clear() {
|
||||
values.clear();
|
||||
},
|
||||
getItem(key: string) {
|
||||
return values.get(key) ?? null;
|
||||
},
|
||||
key(index: number) {
|
||||
return Array.from(values.keys())[index] ?? null;
|
||||
},
|
||||
removeItem(key: string) {
|
||||
values.delete(key);
|
||||
},
|
||||
setItem(key: string, value: string) {
|
||||
values.set(key, value);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test('readLibraryCardSizePreference returns saved valid sizes', () => {
|
||||
const storage = createStorage({ [LIBRARY_CARD_SIZE_STORAGE_KEY]: 'lg' });
|
||||
|
||||
assert.equal(readLibraryCardSizePreference(storage), 'lg');
|
||||
});
|
||||
|
||||
test('readLibraryCardSizePreference falls back for missing or invalid saved sizes', () => {
|
||||
assert.equal(readLibraryCardSizePreference(createStorage()), DEFAULT_LIBRARY_CARD_SIZE);
|
||||
assert.equal(
|
||||
readLibraryCardSizePreference(createStorage({ [LIBRARY_CARD_SIZE_STORAGE_KEY]: 'xl' })),
|
||||
DEFAULT_LIBRARY_CARD_SIZE,
|
||||
);
|
||||
});
|
||||
|
||||
test('library card size preference helpers ignore storage failures', () => {
|
||||
const storage = {
|
||||
getItem() {
|
||||
throw new Error('blocked');
|
||||
},
|
||||
setItem() {
|
||||
throw new Error('blocked');
|
||||
},
|
||||
} as unknown as Storage;
|
||||
|
||||
assert.equal(readLibraryCardSizePreference(storage), DEFAULT_LIBRARY_CARD_SIZE);
|
||||
assert.doesNotThrow(() => writeLibraryCardSizePreference(storage, 'sm'));
|
||||
});
|
||||
|
||||
test('getLibraryCardSizeStorage returns null when localStorage access is blocked', () => {
|
||||
const source = {
|
||||
get localStorage(): Storage {
|
||||
throw new Error('blocked');
|
||||
},
|
||||
};
|
||||
|
||||
assert.equal(getLibraryCardSizeStorage(source), null);
|
||||
});
|
||||
@@ -0,0 +1,36 @@
|
||||
export type LibraryCardSize = 'sm' | 'md' | 'lg';
|
||||
|
||||
export const DEFAULT_LIBRARY_CARD_SIZE: LibraryCardSize = 'md';
|
||||
export const LIBRARY_CARD_SIZE_STORAGE_KEY = 'subminer.stats.library.cardSize';
|
||||
|
||||
export function getLibraryCardSizeStorage(
|
||||
source: { localStorage: Storage } | null | undefined,
|
||||
): Storage | null {
|
||||
try {
|
||||
return source?.localStorage ?? null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function readLibraryCardSizePreference(
|
||||
storage: Storage | null | undefined,
|
||||
): LibraryCardSize {
|
||||
try {
|
||||
const value = storage?.getItem(LIBRARY_CARD_SIZE_STORAGE_KEY);
|
||||
return value === 'sm' || value === 'md' || value === 'lg' ? value : DEFAULT_LIBRARY_CARD_SIZE;
|
||||
} catch {
|
||||
return DEFAULT_LIBRARY_CARD_SIZE;
|
||||
}
|
||||
}
|
||||
|
||||
export function writeLibraryCardSizePreference(
|
||||
storage: Storage | null | undefined,
|
||||
size: LibraryCardSize,
|
||||
): void {
|
||||
try {
|
||||
storage?.setItem(LIBRARY_CARD_SIZE_STORAGE_KEY, size);
|
||||
} catch {
|
||||
// Storage can be blocked in private/restricted contexts; keep the in-memory choice.
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import type { MediaLibraryItem } from '../types/stats';
|
||||
import {
|
||||
groupMediaLibraryItems,
|
||||
resolveMediaArtworkUrl,
|
||||
resolveMediaCoverApiUrl,
|
||||
summarizeMediaLibraryGroups,
|
||||
} from './media-library-grouping';
|
||||
import { CoverImage } from '../components/library/CoverImage';
|
||||
@@ -172,6 +173,13 @@ test('MediaCard uses the proxied cover endpoint instead of metadata artwork urls
|
||||
assert.doesNotMatch(markup, /https:\/\/i\.ytimg\.com\/vi\/yt-1\/hqdefault\.jpg/);
|
||||
});
|
||||
|
||||
test('resolveMediaCoverApiUrl appends retry tokens for late cover refreshes', () => {
|
||||
assert.equal(
|
||||
resolveMediaCoverApiUrl(youtubeEpisodeA.videoId, 2),
|
||||
'http://127.0.0.1:6969/api/stats/media/1/cover?coverRetry=2',
|
||||
);
|
||||
});
|
||||
|
||||
test('MediaCard prefers youtube video title over canonical fallback url slug', () => {
|
||||
const markup = renderToStaticMarkup(<MediaCard item={youtubeEpisodeA} onClick={() => {}} />);
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { BASE_URL } from './api-client';
|
||||
import { appendCoverRetryToken } from './cover-retry';
|
||||
import type { MediaLibraryItem } from '../types/stats';
|
||||
|
||||
export interface MediaLibraryGroup {
|
||||
@@ -22,8 +23,8 @@ export function resolveMediaArtworkUrl(
|
||||
return normalized.length > 0 ? normalized : null;
|
||||
}
|
||||
|
||||
export function resolveMediaCoverApiUrl(videoId: number): string {
|
||||
return `${BASE_URL}/api/stats/media/${videoId}/cover`;
|
||||
export function resolveMediaCoverApiUrl(videoId: number, retryToken = 0): string {
|
||||
return appendCoverRetryToken(`${BASE_URL}/api/stats/media/${videoId}/cover`, retryToken);
|
||||
}
|
||||
|
||||
export function summarizeMediaLibraryGroups(groups: MediaLibraryGroup[]): {
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import {
|
||||
buildStatsMineCardParams,
|
||||
getStatsMineCardError,
|
||||
getStatsMineCardUnavailableReason,
|
||||
} from './mining';
|
||||
import type { SentenceSearchResult } from '../types/stats';
|
||||
|
||||
function makeResult(overrides: Partial<SentenceSearchResult> = {}): SentenceSearchResult {
|
||||
return {
|
||||
animeId: null,
|
||||
animeTitle: 'Little Witch Academia',
|
||||
videoId: 4,
|
||||
videoTitle: 'Episode 4',
|
||||
sourcePath: '/tmp/lwa.mkv',
|
||||
secondaryText: 'Magic is gone',
|
||||
sessionId: 7,
|
||||
lineIndex: 12,
|
||||
segmentStartMs: 5_000,
|
||||
segmentEndMs: 6_000,
|
||||
text: '魔法がなくなった',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
test('buildStatsMineCardParams maps sentence result context to the shared mining payload', () => {
|
||||
assert.deepEqual(buildStatsMineCardParams(makeResult(), '魔法', 'sentence'), {
|
||||
sourcePath: '/tmp/lwa.mkv',
|
||||
startMs: 5_000,
|
||||
endMs: 6_000,
|
||||
sentence: '魔法がなくなった',
|
||||
word: '魔法',
|
||||
secondaryText: 'Magic is gone',
|
||||
videoTitle: 'Episode 4',
|
||||
mode: 'sentence',
|
||||
});
|
||||
});
|
||||
|
||||
test('buildStatsMineCardParams returns null when media context is incomplete', () => {
|
||||
assert.equal(
|
||||
buildStatsMineCardParams(makeResult({ sourcePath: null }), '魔法', 'sentence'),
|
||||
null,
|
||||
);
|
||||
assert.equal(
|
||||
buildStatsMineCardParams(makeResult({ segmentStartMs: null }), '魔法', 'sentence'),
|
||||
null,
|
||||
);
|
||||
assert.equal(
|
||||
buildStatsMineCardParams(makeResult({ segmentEndMs: null }), '魔法', 'sentence'),
|
||||
null,
|
||||
);
|
||||
});
|
||||
|
||||
test('buildStatsMineCardParams returns null when stored timing has no positive duration', () => {
|
||||
assert.equal(
|
||||
buildStatsMineCardParams(
|
||||
makeResult({ segmentStartMs: 5_000, segmentEndMs: 4_900 }),
|
||||
'魔法',
|
||||
'sentence',
|
||||
),
|
||||
null,
|
||||
);
|
||||
assert.equal(
|
||||
getStatsMineCardUnavailableReason(makeResult({ segmentStartMs: 5_000, segmentEndMs: 5_000 })),
|
||||
'This line has invalid segment timing.',
|
||||
);
|
||||
});
|
||||
|
||||
test('getStatsMineCardError surfaces partial media failures', () => {
|
||||
assert.equal(
|
||||
getStatsMineCardError({ noteId: 1, errors: ['audio: ffmpeg failed'] }),
|
||||
'audio: ffmpeg failed',
|
||||
);
|
||||
assert.equal(getStatsMineCardError({ error: 'File not found' }), 'File not found');
|
||||
assert.equal(getStatsMineCardError({ noteId: 1 }), null);
|
||||
});
|
||||
@@ -0,0 +1,68 @@
|
||||
import type { SentenceSearchResult } from '../types/stats';
|
||||
|
||||
export type StatsMineMode = 'word' | 'sentence' | 'audio';
|
||||
|
||||
export interface StatsMineCardParams {
|
||||
sourcePath: string;
|
||||
startMs: number;
|
||||
endMs: number;
|
||||
sentence: string;
|
||||
word: string;
|
||||
secondaryText?: string | null;
|
||||
videoTitle: string;
|
||||
mode: StatsMineMode;
|
||||
}
|
||||
|
||||
export interface StatsMineCardResponse {
|
||||
noteId?: number;
|
||||
error?: string;
|
||||
errors?: string[];
|
||||
}
|
||||
|
||||
export function getStatsMineCardUnavailableReason(
|
||||
result: Pick<SentenceSearchResult, 'sourcePath' | 'segmentStartMs' | 'segmentEndMs'>,
|
||||
): string | null {
|
||||
if (!result.sourcePath) {
|
||||
return 'This source has no local file path.';
|
||||
}
|
||||
if (result.segmentStartMs == null || result.segmentEndMs == null) {
|
||||
return 'This line is missing segment timing.';
|
||||
}
|
||||
if (
|
||||
!Number.isFinite(result.segmentStartMs) ||
|
||||
!Number.isFinite(result.segmentEndMs) ||
|
||||
result.segmentEndMs <= result.segmentStartMs
|
||||
) {
|
||||
return 'This line has invalid segment timing.';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function buildStatsMineCardParams(
|
||||
result: Pick<
|
||||
SentenceSearchResult,
|
||||
'sourcePath' | 'segmentStartMs' | 'segmentEndMs' | 'text' | 'secondaryText' | 'videoTitle'
|
||||
>,
|
||||
word: string,
|
||||
mode: StatsMineMode,
|
||||
): StatsMineCardParams | null {
|
||||
if (getStatsMineCardUnavailableReason(result)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
sourcePath: result.sourcePath!,
|
||||
startMs: result.segmentStartMs!,
|
||||
endMs: result.segmentEndMs!,
|
||||
sentence: result.text,
|
||||
word,
|
||||
secondaryText: result.secondaryText,
|
||||
videoTitle: result.videoTitle,
|
||||
mode,
|
||||
};
|
||||
}
|
||||
|
||||
export function getStatsMineCardError(response: StatsMineCardResponse): string | null {
|
||||
if (response.error) return response.error;
|
||||
return response.errors?.[0] ?? null;
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
function getPreferenceStorage(): Storage | null {
|
||||
try {
|
||||
return globalThis.localStorage ?? null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function readBooleanPreference(key: string, fallback: boolean): boolean {
|
||||
try {
|
||||
const value = getPreferenceStorage()?.getItem(key);
|
||||
if (value === 'true') return true;
|
||||
if (value === 'false') return false;
|
||||
return fallback;
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
export function writeBooleanPreference(key: string, value: boolean): void {
|
||||
try {
|
||||
getPreferenceStorage()?.setItem(key, String(value));
|
||||
} catch {
|
||||
// Storage can be blocked in private/restricted contexts; keep the in-memory choice.
|
||||
}
|
||||
}
|
||||
@@ -1,51 +1,52 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import assert from 'node:assert/strict';
|
||||
import { describe, it } from 'node:test';
|
||||
import { fullReading } from './reading-utils';
|
||||
|
||||
describe('fullReading', () => {
|
||||
it('prefixes leading hiragana from headword', () => {
|
||||
// お前 with reading まえ → おまえ
|
||||
expect(fullReading('お前', 'まえ')).toBe('おまえ');
|
||||
assert.equal(fullReading('お前', 'まえ'), 'おまえ');
|
||||
});
|
||||
|
||||
it('handles katakana stored readings', () => {
|
||||
// お前 with katakana reading マエ → おまえ
|
||||
expect(fullReading('お前', 'マエ')).toBe('おまえ');
|
||||
assert.equal(fullReading('お前', 'マエ'), 'おまえ');
|
||||
});
|
||||
|
||||
it('returns stored reading when it already includes leading kana', () => {
|
||||
// Reading already correct
|
||||
expect(fullReading('お前', 'おまえ')).toBe('おまえ');
|
||||
assert.equal(fullReading('お前', 'おまえ'), 'おまえ');
|
||||
});
|
||||
|
||||
it('handles trailing hiragana', () => {
|
||||
// 隠す with reading かくす — す is trailing hiragana
|
||||
expect(fullReading('隠す', 'かくす')).toBe('かくす');
|
||||
assert.equal(fullReading('隠す', 'かくす'), 'かくす');
|
||||
});
|
||||
|
||||
it('handles pure kanji headwords', () => {
|
||||
expect(fullReading('様', 'さま')).toBe('さま');
|
||||
assert.equal(fullReading('様', 'さま'), 'さま');
|
||||
});
|
||||
|
||||
it('returns empty for empty reading', () => {
|
||||
expect(fullReading('前', '')).toBe('');
|
||||
assert.equal(fullReading('前', ''), '');
|
||||
});
|
||||
|
||||
it('returns empty for empty headword', () => {
|
||||
expect(fullReading('', 'まえ')).toBe('まえ');
|
||||
assert.equal(fullReading('', 'まえ'), 'まえ');
|
||||
});
|
||||
|
||||
it('handles all-kana headword', () => {
|
||||
// Headword is already all hiragana
|
||||
expect(fullReading('いますぐ', 'いますぐ')).toBe('いますぐ');
|
||||
assert.equal(fullReading('いますぐ', 'いますぐ'), 'いますぐ');
|
||||
});
|
||||
|
||||
it('handles mixed leading and trailing kana', () => {
|
||||
// お気に入り: お=leading, に入り=trailing around 気
|
||||
expect(fullReading('お気に入り', 'きにいり')).toBe('おきにいり');
|
||||
assert.equal(fullReading('お気に入り', 'きにいり'), 'おきにいり');
|
||||
});
|
||||
|
||||
it('handles katakana in headword', () => {
|
||||
// カズマ様 — leading katakana + kanji
|
||||
expect(fullReading('カズマ様', 'さま')).toBe('かずまさま');
|
||||
assert.equal(fullReading('カズマ様', 'さま'), 'かずまさま');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -41,8 +41,10 @@ export function fullReading(headword: string, storedReading: string): string {
|
||||
const chars = [...headword];
|
||||
|
||||
let i = 0;
|
||||
while (i < chars.length && (isHiragana(chars[i]) || isKatakana(chars[i]))) {
|
||||
leadingKana.push(katakanaToHiragana(chars[i]));
|
||||
while (i < chars.length) {
|
||||
const ch = chars[i]!;
|
||||
if (!isHiragana(ch) && !isKatakana(ch)) break;
|
||||
leadingKana.push(katakanaToHiragana(ch));
|
||||
i++;
|
||||
}
|
||||
|
||||
@@ -51,8 +53,10 @@ export function fullReading(headword: string, storedReading: string): string {
|
||||
}
|
||||
|
||||
let j = chars.length - 1;
|
||||
while (j > i && (isHiragana(chars[j]) || isKatakana(chars[j]))) {
|
||||
trailingKana.unshift(katakanaToHiragana(chars[j]));
|
||||
while (j > i) {
|
||||
const ch = chars[j]!;
|
||||
if (!isHiragana(ch) && !isKatakana(ch)) break;
|
||||
trailingKana.unshift(katakanaToHiragana(ch));
|
||||
j--;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
import {
|
||||
findExactSentenceMatches,
|
||||
getSentenceSearchMineAvailability,
|
||||
renderSentenceWithMatches,
|
||||
} from './sentence-search';
|
||||
import type { SentenceSearchResult } from '../types/stats';
|
||||
|
||||
function makeResult(over: Partial<SentenceSearchResult>): SentenceSearchResult {
|
||||
return {
|
||||
animeId: null,
|
||||
animeTitle: null,
|
||||
videoId: 1,
|
||||
videoTitle: 'Episode 1',
|
||||
sourcePath: '/tmp/video.mkv',
|
||||
secondaryText: null,
|
||||
sessionId: 10,
|
||||
lineIndex: 3,
|
||||
segmentStartMs: 1000,
|
||||
segmentEndMs: 2500,
|
||||
text: '猫が猫を見た',
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
test('findExactSentenceMatches returns every exact searched-word range', () => {
|
||||
assert.deepEqual(findExactSentenceMatches('猫が猫を見た', '猫'), [
|
||||
{ start: 0, end: 1 },
|
||||
{ start: 2, end: 3 },
|
||||
]);
|
||||
});
|
||||
|
||||
test('findExactSentenceMatches keeps source-text ranges under case folding', () => {
|
||||
assert.deepEqual(findExactSentenceMatches('İstanbul', 'İ'), [{ start: 0, end: 1 }]);
|
||||
});
|
||||
|
||||
test('getSentenceSearchMineAvailability gates word and audio mining on exact sentence match', () => {
|
||||
const result = makeResult({});
|
||||
|
||||
assert.deepEqual(getSentenceSearchMineAvailability(result, '猫'), {
|
||||
canMineSentence: true,
|
||||
canMineWordAudio: true,
|
||||
exactMatch: true,
|
||||
unavailableReason: null,
|
||||
});
|
||||
|
||||
assert.deepEqual(getSentenceSearchMineAvailability(result, '犬'), {
|
||||
canMineSentence: true,
|
||||
canMineWordAudio: false,
|
||||
exactMatch: false,
|
||||
unavailableReason: null,
|
||||
});
|
||||
});
|
||||
|
||||
test('getSentenceSearchMineAvailability disables every mining mode without source timing', () => {
|
||||
const result = makeResult({ sourcePath: null, segmentEndMs: null });
|
||||
|
||||
assert.deepEqual(getSentenceSearchMineAvailability(result, '猫'), {
|
||||
canMineSentence: false,
|
||||
canMineWordAudio: false,
|
||||
exactMatch: true,
|
||||
unavailableReason: 'This source has no local file path.',
|
||||
});
|
||||
});
|
||||
|
||||
test('getSentenceSearchMineAvailability disables every mining mode with invalid source timing', () => {
|
||||
const result = makeResult({ segmentStartMs: 2500, segmentEndMs: 2400 });
|
||||
|
||||
assert.deepEqual(getSentenceSearchMineAvailability(result, '猫'), {
|
||||
canMineSentence: false,
|
||||
canMineWordAudio: false,
|
||||
exactMatch: true,
|
||||
unavailableReason: 'This line has invalid segment timing.',
|
||||
});
|
||||
});
|
||||
|
||||
test('renderSentenceWithMatches highlights exact searched-word matches', () => {
|
||||
const markup = renderToStaticMarkup(<>{renderSentenceWithMatches('猫が寝る', '猫')}</>);
|
||||
|
||||
assert.match(markup, /<mark/);
|
||||
assert.match(markup, />猫<\/mark>/);
|
||||
});
|
||||
@@ -0,0 +1,112 @@
|
||||
import { Fragment, type ReactNode } from 'react';
|
||||
import type { SentenceSearchResult } from '../types/stats';
|
||||
import { getStatsMineCardUnavailableReason } from './mining';
|
||||
|
||||
export interface SentenceMatchRange {
|
||||
start: number;
|
||||
end: number;
|
||||
}
|
||||
|
||||
export interface SentenceSearchMineAvailability {
|
||||
canMineSentence: boolean;
|
||||
canMineWordAudio: boolean;
|
||||
exactMatch: boolean;
|
||||
unavailableReason: string | null;
|
||||
}
|
||||
|
||||
function normalizedSearchWord(query: string): string {
|
||||
return query.trim();
|
||||
}
|
||||
|
||||
function buildFoldedSearchIndex(text: string): {
|
||||
text: string;
|
||||
sourceStartByIndex: number[];
|
||||
sourceEndByIndex: number[];
|
||||
} {
|
||||
let foldedText = '';
|
||||
const sourceStartByIndex: number[] = [];
|
||||
const sourceEndByIndex: number[] = [];
|
||||
|
||||
for (let sourceStart = 0; sourceStart < text.length; ) {
|
||||
const codePoint = text.codePointAt(sourceStart);
|
||||
if (codePoint == null) break;
|
||||
|
||||
const char = String.fromCodePoint(codePoint);
|
||||
const sourceEnd = sourceStart + char.length;
|
||||
const foldedChar = char.toLocaleLowerCase();
|
||||
for (let index = 0; index < foldedChar.length; index++) {
|
||||
sourceStartByIndex.push(sourceStart);
|
||||
sourceEndByIndex.push(sourceEnd);
|
||||
}
|
||||
foldedText += foldedChar;
|
||||
sourceStart = sourceEnd;
|
||||
}
|
||||
|
||||
return { text: foldedText, sourceStartByIndex, sourceEndByIndex };
|
||||
}
|
||||
|
||||
export function findExactSentenceMatches(text: string, query: string): SentenceMatchRange[] {
|
||||
const needle = normalizedSearchWord(query);
|
||||
if (!needle) return [];
|
||||
|
||||
const ranges: SentenceMatchRange[] = [];
|
||||
const haystack = buildFoldedSearchIndex(text);
|
||||
const normalizedNeedle = needle.toLocaleLowerCase();
|
||||
let searchFrom = 0;
|
||||
|
||||
while (searchFrom < haystack.text.length) {
|
||||
const index = haystack.text.indexOf(normalizedNeedle, searchFrom);
|
||||
if (index < 0) break;
|
||||
const endIndex = index + normalizedNeedle.length - 1;
|
||||
ranges.push({
|
||||
start: haystack.sourceStartByIndex[index] ?? index,
|
||||
end: haystack.sourceEndByIndex[endIndex] ?? index + normalizedNeedle.length,
|
||||
});
|
||||
searchFrom = index + normalizedNeedle.length;
|
||||
}
|
||||
|
||||
return ranges;
|
||||
}
|
||||
|
||||
export function getSentenceSearchMineAvailability(
|
||||
result: Pick<SentenceSearchResult, 'sourcePath' | 'segmentStartMs' | 'segmentEndMs' | 'text'>,
|
||||
query: string,
|
||||
): SentenceSearchMineAvailability {
|
||||
const exactMatch = findExactSentenceMatches(result.text, query).length > 0;
|
||||
const unavailableReason = getStatsMineCardUnavailableReason(result);
|
||||
|
||||
return {
|
||||
canMineSentence: unavailableReason === null,
|
||||
canMineWordAudio: unavailableReason === null && exactMatch,
|
||||
exactMatch,
|
||||
unavailableReason,
|
||||
};
|
||||
}
|
||||
|
||||
export function renderSentenceWithMatches(text: string, query: string): ReactNode {
|
||||
const ranges = findExactSentenceMatches(text, query);
|
||||
if (ranges.length === 0) return text;
|
||||
|
||||
const parts: ReactNode[] = [];
|
||||
let cursor = 0;
|
||||
ranges.forEach((range, index) => {
|
||||
if (range.start > cursor) {
|
||||
parts.push(<Fragment key={`text-${cursor}`}>{text.slice(cursor, range.start)}</Fragment>);
|
||||
}
|
||||
parts.push(
|
||||
<mark
|
||||
key={`${range.start}-${index}`}
|
||||
className="rounded bg-ctp-yellow/15 px-0.5 text-ctp-yellow underline decoration-ctp-yellow/60 underline-offset-2"
|
||||
>
|
||||
{text.slice(range.start, range.end)}
|
||||
</mark>,
|
||||
);
|
||||
cursor = range.end;
|
||||
});
|
||||
|
||||
if (cursor < text.length) {
|
||||
parts.push(<Fragment key={`text-${cursor}`}>{text.slice(cursor)}</Fragment>);
|
||||
}
|
||||
|
||||
return parts;
|
||||
}
|
||||
@@ -4,8 +4,9 @@ import type { SessionSummary } from '../types/stats';
|
||||
import { groupSessionsByVideo } from './session-grouping';
|
||||
|
||||
function makeSession(overrides: Partial<SessionSummary> & { sessionId: number }): SessionSummary {
|
||||
const { sessionId, ...rest } = overrides;
|
||||
return {
|
||||
sessionId: overrides.sessionId,
|
||||
sessionId,
|
||||
canonicalTitle: null,
|
||||
videoId: null,
|
||||
animeId: null,
|
||||
@@ -22,7 +23,7 @@ function makeSession(overrides: Partial<SessionSummary> & { sessionId: number })
|
||||
yomitanLookupCount: 0,
|
||||
knownWordsSeen: 0,
|
||||
knownWordRate: 0,
|
||||
...overrides,
|
||||
...rest,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ test('TabBar renders Library instead of Anime for the media library tab', () =>
|
||||
assert.doesNotMatch(markup, />Anime</);
|
||||
assert.match(markup, />Overview</);
|
||||
assert.match(markup, />Library</);
|
||||
assert.match(markup, />Search</);
|
||||
});
|
||||
|
||||
test('EpisodeList renders explicit episode detail button alongside quick peek row', () => {
|
||||
|
||||
@@ -132,7 +132,6 @@ test('AnimeOverviewStats renders aggregate Yomitan lookup metrics', () => {
|
||||
episodeCount: 3,
|
||||
lastWatchedMs: 0,
|
||||
}}
|
||||
avgSessionMs={20_000}
|
||||
knownWordsSummary={null}
|
||||
/>,
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user