feat(stats): add sentence search tab and fix mining card behavior

- Add Search tab with realtime subtitle sentence search, media context, and mining actions
- Persist library card size selection across reloads
- Fix selection text written only for sentence cards, not word/audio cards
- Fix word mining audio routed to SentenceAudio field when present
- Fix sentence card created immediately before slow media generation completes
- Fix Anki config resolved at request time for sentence mining
- Prefer non-Signs/Songs tracks when auto-selecting secondary subtitle language
This commit is contained in:
2026-06-05 01:39:06 -07:00
parent e9d97fb01e
commit eb5e07cfc0
33 changed files with 1532 additions and 128 deletions
@@ -2,3 +2,8 @@ type: fixed
area: stats area: stats
- Fixed vocab-page example sentence mining buttons failing when the Anki deck setting is blank or Yomitan card formats are ordered with a non-term card first. - Fixed vocab-page example sentence mining buttons failing when the Anki deck setting is blank or Yomitan card formats are ordered with a non-term card first.
- Fixed vocab-page example word and audio mining so English subtitles are only written to Selection Text for sentence cards, leaving word cards to show the normal Yomitan dictionary glossary.
- Fixed stats-page mining audio updates so generated sentence clips populate `SentenceAudio` when that field exists, while preserving configured expression-audio behavior for direct sentence cards.
- Fixed stats-page word mining so the hidden Yomitan helper uses the same configured word-audio sources as the normal Yomitan plus button.
- Fixed stats-page sentence mining to use the current Anki deck/settings at request time, create direct sentence cards before slow media generation completes, and include stored English subtitle text as Selection Text.
- Fixed secondary subtitle auto-selection to prefer regular English tracks over Signs/Songs tracks when both are available.
+4
View File
@@ -0,0 +1,4 @@
type: fixed
area: stats
- Library card size selection is now remembered across Stats window reloads and remounts.
+6
View File
@@ -0,0 +1,6 @@
type: added
area: stats
- Added a Search tab for realtime subtitle sentence search with media context and mining actions.
- Search results can mine sentence cards from valid source lines, while word/audio card actions appear only for exact searched-word matches.
- Search results omit secondary subtitle text from display and matching, but pass stored secondary subtitle text into sentence-card mining when available.
+5 -5
View File
@@ -517,8 +517,8 @@ See `config.example.jsonc` for detailed configuration options.
``` ```
| Option | Values | Description | | Option | Values | Description |
| ----------------------- | ---------------------------------- | ------------------------------------------------------ | | ----------------------- | ---------------------------------- | ------------------------------------------------------------------------------------------------------------------- |
| `secondarySubLanguages` | string[] | Language codes to auto-load (e.g., `["eng", "en"]`) | | `secondarySubLanguages` | string[] | Language codes to auto-load (e.g., `["eng", "en"]`); non-Signs/Songs tracks are preferred when several tracks match |
| `autoLoadSecondarySub` | `true`, `false` | Auto-detect and load matching secondary subtitle track | | `autoLoadSecondarySub` | `true`, `false` | Auto-detect and load matching secondary subtitle track |
| `defaultMode` | `"hidden"`, `"visible"`, `"hover"` | Initial display mode (default: `"hover"`) | | `defaultMode` | `"hidden"`, `"visible"`, `"hover"` | Initial display mode (default: `"hover"`) |
@@ -1433,7 +1433,7 @@ Usage notes:
- The browser UI is served at `http://127.0.0.1:<serverPort>`. - The browser UI is served at `http://127.0.0.1:<serverPort>`.
- The overlay toggle is local to the focused visible overlay window; it is not registered as a global OS shortcut. - The overlay toggle is local to the focused visible overlay window; it is not registered as a global OS shortcut.
- The dashboard reads from the same immersion-tracking database, so keep `immersionTracking.enabled` on if you want data to appear. - The dashboard reads from the same immersion-tracking database, so keep `immersionTracking.enabled` on if you want data to appear.
- The UI includes Overview, Library, Trends, Vocabulary, and Sessions tabs. - The UI includes Overview, Library, Trends, Vocabulary, Search, and Sessions tabs.
### MPV Launcher ### MPV Launcher
@@ -1457,14 +1457,14 @@ Configure the mpv executable, profile, and window state for SubMiner-managed mpv
``` ```
| Option | Values | Description | | Option | Values | Description |
| ----------------------- | ------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------- | | ------------------------ | --------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------- |
| `executablePath` | string | Absolute path to `mpv.exe` for Windows launch flows. Leave empty to auto-discover from `SUBMINER_MPV_PATH` or `PATH` (default `""`) | | `executablePath` | string | Absolute path to `mpv.exe` for Windows launch flows. Leave empty to auto-discover from `SUBMINER_MPV_PATH` or `PATH` (default `""`) |
| `profile` | string | mpv profile name passed as `--profile=<name>`. Leave empty to pass no profile (default `""`) | | `profile` | string | mpv profile name passed as `--profile=<name>`. Leave empty to pass no profile (default `""`) |
| `launchMode` | `"normal"` \| `"maximized"` \| `"fullscreen"` | Window state when SubMiner spawns mpv (default `"normal"`) | | `launchMode` | `"normal"` \| `"maximized"` \| `"fullscreen"` | Window state when SubMiner spawns mpv (default `"normal"`) |
| `socketPath` | string | mpv IPC socket path used by SubMiner-managed playback and the bundled mpv plugin (default: `\\\\.\\pipe\\subminer-socket`) | | `socketPath` | string | mpv IPC socket path used by SubMiner-managed playback and the bundled mpv plugin (default: `\\\\.\\pipe\\subminer-socket`) |
| `backend` | `"auto"` \| `"hyprland"` \| `"sway"` \| `"x11"` \| `"macos"` \| `"windows"` | Window tracking backend passed to the bundled mpv plugin. Auto detects the current platform (default: `"auto"`) | | `backend` | `"auto"` \| `"hyprland"` \| `"sway"` \| `"x11"` \| `"macos"` \| `"windows"` | Window tracking backend passed to the bundled mpv plugin. Auto detects the current platform (default: `"auto"`) |
| `autoStartSubMiner` | `true`, `false` | Start SubMiner in the background when SubMiner-managed mpv loads a file (default: `true`) | | `autoStartSubMiner` | `true`, `false` | Start SubMiner in the background when SubMiner-managed mpv loads a file (default: `true`) |
| `pauseUntilOverlayReady`| `true`, `false` | Pause mpv on visible-overlay auto-start until SubMiner signals subtitle tokenization readiness (default: `true`) | | `pauseUntilOverlayReady` | `true`, `false` | Pause mpv on visible-overlay auto-start until SubMiner signals subtitle tokenization readiness (default: `true`) |
| `subminerBinaryPath` | string | SubMiner app binary path passed to the bundled mpv plugin. Leave empty to use the launcher-detected app path (default: `""`) | | `subminerBinaryPath` | string | SubMiner app binary path passed to the bundled mpv plugin. Leave empty to use the launcher-detected app path (default: `""`) |
| `aniskipEnabled` | `true`, `false` | Enable AniSkip intro detection and skip markers in the bundled mpv plugin (default: `true`) | | `aniskipEnabled` | `true`, `false` | Enable AniSkip intro detection and skip markers in the bundled mpv plugin (default: `true`) |
| `aniskipButtonKey` | string | mpv key used to trigger the AniSkip button while the skip marker is visible (default: `"TAB"`) | | `aniskipButtonKey` | string | mpv key used to trigger the AniSkip button while the skip marker is visible (default: `"TAB"`) |
+13 -9
View File
@@ -18,8 +18,8 @@ Episode completion for local `watched` state uses the shared `DEFAULT_MIN_WATCH_
{ {
"immersionTracking": { "immersionTracking": {
"enabled": true, "enabled": true,
"dbPath": "" "dbPath": "",
} },
} }
``` ```
@@ -70,6 +70,10 @@ Top repeated words (click a bar to open the word), new-word timeline, frequency
![Stats Vocabulary](/screenshots/stats-vocabulary.png) ![Stats Vocabulary](/screenshots/stats-vocabulary.png)
#### Search
Realtime search across tracked primary subtitle lines and media titles. Results show the source media, session, line number, timing, and sentence text. Secondary subtitle text is not shown or searched here because separate subtitle tracks may not line up sentence-for-sentence. Sentence cards can be mined from any result with a valid local source and timing. Word and audio card buttons appear only when the searched word exactly appears in the primary sentence text; matching text is highlighted in the result.
Stats server config lives under `stats`: Stats server config lives under `stats`:
```jsonc ```jsonc
@@ -78,8 +82,8 @@ Stats server config lives under `stats`:
"toggleKey": "Backquote", "toggleKey": "Backquote",
"serverPort": 6969, "serverPort": 6969,
"autoStartServer": true, "autoStartServer": true,
"autoOpenBrowser": false "autoOpenBrowser": false,
} },
} }
``` ```
@@ -96,15 +100,15 @@ Stats server config lives under `stats`:
## Mining Cards from the Stats Page ## Mining Cards from the Stats Page
The Vocabulary tab's word detail panel shows example lines from your viewing history. Each example line with a valid source file offers three mining buttons: The Search tab and the Vocabulary tab's word detail panel both mine from subtitle lines in your viewing history. Each line with a valid source file offers sentence-card mining; word/audio mining is available when the selected word or searched word appears in the sentence:
- **Mine Word** - performs a full Yomitan dictionary lookup for the word (definition, reading, pitch accent, etc.) via a short-lived hidden helper, then enriches the card with sentence audio, a screenshot or animated AVIF clip, the highlighted sentence, and metadata extracted from the source video file. Requires Anki and Yomitan dictionaries to be loaded. - **Mine Word** - performs a full Yomitan dictionary lookup for the word (definition, reading, pitch accent, etc.) via a short-lived hidden helper, then enriches the card with sentence audio, a screenshot or animated AVIF clip, the highlighted sentence, and metadata extracted from the source video file. Requires Anki and Yomitan dictionaries to be loaded.
- **Mine Sentence** - creates a sentence card directly with the `IsSentenceCard` flag set (for Lapis/Kiku workflows), along with audio, image, and translation from the secondary subtitle if available. - **Mine Sentence** - creates a sentence card directly with the `IsSentenceCard` flag set (for Lapis/Kiku workflows), along with audio and image from the source video.
- **Mine Audio** - creates an audio-only card with the `IsAudioCard` flag, attaching only the sentence audio clip. - **Mine Audio** - creates an audio-only card with the `IsAudioCard` flag, attaching only the sentence audio clip.
All three modes respect your `ankiConnect` config: deck, model, field mappings, media settings (static vs AVIF, quality, dimensions), audio padding, metadata pattern, and tags. Media generation runs in parallel for faster card creation. All three modes respect your `ankiConnect` config: deck, model, field mappings, media settings (static vs AVIF, quality, dimensions), audio padding, metadata pattern, and tags. Media generation runs in parallel for faster card creation.
Secondary subtitle text (typically English translations) is stored alongside primary subtitles during playback and used as the translation field when mining from the stats page. Secondary subtitle text (typically English translations) is stored alongside primary subtitles during playback and can be used as the translation field when mining sentence cards from Search or vocabulary occurrences. The Search tab does not use that text for display or matching.
### Word Exclusion List ### Word Exclusion List
@@ -115,7 +119,7 @@ The Vocabulary tab toolbar includes an **Exclusions** button for hiding words fr
By default, SubMiner keeps all retention tables and raw data (`0` means keep all) while continuing daily/monthly rollup maintenance: By default, SubMiner keeps all retention tables and raw data (`0` means keep all) while continuing daily/monthly rollup maintenance:
| Data type | Retention | | Data type | Retention |
| -------------- | --------- | | --------------- | ------------ |
| Raw events | 0 (keep all) | | Raw events | 0 (keep all) |
| Telemetry | 0 (keep all) | | Telemetry | 0 (keep all) |
| Sessions | 0 (keep all) | | Sessions | 0 (keep all) |
@@ -147,7 +151,7 @@ The tracker is optimized for "keep everything" defaults:
All policy options live under `immersionTracking` in your config: All policy options live under `immersionTracking` in your config:
| Option | Description | | Option | Description |
| ------ | ----------- | | ------------------------------ | ------------------------------------------------------------------ |
| `batchSize` | Writes per flush batch | | `batchSize` | Writes per flush batch |
| `flushIntervalMs` | Max delay between flushes (default: 500ms) | | `flushIntervalMs` | Max delay between flushes (default: 500ms) |
| `queueCap` | Max queued writes before oldest are dropped | | `queueCap` | Max queued writes before oldest are dropped |
+1 -1
View File
@@ -287,7 +287,7 @@ Notes:
- For YouTube URLs, `subminer` probes available YouTube subtitle tracks, reuses existing authoritative tracks when available, and downloads only missing sides. - For YouTube URLs, `subminer` probes available YouTube subtitle tracks, reuses existing authoritative tracks when available, and downloads only missing sides.
- Native mpv secondary subtitle rendering stays hidden so the overlay remains the visible secondary subtitle surface. - Native mpv secondary subtitle rendering stays hidden so the overlay remains the visible secondary subtitle surface.
- Primary subtitle target languages come from `youtube.primarySubLanguages` (defaults to `["ja","jpn"]`). - Primary subtitle target languages come from `youtube.primarySubLanguages` (defaults to `["ja","jpn"]`).
- Secondary target languages come from `secondarySub.secondarySubLanguages` (empty by default; when empty, no language-based secondary track is auto-selected, though mpv's `--slang` list above still prefers English variants). - Secondary target languages come from `secondarySub.secondarySubLanguages` (empty by default; when empty, no language-based secondary track is auto-selected, though mpv's `--slang` list above still prefers English variants). When multiple matching secondary tracks exist, SubMiner prefers a non-Signs/Songs track.
- Configure defaults in `$XDG_CONFIG_HOME/SubMiner/config.jsonc` (or `~/.config/SubMiner/config.jsonc`) under `youtube` and `secondarySub`. - Configure defaults in `$XDG_CONFIG_HOME/SubMiner/config.jsonc` (or `~/.config/SubMiner/config.jsonc`) under `youtube` and `secondarySub`.
For local video files, SubMiner uses the same config-driven language priorities to auto-select the primary and secondary subtitle tracks from internal and external subtitle sources. For local video files, SubMiner uses the same config-driven language priorities to auto-select the primary and secondary subtitle tracks from internal and external subtitle sources.
@@ -338,7 +338,9 @@ function withTempDir<T>(fn: (dir: string) => Promise<T> | T): Promise<T> | T {
type CapturedAnkiRequest = { type CapturedAnkiRequest = {
action?: string; action?: string;
params?: { params?: {
notes?: number[];
note?: { note?: {
id?: number;
deckName?: string; deckName?: string;
modelName?: string; modelName?: string;
fields?: Record<string, string>; fields?: Record<string, string>;
@@ -365,6 +367,23 @@ async function withFakeAnkiConnect<T>(
} else { } else {
body = { result: 12345, error: null }; body = { result: 12345, error: null };
} }
} else if (payload.action === 'notesInfo') {
const noteIds = payload.params?.notes ?? [];
body = {
result: noteIds.map((noteId) => ({
noteId,
fields: {
Expression: { value: '猫' },
ExpressionAudio: { value: '[sound:word.mp3]' },
Sentence: { value: '' },
SentenceAudio: { value: '' },
Picture: { value: '' },
MiscInfo: { value: '' },
SelectionText: { value: '' },
},
})),
error: null,
};
} }
res.writeHead(200, { 'Content-Type': 'application/json' }); res.writeHead(200, { 'Content-Type': 'application/json' });
@@ -1149,6 +1168,370 @@ describe('stats server API routes', () => {
}); });
}); });
it('POST /api/stats/mine-card resolves Anki config at request time', async () => {
await withTempDir(async (dir) => {
const sourcePath = path.join(dir, 'episode.mkv');
fs.writeFileSync(sourcePath, 'fake media');
await withFakeAnkiConnect(async (requests, url) => {
const app = createStatsApp(createMockTracker(), {
getAnkiConnectConfig: () => ({
url,
deck: 'Mining',
tags: ['SubMiner'],
fields: {
word: 'Expression',
sentence: 'Sentence',
translation: 'SelectionText',
},
media: {
generateAudio: false,
generateImage: false,
},
isLapis: {
enabled: true,
sentenceCardModel: 'Lapis Morph',
},
}),
} as Parameters<typeof createStatsApp>[1]);
const res = await app.request('/api/stats/mine-card?mode=sentence', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
sourcePath,
startMs: 1_000,
endMs: 2_000,
sentence: '猫を見た',
word: '猫',
secondaryText: 'I saw a cat',
videoTitle: 'Episode 1',
}),
});
const body = await res.json();
assert.equal(res.status, 200, JSON.stringify(body));
const addNoteRequest = requests.find((request) => request.action === 'addNote');
assert.equal(addNoteRequest?.params?.note?.deckName, 'Mining');
assert.equal(addNoteRequest?.params?.note?.modelName, 'Lapis Morph');
assert.equal(addNoteRequest?.params?.note?.fields?.SelectionText, 'I saw a cat');
});
});
});
it('POST /api/stats/mine-card adds direct sentence cards before slow media finishes', async () => {
await withTempDir(async (dir) => {
const sourcePath = path.join(dir, 'episode.mkv');
fs.writeFileSync(sourcePath, 'fake media');
await withFakeAnkiConnect(async (requests, url) => {
const mediaRelease: {
audio?: () => void;
image?: () => void;
} = {};
const app = createStatsApp(createMockTracker(), {
createMediaGenerator: () => ({
generateAudio: async () =>
await new Promise<Buffer>((resolve) => {
mediaRelease.audio = () => resolve(Buffer.from('audio'));
}),
generateScreenshot: async () =>
await new Promise<Buffer>((resolve) => {
mediaRelease.image = () => resolve(Buffer.from('image'));
}),
generateAnimatedImage: async () => null,
}),
ankiConnectConfig: {
url,
deck: 'Mining',
tags: ['SubMiner'],
fields: {
word: 'Expression',
audio: 'ExpressionAudio',
image: 'Picture',
sentence: 'Sentence',
translation: 'SelectionText',
},
media: {
generateAudio: true,
generateImage: true,
imageType: 'static',
},
isLapis: {
enabled: true,
sentenceCardModel: 'Lapis Morph',
},
},
});
const pendingResponse = app.request('/api/stats/mine-card?mode=sentence', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
sourcePath,
startMs: 1_000,
endMs: 2_000,
sentence: '猫を見た',
word: '猫',
secondaryText: 'I saw a cat',
videoTitle: 'Episode 1',
}),
});
for (let attempt = 0; attempt < 20; attempt += 1) {
if (requests.some((request) => request.action === 'addNote')) break;
await new Promise((resolve) => setTimeout(resolve, 1));
}
const addedBeforeMediaFinished = requests.some((request) => request.action === 'addNote');
mediaRelease.audio?.();
mediaRelease.image?.();
const res = await pendingResponse;
const body = await res.json();
assert.equal(res.status, 200, JSON.stringify(body));
assert.equal(addedBeforeMediaFinished, true);
});
});
});
it('POST /api/stats/mine-card leaves word card selection text to Yomitan glossary fields', async () => {
await withTempDir(async (dir) => {
const sourcePath = path.join(dir, 'episode.mkv');
fs.writeFileSync(sourcePath, 'fake media');
await withFakeAnkiConnect(async (requests, url) => {
const app = createStatsApp(createMockTracker(), {
addYomitanNote: async () => 777,
ankiConnectConfig: {
url,
deck: 'Mining',
fields: {
audio: 'ExpressionAudio',
image: 'Picture',
sentence: 'Sentence',
miscInfo: 'MiscInfo',
translation: 'SelectionText',
},
media: {
generateAudio: false,
generateImage: false,
},
},
});
const res = await app.request('/api/stats/mine-card?mode=word', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
sourcePath,
startMs: 1_000,
endMs: 2_000,
sentence: '猫を見た',
word: '猫',
secondaryText: 'I saw a cat',
videoTitle: 'Episode 1',
}),
});
const body = await res.json();
assert.equal(res.status, 200, JSON.stringify(body));
assert.equal(body.noteId, 777);
const updateRequest = requests.find((request) => request.action === 'updateNoteFields');
assert.equal(updateRequest?.params?.note?.id, 777);
assert.equal(updateRequest?.params?.note?.fields?.Sentence, '<b>猫</b>を見た');
assert.equal(updateRequest?.params?.note?.fields?.SelectionText, undefined);
});
});
});
it('POST /api/stats/mine-card writes word mining audio to SentenceAudio when present', async () => {
await withTempDir(async (dir) => {
const sourcePath = path.join(dir, 'episode.mkv');
fs.writeFileSync(sourcePath, 'fake media');
await withFakeAnkiConnect(async (requests, url) => {
const app = createStatsApp(createMockTracker(), {
addYomitanNote: async () => 777,
createMediaGenerator: () => ({
generateAudio: async () => Buffer.from('audio'),
generateScreenshot: async () => null,
generateAnimatedImage: async () => null,
}),
ankiConnectConfig: {
url,
deck: 'Mining',
fields: {
audio: 'ExpressionAudio',
image: 'Picture',
sentence: 'Sentence',
miscInfo: 'MiscInfo',
},
media: {
generateAudio: true,
generateImage: false,
},
},
});
const res = await app.request('/api/stats/mine-card?mode=word', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
sourcePath,
startMs: 1_000,
endMs: 2_000,
sentence: '猫を見た',
word: '猫',
videoTitle: 'Episode 1',
}),
});
const body = await res.json();
assert.equal(res.status, 200, JSON.stringify(body));
const updateRequest = requests.find((request) => request.action === 'updateNoteFields');
const audioValue = updateRequest?.params?.note?.fields?.SentenceAudio;
assert.match(audioValue ?? '', /^\[sound:subminer_audio_\d+\.mp3\]$/);
assert.equal(updateRequest?.params?.note?.fields?.ExpressionAudio, undefined);
});
});
});
it('POST /api/stats/mine-card records timing for slow sentence mining phases', async () => {
await withTempDir(async (dir) => {
const sourcePath = path.join(dir, 'episode.mkv');
fs.writeFileSync(sourcePath, 'fake media');
await withFakeAnkiConnect(async (requests, url) => {
let now = 0;
const timings: Array<{ mode: string; phase: string; elapsedMs: number; noteId?: number }> =
[];
const app = createStatsApp(createMockTracker(), {
nowMs: () => {
now += 10;
return now;
},
onMiningTiming: (event) => {
timings.push(event);
},
createMediaGenerator: () => ({
generateAudio: async () => Buffer.from('audio'),
generateScreenshot: async () => Buffer.from('image'),
generateAnimatedImage: async () => Buffer.from('animated'),
}),
ankiConnectConfig: {
url,
deck: 'Mining',
tags: ['SubMiner'],
fields: {
word: 'Expression',
audio: 'ExpressionAudio',
image: 'Picture',
sentence: 'Sentence',
miscInfo: 'MiscInfo',
translation: 'SelectionText',
},
media: {
generateAudio: true,
generateImage: true,
imageType: 'static',
},
isLapis: {
enabled: true,
sentenceCardModel: 'Lapis Morph',
},
},
});
const res = await app.request('/api/stats/mine-card?mode=sentence', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
sourcePath,
startMs: 1_000,
endMs: 2_000,
sentence: '猫を見た',
word: '猫',
secondaryText: 'I saw a cat',
videoTitle: 'Episode 1',
}),
});
const body = await res.json();
assert.equal(res.status, 200, JSON.stringify(body));
assert.deepEqual(
timings.map((entry) => entry.phase),
[
'generateAudio',
'generateScreenshot',
'addNote',
'uploadAudio',
'uploadImage',
'updateNoteFields',
],
);
assert.ok(timings.every((entry) => entry.mode === 'sentence' && entry.elapsedMs >= 0));
assert.equal(timings.find((entry) => entry.phase === 'addNote')?.noteId, 12345);
const updateRequest = requests.find((request) => request.action === 'updateNoteFields');
const audioValue = updateRequest?.params?.note?.fields?.SentenceAudio;
assert.match(audioValue ?? '', /^\[sound:subminer_audio_\d+\.mp3\]$/);
assert.equal(updateRequest?.params?.note?.fields?.ExpressionAudio, audioValue);
});
});
});
it('POST /api/stats/mine-card only writes selection text for sentence cards', async () => {
await withTempDir(async (dir) => {
const sourcePath = path.join(dir, 'episode.mkv');
fs.writeFileSync(sourcePath, 'fake media');
await withFakeAnkiConnect(async (requests, url) => {
const app = createStatsApp(createMockTracker(), {
ankiConnectConfig: {
url,
deck: 'Mining',
fields: {
word: 'Expression',
sentence: 'Sentence',
translation: 'SelectionText',
},
media: {
generateAudio: false,
generateImage: false,
},
isLapis: {
enabled: true,
sentenceCardModel: 'Lapis Morph',
},
},
});
const res = await app.request('/api/stats/mine-card?mode=audio', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
sourcePath,
startMs: 1_000,
endMs: 2_000,
sentence: '猫を見た',
word: '猫',
secondaryText: 'I saw a cat',
videoTitle: 'Episode 1',
}),
});
const body = await res.json();
assert.equal(res.status, 200, JSON.stringify(body));
const addNoteRequest = requests.find((request) => request.action === 'addNote');
assert.equal(addNoteRequest?.params?.note?.fields?.SelectionText, undefined);
assert.equal(addNoteRequest?.params?.note?.fields?.IsAudioCard, 'x');
});
});
});
it('GET /api/stats/episode/:videoId/detail returns episode detail', async () => { it('GET /api/stats/episode/:videoId/detail returns episode detail', async () => {
const app = createStatsApp(createMockTracker()); const app = createStatsApp(createMockTracker());
const res = await app.request('/api/stats/episode/1/detail'); const res = await app.request('/api/stats/episode/1/detail');
@@ -55,6 +55,7 @@ import {
getStatsExcludedWords, getStatsExcludedWords,
getVocabularyStats, getVocabularyStats,
replaceStatsExcludedWords, replaceStatsExcludedWords,
searchSubtitleSentences,
getWordAnimeAppearances, getWordAnimeAppearances,
getWordDetail, getWordDetail,
getWordOccurrences, getWordOccurrences,
@@ -148,6 +149,7 @@ import {
type MediaLibraryRow, type MediaLibraryRow,
type NewAnimePerDayRow, type NewAnimePerDayRow,
type QueuedWrite, type QueuedWrite,
type SentenceSearchResultRow,
type SessionEventRow, type SessionEventRow,
type SessionState, type SessionState,
type SessionSummaryQueryRow, type SessionSummaryQueryRow,
@@ -568,6 +570,10 @@ export class ImmersionTrackerService {
return getKanjiOccurrences(this.db, kanji, limit, offset); return getKanjiOccurrences(this.db, kanji, limit, offset);
} }
async searchSubtitleSentences(query: string, limit = 50): Promise<SentenceSearchResultRow[]> {
return searchSubtitleSentences(this.db, query, limit);
}
async getSessionEvents( async getSessionEvents(
sessionId: number, sessionId: number,
limit = 500, limit = 500,
@@ -35,6 +35,7 @@ import {
getSessionTimeline, getSessionTimeline,
getSessionWordsByLine, getSessionWordsByLine,
getWordOccurrences, getWordOccurrences,
searchSubtitleSentences,
upsertCoverArt, upsertCoverArt,
} from '../query.js'; } from '../query.js';
import { import {
@@ -3686,6 +3687,101 @@ test('getWordOccurrences maps a normalized word back to anime, video, and subtit
} }
}); });
test('searchSubtitleSentences searches known subtitle lines and returns media context', () => {
const dbPath = makeDbPath();
const db = new Database(dbPath);
try {
ensureSchema(db);
const animeId = getOrCreateAnimeRecord(db, {
parsedTitle: 'Dungeon Meshi',
canonicalTitle: 'Dungeon Meshi',
anilistId: null,
titleRomaji: null,
titleEnglish: null,
titleNative: null,
metadataJson: '{"source":"test"}',
});
const videoId = getOrCreateVideoRecord(db, 'local:/tmp/dungeon-meshi-01.mkv', {
canonicalTitle: 'Episode 1',
sourcePath: '/tmp/Dungeon Meshi 01.mkv',
sourceUrl: null,
sourceType: SOURCE_TYPE_LOCAL,
});
linkVideoToAnimeRecord(db, videoId, {
animeId,
parsedBasename: 'Dungeon Meshi 01.mkv',
parsedTitle: 'Dungeon Meshi',
parsedSeason: 1,
parsedEpisode: 1,
parserSource: 'fallback',
parserConfidence: 1,
parseMetadataJson: '{"episode":1}',
});
const { sessionId } = startSessionRecord(db, videoId, 3_000_000);
db.prepare(
`INSERT INTO imm_subtitle_lines (
session_id, event_id, video_id, anime_id, line_index, segment_start_ms, segment_end_ms,
text, secondary_text, CREATED_DATE, LAST_UPDATE_DATE
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
).run(
sessionId,
null,
videoId,
animeId,
7,
4_000,
5_500,
'魔物を食べるなんて信じられない',
'I cannot believe we are eating monsters',
3_000,
3_000,
);
db.prepare(
`INSERT INTO imm_subtitle_lines (
session_id, event_id, video_id, anime_id, line_index, segment_start_ms, segment_end_ms,
text, secondary_text, CREATED_DATE, LAST_UPDATE_DATE
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
).run(
sessionId,
null,
videoId,
animeId,
8,
6_000,
7_000,
'これは別の行です',
'Another line',
2_000,
2_000,
);
const rows = searchSubtitleSentences(db, '魔物 食べる', 10);
assert.deepEqual(rows, [
{
animeId,
animeTitle: 'Dungeon Meshi',
sourcePath: '/tmp/Dungeon Meshi 01.mkv',
secondaryText: 'I cannot believe we are eating monsters',
videoId,
videoTitle: 'Episode 1',
sessionId,
lineIndex: 7,
segmentStartMs: 4_000,
segmentEndMs: 5_500,
text: '魔物を食べるなんて信じられない',
},
]);
assert.deepEqual(searchSubtitleSentences(db, 'monsters', 10), []);
} finally {
db.close();
cleanupDbPath(dbPath);
}
});
test('getKanjiOccurrences maps a kanji back to anime, video, and subtitle line context', () => { test('getKanjiOccurrences maps a kanji back to anime, video, and subtitle line context', () => {
const dbPath = makeDbPath(); const dbPath = makeDbPath();
const db = new Database(dbPath); const db = new Database(dbPath);
@@ -7,6 +7,7 @@ import type {
KanjiOccurrenceRow, KanjiOccurrenceRow,
KanjiStatsRow, KanjiStatsRow,
KanjiWordRow, KanjiWordRow,
SentenceSearchResultRow,
SessionEventRow, SessionEventRow,
SimilarWordRow, SimilarWordRow,
StatsExcludedWordRow, StatsExcludedWordRow,
@@ -21,6 +22,19 @@ import { nowMs } from './time';
const VOCABULARY_STATS_FILTER_OVERSAMPLE_FACTOR = 4; const VOCABULARY_STATS_FILTER_OVERSAMPLE_FACTOR = 4;
const VOCABULARY_STATS_FILTER_OVERSAMPLE_MIN = 100; const VOCABULARY_STATS_FILTER_OVERSAMPLE_MIN = 100;
function splitSearchTerms(query: string): string[] {
return query
.trim()
.split(/\s+/)
.map((term) => term.trim())
.filter(Boolean)
.slice(0, 8);
}
function escapeLikeTerm(term: string): string {
return term.replace(/[\\%_]/g, (match) => `\\${match}`);
}
function toVocabularyToken(row: VocabularyStatsRow): MergedToken { function toVocabularyToken(row: VocabularyStatsRow): MergedToken {
const partOfSpeech = const partOfSpeech =
row.partOfSpeech && Object.values(PartOfSpeech).includes(row.partOfSpeech as PartOfSpeech) row.partOfSpeech && Object.values(PartOfSpeech).includes(row.partOfSpeech as PartOfSpeech)
@@ -211,6 +225,54 @@ export function getKanjiOccurrences(
.all(kanji, limit, offset) as unknown as KanjiOccurrenceRow[]; .all(kanji, limit, offset) as unknown as KanjiOccurrenceRow[];
} }
export function searchSubtitleSentences(
db: DatabaseSync,
query: string,
limit = 50,
): SentenceSearchResultRow[] {
const terms = splitSearchTerms(query);
if (terms.length === 0) return [];
const clauses: string[] = [];
const params: string[] = [];
for (const term of terms) {
const likeTerm = `%${escapeLikeTerm(term)}%`;
clauses.push(`
(
l.text LIKE ? ESCAPE '\\'
OR v.canonical_title LIKE ? ESCAPE '\\'
OR COALESCE(a.canonical_title, '') LIKE ? ESCAPE '\\'
)
`);
params.push(likeTerm, likeTerm, likeTerm);
}
return db
.prepare(
`
SELECT
l.anime_id AS animeId,
a.canonical_title AS animeTitle,
l.video_id AS videoId,
v.canonical_title AS videoTitle,
v.source_path AS sourcePath,
l.secondary_text AS secondaryText,
l.session_id AS sessionId,
l.line_index AS lineIndex,
l.segment_start_ms AS segmentStartMs,
l.segment_end_ms AS segmentEndMs,
l.text AS text
FROM imm_subtitle_lines l
JOIN imm_videos v ON v.video_id = l.video_id
LEFT JOIN imm_anime a ON a.anime_id = l.anime_id
WHERE ${clauses.join(' AND ')}
ORDER BY l.CREATED_DATE DESC, l.line_id DESC
LIMIT ?
`,
)
.all(...params, limit) as unknown as SentenceSearchResultRow[];
}
export function getSessionEvents( export function getSessionEvents(
db: DatabaseSync, db: DatabaseSync,
sessionId: number, sessionId: number,
@@ -367,6 +367,20 @@ export interface KanjiOccurrenceRow {
occurrenceCount: number; occurrenceCount: number;
} }
export interface SentenceSearchResultRow {
animeId: number | null;
animeTitle: string | null;
videoId: number;
videoTitle: string;
sourcePath: string | null;
secondaryText: string | null;
sessionId: number;
lineIndex: number;
segmentStartMs: number | null;
segmentEndMs: number | null;
text: string;
}
export interface SessionEventRow { export interface SessionEventRow {
eventType: number; eventType: number;
tsMs: number; tsMs: number;
+21
View File
@@ -235,6 +235,27 @@ test('dispatchMpvProtocolMessage prefers the already selected matching secondary
assert.deepEqual(state.commands, [{ command: ['set_property', 'secondary-sid', 3] }]); assert.deepEqual(state.commands, [{ command: ['set_property', 'secondary-sid', 3] }]);
}); });
test('dispatchMpvProtocolMessage skips signs and songs when choosing secondary subtitles', async () => {
const { deps, state } = createDeps({
getResolvedConfig: () => ({
secondarySub: { secondarySubLanguages: ['eng', 'en'] },
}),
});
await dispatchMpvProtocolMessage(
{
request_id: MPV_REQUEST_ID_TRACK_LIST_SECONDARY,
data: [
{ type: 'sub', id: 2, lang: 'eng', title: 'English Signs & Songs' },
{ type: 'sub', id: 3, lang: 'eng', title: 'English Dialogue' },
],
},
deps,
);
assert.deepEqual(state.commands, [{ command: ['set_property', 'secondary-sid', 3] }]);
});
test('dispatchMpvProtocolMessage restores secondary visibility on shutdown', async () => { test('dispatchMpvProtocolMessage restores secondary visibility on shutdown', async () => {
const { deps, state } = createDeps(); const { deps, state } = createDeps();
+14 -2
View File
@@ -149,6 +149,11 @@ function getSubtitleTrackIdentity(track: SubtitleTrackCandidate): string {
return `id:${track.id}`; return `id:${track.id}`;
} }
function isSignsOrSongsSubtitleTrack(track: SubtitleTrackCandidate): boolean {
const label = `${track.title} ${track.externalFilename ?? ''}`.toLowerCase();
return /\b(signs?|songs?)\b/.test(label);
}
function pickSecondarySubtitleTrackId( function pickSecondarySubtitleTrackId(
tracks: Array<Record<string, unknown>>, tracks: Array<Record<string, unknown>>,
preferredLanguages: string[], preferredLanguages: string[],
@@ -177,12 +182,19 @@ function pickSecondarySubtitleTrackId(
const uniqueTracks = [...dedupedTracks.values()]; const uniqueTracks = [...dedupedTracks.values()];
for (const language of normalizedLanguages) { for (const language of normalizedLanguages) {
const selectedMatch = uniqueTracks.find((track) => track.selected && track.lang === language); const languageTracks = uniqueTracks.filter((track) => track.lang === language);
if (languageTracks.length === 0) {
continue;
}
const cleanTracks = languageTracks.filter((track) => !isSignsOrSongsSubtitleTrack(track));
const candidateTracks = cleanTracks.length > 0 ? cleanTracks : languageTracks;
const selectedMatch = candidateTracks.find((track) => track.selected);
if (selectedMatch) { if (selectedMatch) {
return selectedMatch.id; return selectedMatch.id;
} }
const match = uniqueTracks.find((track) => track.lang === language); const match = candidateTracks[0];
if (match) { if (match) {
return match.id; return match.id;
} }
+212 -37
View File
@@ -7,6 +7,7 @@ import { Readable } from 'node:stream';
import { MediaGenerator } from '../../media-generator.js'; import { MediaGenerator } from '../../media-generator.js';
import { AnkiConnectClient } from '../../anki-connect.js'; import { AnkiConnectClient } from '../../anki-connect.js';
import type { AnkiConnectConfig } from '../../types.js'; import type { AnkiConnectConfig } from '../../types.js';
import { createLogger } from '../../logger.js';
import { import {
getConfiguredSentenceFieldName, getConfiguredSentenceFieldName,
getConfiguredTranslationFieldName, getConfiguredTranslationFieldName,
@@ -21,6 +22,23 @@ type StatsServerNoteInfo = {
fields: Record<string, { value: string }>; fields: Record<string, { value: string }>;
}; };
type StatsServerMediaGenerator = {
generateAudio: (...args: Parameters<MediaGenerator['generateAudio']>) => Promise<Buffer | null>;
generateScreenshot: (
...args: Parameters<MediaGenerator['generateScreenshot']>
) => Promise<Buffer | null>;
generateAnimatedImage: (
...args: Parameters<MediaGenerator['generateAnimatedImage']>
) => Promise<Buffer | null>;
};
export type StatsMiningTimingEvent = {
mode: 'word' | 'sentence' | 'audio';
phase: string;
elapsedMs: number;
noteId?: number;
};
type StatsExcludedWordPayload = { type StatsExcludedWordPayload = {
headword: string; headword: string;
word: string; word: string;
@@ -122,6 +140,52 @@ function resolveStatsNoteFieldName(
return null; return null;
} }
function uniqueFieldNames(...fieldNames: (string | null | undefined)[]): string[] {
const seen = new Set<string>();
const result: string[] = [];
for (const fieldName of fieldNames) {
const normalized = fieldName?.trim();
if (!normalized) continue;
const key = normalized.toLowerCase();
if (seen.has(key)) continue;
seen.add(key);
result.push(normalized);
}
return result;
}
function getStatsWordMiningAudioFieldName(
ankiConfig: AnkiConnectConfig,
noteInfo: StatsServerNoteInfo | null,
): string {
return (
(noteInfo
? resolveStatsNoteFieldName(noteInfo, 'SentenceAudio', ankiConfig.fields?.audio)
: null) ??
ankiConfig.fields?.audio ??
'ExpressionAudio'
);
}
function getStatsDirectMiningAudioFieldNames(
ankiConfig: AnkiConnectConfig,
noteInfo: StatsServerNoteInfo | null,
): string[] {
const configuredAudioField = ankiConfig.fields?.audio ?? 'ExpressionAudio';
if (!ankiConfig.isLapis?.enabled && !ankiConfig.isKiku?.enabled) {
return [configuredAudioField];
}
const sentenceAudioField = noteInfo
? resolveStatsNoteFieldName(noteInfo, 'SentenceAudio', configuredAudioField)
: 'SentenceAudio';
const expressionAudioField = noteInfo
? resolveStatsNoteFieldName(noteInfo, configuredAudioField)
: null;
return uniqueFieldNames(sentenceAudioField, expressionAudioField);
}
function toFetchHeaders(headers: IncomingMessage['headers']): Headers { function toFetchHeaders(headers: IncomingMessage['headers']): Headers {
const fetchHeaders = new Headers(); const fetchHeaders = new Headers();
for (const [name, value] of Object.entries(headers)) { for (const [name, value] of Object.entries(headers)) {
@@ -291,6 +355,7 @@ export interface StatsServerConfig {
knownWordCachePath?: string; knownWordCachePath?: string;
mpvSocketPath?: string; mpvSocketPath?: string;
ankiConnectConfig?: AnkiConnectConfig; ankiConnectConfig?: AnkiConnectConfig;
getAnkiConnectConfig?: () => AnkiConnectConfig | undefined;
anilistRateLimiter?: AnilistRateLimiter; anilistRateLimiter?: AnilistRateLimiter;
addYomitanNote?: (word: string) => Promise<number | null>; addYomitanNote?: (word: string) => Promise<number | null>;
resolveAnkiNoteId?: (noteId: number) => number; resolveAnkiNoteId?: (noteId: number) => number;
@@ -314,6 +379,11 @@ const STATS_STATIC_CONTENT_TYPES: Record<string, string> = {
'.woff2': 'font/woff2', '.woff2': 'font/woff2',
}; };
const ANKI_CONNECT_FETCH_TIMEOUT_MS = 3_000; const ANKI_CONNECT_FETCH_TIMEOUT_MS = 3_000;
const statsMiningLogger = createLogger('stats:mining');
function defaultNowMs(): number {
return Date.now();
}
function buildAnkiNotePreview( function buildAnkiNotePreview(
fields: Record<string, { value: string }>, fields: Record<string, { value: string }>,
@@ -375,12 +445,53 @@ export function createStatsApp(
knownWordCachePath?: string; knownWordCachePath?: string;
mpvSocketPath?: string; mpvSocketPath?: string;
ankiConnectConfig?: AnkiConnectConfig; ankiConnectConfig?: AnkiConnectConfig;
getAnkiConnectConfig?: () => AnkiConnectConfig | undefined;
anilistRateLimiter?: AnilistRateLimiter; anilistRateLimiter?: AnilistRateLimiter;
addYomitanNote?: (word: string) => Promise<number | null>; addYomitanNote?: (word: string) => Promise<number | null>;
resolveAnkiNoteId?: (noteId: number) => number; resolveAnkiNoteId?: (noteId: number) => number;
createMediaGenerator?: () => StatsServerMediaGenerator;
onMiningTiming?: (event: StatsMiningTimingEvent) => void;
nowMs?: () => number;
}, },
) { ) {
const app = new Hono(); const app = new Hono();
const nowMs = options?.nowMs ?? defaultNowMs;
const getAnkiConnectConfig = (): AnkiConnectConfig | undefined =>
options?.getAnkiConnectConfig?.() ?? options?.ankiConnectConfig;
const recordMiningTiming = (event: StatsMiningTimingEvent): void => {
options?.onMiningTiming?.(event);
statsMiningLogger.debug(
`[stats:mining] ${event.mode} ${event.phase} ${Math.round(event.elapsedMs)}ms`,
event,
);
};
const timeMiningPhase = async <T>(
mode: StatsMiningTimingEvent['mode'],
phase: string,
fn: () => Promise<T>,
details?: (value: T) => Partial<StatsMiningTimingEvent>,
): Promise<T> => {
const startedAtMs = nowMs();
try {
const value = await fn();
recordMiningTiming({
mode,
phase,
elapsedMs: nowMs() - startedAtMs,
...details?.(value),
});
return value;
} catch (err) {
recordMiningTiming({
mode,
phase,
elapsedMs: nowMs() - startedAtMs,
});
throw err;
}
};
app.get('/api/stats/overview', async (c) => { app.get('/api/stats/overview', async (c) => {
const [rawSessions, rollups, hints] = await Promise.all([ const [rawSessions, rollups, hints] = await Promise.all([
@@ -544,6 +655,14 @@ export function createStatsApp(
return c.json(occurrences); return c.json(occurrences);
}); });
app.get('/api/stats/sentences/search', async (c) => {
const query = (c.req.query('q') ?? '').trim();
if (!query) return c.json([]);
const limit = parseIntQuery(c.req.query('limit'), 50, 100);
const rows = await tracker.searchSubtitleSentences(query, limit);
return c.json(rows);
});
app.get('/api/stats/kanji', async (c) => { app.get('/api/stats/kanji', async (c) => {
const limit = parseIntQuery(c.req.query('limit'), 100, 500); const limit = parseIntQuery(c.req.query('limit'), 100, 500);
const kanji = await tracker.getKanjiStats(limit); const kanji = await tracker.getKanjiStats(limit);
@@ -863,7 +982,7 @@ export function createStatsApp(
return c.json( return c.json(
(result.result ?? []).map((note) => ({ (result.result ?? []).map((note) => ({
...note, ...note,
preview: buildAnkiNotePreview(note.fields, options?.ankiConnectConfig), preview: buildAnkiNotePreview(note.fields, getAnkiConnectConfig()),
})), })),
); );
} catch { } catch {
@@ -891,13 +1010,13 @@ export function createStatsApp(
return c.json({ error: 'File not found' }, 404); return c.json({ error: 'File not found' }, 404);
} }
const ankiConfig = options?.ankiConnectConfig; const ankiConfig = getAnkiConnectConfig();
if (!ankiConfig) { if (!ankiConfig) {
return c.json({ error: 'AnkiConnect is not configured' }, 500); return c.json({ error: 'AnkiConnect is not configured' }, 500);
} }
const client = new AnkiConnectClient(ankiConfig.url ?? 'http://127.0.0.1:8765'); const client = new AnkiConnectClient(ankiConfig.url ?? 'http://127.0.0.1:8765');
const mediaGen = new MediaGenerator(); const mediaGen = options?.createMediaGenerator?.() ?? new MediaGenerator();
const audioPadding = ankiConfig.media?.audioPadding ?? 0; const audioPadding = ankiConfig.media?.audioPadding ?? 0;
const maxMediaDuration = ankiConfig.media?.maxMediaDuration ?? 30; const maxMediaDuration = ankiConfig.media?.maxMediaDuration ?? 30;
@@ -921,7 +1040,9 @@ export function createStatsApp(
imageType === 'avif' && ankiConfig.media?.syncAnimatedImageToWordAudio !== false; imageType === 'avif' && ankiConfig.media?.syncAnimatedImageToWordAudio !== false;
const audioPromise = generateAudio const audioPromise = generateAudio
? mediaGen.generateAudio(sourcePath, startSec, clampedEndSec, audioPadding) ? timeMiningPhase(mode, 'generateAudio', () =>
mediaGen.generateAudio(sourcePath, startSec, clampedEndSec, audioPadding),
)
: Promise.resolve(null); : Promise.resolve(null);
const createImagePromise = (animatedLeadInSeconds = 0): Promise<Buffer | null> => { const createImagePromise = (animatedLeadInSeconds = 0): Promise<Buffer | null> => {
@@ -930,22 +1051,26 @@ export function createStatsApp(
} }
if (imageType === 'avif') { if (imageType === 'avif') {
return mediaGen.generateAnimatedImage(sourcePath, startSec, clampedEndSec, audioPadding, { return timeMiningPhase(mode, 'generateAnimatedImage', () =>
mediaGen.generateAnimatedImage(sourcePath, startSec, clampedEndSec, audioPadding, {
fps: ankiConfig.media?.animatedFps ?? 10, fps: ankiConfig.media?.animatedFps ?? 10,
maxWidth: ankiConfig.media?.animatedMaxWidth ?? 640, maxWidth: ankiConfig.media?.animatedMaxWidth ?? 640,
maxHeight: ankiConfig.media?.animatedMaxHeight, maxHeight: ankiConfig.media?.animatedMaxHeight,
crf: ankiConfig.media?.animatedCrf ?? 35, crf: ankiConfig.media?.animatedCrf ?? 35,
leadingStillDuration: animatedLeadInSeconds, leadingStillDuration: animatedLeadInSeconds,
}); }),
);
} }
const midpointSec = (startSec + clampedEndSec) / 2; const midpointSec = (startSec + clampedEndSec) / 2;
return mediaGen.generateScreenshot(sourcePath, midpointSec, { return timeMiningPhase(mode, 'generateScreenshot', () =>
mediaGen.generateScreenshot(sourcePath, midpointSec, {
format: ankiConfig.media?.imageFormat ?? 'jpg', format: ankiConfig.media?.imageFormat ?? 'jpg',
quality: ankiConfig.media?.imageQuality ?? 92, quality: ankiConfig.media?.imageQuality ?? 92,
maxWidth: ankiConfig.media?.imageMaxWidth, maxWidth: ankiConfig.media?.imageMaxWidth,
maxHeight: ankiConfig.media?.imageMaxHeight, maxHeight: ankiConfig.media?.imageMaxHeight,
}); }),
);
}; };
const imagePromise = const imagePromise =
@@ -962,7 +1087,12 @@ export function createStatsApp(
} }
const [yomitanResult, audioResult, imageResult] = await Promise.allSettled([ const [yomitanResult, audioResult, imageResult] = await Promise.allSettled([
options.addYomitanNote(word), timeMiningPhase(
'word',
'addYomitanNote',
() => options.addYomitanNote!(word),
(noteId) => (typeof noteId === 'number' ? { noteId } : {}),
),
audioPromise, audioPromise,
imagePromise, imagePromise,
]); ]);
@@ -984,10 +1114,19 @@ export function createStatsApp(
errors.push(`image: ${(imageResult.reason as Error).message}`); errors.push(`image: ${(imageResult.reason as Error).message}`);
let imageBuffer = imageResult.status === 'fulfilled' ? imageResult.value : null; let imageBuffer = imageResult.status === 'fulfilled' ? imageResult.value : null;
if (syncAnimatedImageToWordAudio && generateImage) { let noteInfo: StatsServerNoteInfo | null = null;
if (audioBuffer || (syncAnimatedImageToWordAudio && generateImage)) {
try { try {
const noteInfoResult = (await client.notesInfo([noteId])) as StatsServerNoteInfo[]; const noteInfoResult = (await client.notesInfo([noteId])) as StatsServerNoteInfo[];
const noteInfo = noteInfoResult[0] ?? null; noteInfo = noteInfoResult[0] ?? null;
} catch (err) {
if (syncAnimatedImageToWordAudio && generateImage) {
errors.push(`image: ${(err as Error).message}`);
}
}
}
if (syncAnimatedImageToWordAudio && generateImage) {
try {
const animatedLeadInSeconds = noteInfo const animatedLeadInSeconds = noteInfo
? await resolveAnimatedImageLeadInSeconds({ ? await resolveAnimatedImageLeadInSeconds({
config: ankiConfig, config: ankiConfig,
@@ -1006,18 +1145,17 @@ export function createStatsApp(
const mediaFields: Record<string, string> = {}; const mediaFields: Record<string, string> = {};
const timestamp = Date.now(); const timestamp = Date.now();
const sentenceFieldName = ankiConfig.fields?.sentence ?? 'Sentence'; const sentenceFieldName = ankiConfig.fields?.sentence ?? 'Sentence';
const audioFieldName = ankiConfig.fields?.audio ?? 'ExpressionAudio'; const audioFieldName = getStatsWordMiningAudioFieldName(ankiConfig, noteInfo);
const imageFieldName = ankiConfig.fields?.image ?? 'Picture'; const imageFieldName = ankiConfig.fields?.image ?? 'Picture';
mediaFields[sentenceFieldName] = highlightedSentence; mediaFields[sentenceFieldName] = highlightedSentence;
if (secondaryText) {
mediaFields[ankiConfig.fields?.translation ?? 'SelectionText'] = secondaryText;
}
if (audioBuffer) { if (audioBuffer) {
const audioFilename = `subminer_audio_${timestamp}.mp3`; const audioFilename = `subminer_audio_${timestamp}.mp3`;
try { try {
await client.storeMediaFile(audioFilename, audioBuffer); await timeMiningPhase('word', 'uploadAudio', () =>
client.storeMediaFile(audioFilename, audioBuffer),
);
mediaFields[audioFieldName] = `[sound:${audioFilename}]`; mediaFields[audioFieldName] = `[sound:${audioFilename}]`;
} catch (err) { } catch (err) {
errors.push(`audio upload: ${(err as Error).message}`); errors.push(`audio upload: ${(err as Error).message}`);
@@ -1028,7 +1166,9 @@ export function createStatsApp(
const imageExt = imageType === 'avif' ? 'avif' : (ankiConfig.media?.imageFormat ?? 'jpg'); const imageExt = imageType === 'avif' ? 'avif' : (ankiConfig.media?.imageFormat ?? 'jpg');
const imageFilename = `subminer_image_${timestamp}.${imageExt}`; const imageFilename = `subminer_image_${timestamp}.${imageExt}`;
try { try {
await client.storeMediaFile(imageFilename, imageBuffer); await timeMiningPhase('word', 'uploadImage', () =>
client.storeMediaFile(imageFilename, imageBuffer),
);
mediaFields[imageFieldName] = `<img src="${imageFilename}">`; mediaFields[imageFieldName] = `<img src="${imageFilename}">`;
} catch (err) { } catch (err) {
errors.push(`image upload: ${(err as Error).message}`); errors.push(`image upload: ${(err as Error).message}`);
@@ -1056,7 +1196,9 @@ export function createStatsApp(
if (Object.keys(mediaFields).length > 0) { if (Object.keys(mediaFields).length > 0) {
try { try {
await client.updateNoteFields(noteId, mediaFields); await timeMiningPhase('word', 'updateNoteFields', () =>
client.updateNoteFields(noteId, mediaFields),
);
} catch (err) { } catch (err) {
errors.push(`update fields: ${(err as Error).message}`); errors.push(`update fields: ${(err as Error).message}`);
} }
@@ -1065,19 +1207,9 @@ export function createStatsApp(
return c.json({ noteId, ...(errors.length > 0 ? { errors } : {}) }); return c.json({ noteId, ...(errors.length > 0 ? { errors } : {}) });
} }
const [audioResult, imageResult] = await Promise.allSettled([audioPromise, imagePromise]);
const audioBuffer = audioResult.status === 'fulfilled' ? audioResult.value : null;
const imageBuffer = imageResult.status === 'fulfilled' ? imageResult.value : null;
if (audioResult.status === 'rejected')
errors.push(`audio: ${(audioResult.reason as Error).message}`);
if (imageResult.status === 'rejected')
errors.push(`image: ${(imageResult.reason as Error).message}`);
const wordFieldName = getConfiguredWordFieldName(ankiConfig); const wordFieldName = getConfiguredWordFieldName(ankiConfig);
const sentenceFieldName = ankiConfig.fields?.sentence ?? 'Sentence'; const sentenceFieldName = ankiConfig.fields?.sentence ?? 'Sentence';
const translationFieldName = ankiConfig.fields?.translation ?? 'SelectionText'; const translationFieldName = ankiConfig.fields?.translation ?? 'SelectionText';
const audioFieldName = ankiConfig.fields?.audio ?? 'ExpressionAudio';
const imageFieldName = ankiConfig.fields?.image ?? 'Picture'; const imageFieldName = ankiConfig.fields?.image ?? 'Picture';
const miscInfoFieldName = ankiConfig.fields?.miscInfo ?? ''; const miscInfoFieldName = ankiConfig.fields?.miscInfo ?? '';
@@ -1085,7 +1217,7 @@ export function createStatsApp(
[sentenceFieldName]: highlightedSentence, [sentenceFieldName]: highlightedSentence,
}; };
if (secondaryText) { if (mode === 'sentence' && secondaryText) {
fields[translationFieldName] = secondaryText; fields[translationFieldName] = secondaryText;
} }
@@ -1104,20 +1236,58 @@ export function createStatsApp(
const deck = ankiConfig.deck?.trim() || 'Default'; const deck = ankiConfig.deck?.trim() || 'Default';
const tags = ankiConfig.tags ?? ['SubMiner']; const tags = ankiConfig.tags ?? ['SubMiner'];
try { const addNotePromise = timeMiningPhase(
noteId = await client.addNote(deck, model, fields, tags); mode,
} catch (err) { 'addNote',
return c.json({ error: `Failed to add note: ${(err as Error).message}` }, 502); () => client.addNote(deck, model, fields, tags),
(id) => ({
noteId: id,
}),
);
const [audioResult, imageResult, addNoteResult] = await Promise.allSettled([
audioPromise,
imagePromise,
addNotePromise,
]);
const audioBuffer = audioResult.status === 'fulfilled' ? audioResult.value : null;
const imageBuffer = imageResult.status === 'fulfilled' ? imageResult.value : null;
if (audioResult.status === 'rejected')
errors.push(`audio: ${(audioResult.reason as Error).message}`);
if (imageResult.status === 'rejected')
errors.push(`image: ${(imageResult.reason as Error).message}`);
if (addNoteResult.status === 'rejected') {
return c.json(
{ error: `Failed to add note: ${(addNoteResult.reason as Error).message}` },
502,
);
} }
noteId = addNoteResult.value;
const mediaFields: Record<string, string> = {}; const mediaFields: Record<string, string> = {};
const timestamp = Date.now(); const timestamp = Date.now();
let noteInfo: StatsServerNoteInfo | null = null;
if (audioBuffer) {
try {
const noteInfoResult = (await client.notesInfo([noteId])) as StatsServerNoteInfo[];
noteInfo = noteInfoResult[0] ?? null;
} catch {
noteInfo = null;
}
}
if (audioBuffer) { if (audioBuffer) {
const audioFilename = `subminer_audio_${timestamp}.mp3`; const audioFilename = `subminer_audio_${timestamp}.mp3`;
try { try {
await client.storeMediaFile(audioFilename, audioBuffer); await timeMiningPhase(mode, 'uploadAudio', () =>
mediaFields[audioFieldName] = `[sound:${audioFilename}]`; client.storeMediaFile(audioFilename, audioBuffer),
);
const audioValue = `[sound:${audioFilename}]`;
for (const fieldName of getStatsDirectMiningAudioFieldNames(ankiConfig, noteInfo)) {
mediaFields[fieldName] = audioValue;
}
} catch (err) { } catch (err) {
errors.push(`audio upload: ${(err as Error).message}`); errors.push(`audio upload: ${(err as Error).message}`);
} }
@@ -1127,7 +1297,9 @@ export function createStatsApp(
const imageExt = imageType === 'avif' ? 'avif' : (ankiConfig.media?.imageFormat ?? 'jpg'); const imageExt = imageType === 'avif' ? 'avif' : (ankiConfig.media?.imageFormat ?? 'jpg');
const imageFilename = `subminer_image_${timestamp}.${imageExt}`; const imageFilename = `subminer_image_${timestamp}.${imageExt}`;
try { try {
await client.storeMediaFile(imageFilename, imageBuffer); await timeMiningPhase(mode, 'uploadImage', () =>
client.storeMediaFile(imageFilename, imageBuffer),
);
mediaFields[imageFieldName] = `<img src="${imageFilename}">`; mediaFields[imageFieldName] = `<img src="${imageFilename}">`;
} catch (err) { } catch (err) {
errors.push(`image upload: ${(err as Error).message}`); errors.push(`image upload: ${(err as Error).message}`);
@@ -1155,7 +1327,9 @@ export function createStatsApp(
if (Object.keys(mediaFields).length > 0) { if (Object.keys(mediaFields).length > 0) {
try { try {
await client.updateNoteFields(noteId, mediaFields); await timeMiningPhase(mode, 'updateNoteFields', () =>
client.updateNoteFields(noteId, mediaFields),
);
} catch (err) { } catch (err) {
errors.push(`update fields: ${(err as Error).message}`); errors.push(`update fields: ${(err as Error).message}`);
} }
@@ -1195,6 +1369,7 @@ export function startStatsServer(config: StatsServerConfig): { close: () => void
knownWordCachePath: config.knownWordCachePath, knownWordCachePath: config.knownWordCachePath,
mpvSocketPath: config.mpvSocketPath, mpvSocketPath: config.mpvSocketPath,
ankiConnectConfig: config.ankiConnectConfig, ankiConnectConfig: config.ankiConnectConfig,
getAnkiConnectConfig: config.getAnkiConnectConfig,
anilistRateLimiter: config.anilistRateLimiter, anilistRateLimiter: config.anilistRateLimiter,
addYomitanNote: config.addYomitanNote, addYomitanNote: config.addYomitanNote,
resolveAnkiNoteId: config.resolveAnkiNoteId, resolveAnkiNoteId: config.resolveAnkiNoteId,
+1 -1
View File
@@ -4445,7 +4445,7 @@ const startLocalStatsServer = (): void => {
tracker, tracker,
knownWordCachePath: path.join(USER_DATA_PATH, 'known-words-cache.json'), knownWordCachePath: path.join(USER_DATA_PATH, 'known-words-cache.json'),
mpvSocketPath: appState.mpvSocketPath, mpvSocketPath: appState.mpvSocketPath,
ankiConnectConfig: getResolvedConfig().ankiConnect, getAnkiConnectConfig: () => getResolvedConfig().ankiConnect,
anilistRateLimiter, anilistRateLimiter,
resolveAnkiNoteId: (noteId: number) => resolveAnkiNoteId: (noteId: number) =>
appState.ankiIntegration?.resolveCurrentNoteId(noteId) ?? noteId, appState.ankiIntegration?.resolveCurrentNoteId(noteId) ?? noteId,
+1 -1
View File
@@ -195,7 +195,7 @@ async function main(): Promise<void> {
staticDir: statsDistPath, staticDir: statsDistPath,
tracker, tracker,
knownWordCachePath, knownWordCachePath,
ankiConnectConfig: config.ankiConnect, getAnkiConnectConfig: () => configService.reloadConfig().ankiConnect,
addYomitanNote: async (word: string) => addYomitanNote: async (word: string) =>
await invokeStatsWordHelper({ await invokeStatsWordHelper({
helperScriptPath: wordHelperScriptPath, helperScriptPath: wordHelperScriptPath,
+18
View File
@@ -30,6 +30,11 @@ const VocabularyTab = lazy(() =>
default: module.VocabularyTab, default: module.VocabularyTab,
})), })),
); );
const SearchTab = lazy(() =>
import('./components/search/SearchTab').then((module) => ({
default: module.SearchTab,
})),
);
const SessionsTab = lazy(() => const SessionsTab = lazy(() =>
import('./components/sessions/SessionsTab').then((module) => ({ import('./components/sessions/SessionsTab').then((module) => ({
default: module.SessionsTab, default: module.SessionsTab,
@@ -239,6 +244,19 @@ export function App() {
</Suspense> </Suspense>
</section> </section>
) : null} ) : null}
{mountedTabs.has('search') ? (
<section
id="panel-search"
role="tabpanel"
aria-labelledby="tab-search"
hidden={activeTab !== 'search'}
className="animate-fade-in"
>
<Suspense fallback={<LoadingSurface label="Loading search..." />}>
<SearchTab />
</Suspense>
</section>
) : null}
{mountedTabs.has('sessions') ? ( {mountedTabs.has('sessions') ? (
<section <section
id="panel-sessions" id="panel-sessions"
+21 -4
View File
@@ -1,13 +1,18 @@
import { useState, useMemo, useEffect } from 'react'; import { useState, useMemo, useEffect } from 'react';
import { useAnimeLibrary } from '../../hooks/useAnimeLibrary'; import { useAnimeLibrary } from '../../hooks/useAnimeLibrary';
import { formatDuration } from '../../lib/formatters'; import { formatDuration } from '../../lib/formatters';
import {
getLibraryCardSizeStorage,
readLibraryCardSizePreference,
type LibraryCardSize,
writeLibraryCardSizePreference,
} from '../../lib/library-card-size';
import { AnimeCard } from './AnimeCard'; import { AnimeCard } from './AnimeCard';
import { AnimeDetailView } from './AnimeDetailView'; import { AnimeDetailView } from './AnimeDetailView';
type SortKey = 'lastWatched' | 'watchTime' | 'cards' | 'episodes'; type SortKey = 'lastWatched' | 'watchTime' | 'cards' | 'episodes';
type CardSize = 'sm' | 'md' | 'lg';
const GRID_CLASSES: Record<CardSize, string> = { const GRID_CLASSES: Record<LibraryCardSize, string> = {
sm: 'grid-cols-5 sm:grid-cols-7 md:grid-cols-9 lg:grid-cols-11', sm: 'grid-cols-5 sm:grid-cols-7 md:grid-cols-9 lg:grid-cols-11',
md: 'grid-cols-4 sm:grid-cols-5 md:grid-cols-7 lg:grid-cols-9', md: 'grid-cols-4 sm:grid-cols-5 md:grid-cols-7 lg:grid-cols-9',
lg: 'grid-cols-3 sm:grid-cols-4 md:grid-cols-5 lg:grid-cols-7', lg: 'grid-cols-3 sm:grid-cols-4 md:grid-cols-5 lg:grid-cols-7',
@@ -51,9 +56,21 @@ export function AnimeTab({
const { anime, loading, error } = useAnimeLibrary(); const { anime, loading, error } = useAnimeLibrary();
const [search, setSearch] = useState(''); const [search, setSearch] = useState('');
const [sortKey, setSortKey] = useState<SortKey>('lastWatched'); const [sortKey, setSortKey] = useState<SortKey>('lastWatched');
const [cardSize, setCardSize] = useState<CardSize>('md'); const [cardSize, setCardSize] = useState<LibraryCardSize>(() =>
readLibraryCardSizePreference(
getLibraryCardSizeStorage(typeof window === 'undefined' ? null : window),
),
);
const [selectedAnimeId, setSelectedAnimeId] = useState<number | null>(null); const [selectedAnimeId, setSelectedAnimeId] = useState<number | null>(null);
function handleCardSizeChange(size: LibraryCardSize): void {
setCardSize(size);
writeLibraryCardSizePreference(
getLibraryCardSizeStorage(typeof window === 'undefined' ? null : window),
size,
);
}
useEffect(() => { useEffect(() => {
if (initialAnimeId != null) { if (initialAnimeId != null) {
setSelectedAnimeId(initialAnimeId); setSelectedAnimeId(initialAnimeId);
@@ -113,7 +130,7 @@ export function AnimeTab({
{(['sm', 'md', 'lg'] as const).map((size) => ( {(['sm', 'md', 'lg'] as const).map((size) => (
<button <button
key={size} key={size}
onClick={() => setCardSize(size)} onClick={() => handleCardSizeChange(size)}
className={`px-2 py-1 rounded-md text-xs transition-colors ${ className={`px-2 py-1 rounded-md text-xs transition-colors ${
cardSize === size cardSize === size
? 'bg-ctp-surface2 text-ctp-text shadow-sm' ? 'bg-ctp-surface2 text-ctp-text shadow-sm'
+2 -1
View File
@@ -1,6 +1,6 @@
import { useRef, type KeyboardEvent } from 'react'; import { useRef, type KeyboardEvent } from 'react';
export type TabId = 'overview' | 'anime' | 'trends' | 'vocabulary' | 'sessions'; export type TabId = 'overview' | 'anime' | 'trends' | 'vocabulary' | 'search' | 'sessions';
interface Tab { interface Tab {
id: TabId; id: TabId;
@@ -12,6 +12,7 @@ const TABS: Tab[] = [
{ id: 'anime', label: 'Library' }, { id: 'anime', label: 'Library' },
{ id: 'trends', label: 'Trends' }, { id: 'trends', label: 'Trends' },
{ id: 'vocabulary', label: 'Vocabulary' }, { id: 'vocabulary', label: 'Vocabulary' },
{ id: 'search', label: 'Search' },
{ id: 'sessions', label: 'Sessions' }, { id: 'sessions', label: 'Sessions' },
]; ];
@@ -0,0 +1,13 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';
import path from 'node:path';
import test from 'node:test';
import { fileURLToPath } from 'node:url';
const SEARCH_TAB_PATH = path.resolve(path.dirname(fileURLToPath(import.meta.url)), 'SearchTab.tsx');
test('SearchTab forwards stored secondary subtitle text when mining from search results', () => {
const source = fs.readFileSync(SEARCH_TAB_PATH, 'utf8');
assert.match(source, /secondaryText:\s*result\.secondaryText/);
});
+268
View File
@@ -0,0 +1,268 @@
import { useEffect, useRef, useState } from 'react';
import { apiClient } from '../../lib/api-client';
import {
getSentenceSearchMineAvailability,
renderSentenceWithMatches,
} from '../../lib/sentence-search';
import type { SentenceSearchResult } from '../../types/stats';
const SEARCH_LIMIT = 50;
const SEARCH_DEBOUNCE_MS = 160;
type MineMode = 'word' | 'sentence' | 'audio';
type MineStatus = { loading?: boolean; success?: boolean; error?: string };
function formatSegment(ms: number | null): string {
if (ms == null || !Number.isFinite(ms)) return '--:--';
const totalSeconds = Math.max(0, Math.floor(ms / 1000));
const minutes = Math.floor(totalSeconds / 60);
const seconds = totalSeconds % 60;
return `${minutes}:${String(seconds).padStart(2, '0')}`;
}
function resultKey(result: SentenceSearchResult, index: number): string {
return `${result.sessionId}-${result.lineIndex}-${result.segmentStartMs ?? index}`;
}
function statusKey(result: SentenceSearchResult, index: number, mode: MineMode): string {
return `${resultKey(result, index)}-${mode}`;
}
function buttonLabel(
mode: MineMode,
status: MineStatus | undefined,
disabledLabel: string,
): string {
if (status?.loading) return 'Mining...';
if (status?.success) return 'Mined!';
if (disabledLabel) return disabledLabel;
if (mode === 'word') return 'Word';
if (mode === 'audio') return 'Audio';
return 'Sentence';
}
export function SearchTab() {
const [query, setQuery] = useState('');
const [results, setResults] = useState<SentenceSearchResult[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [mineStatus, setMineStatus] = useState<Record<string, MineStatus>>({});
const requestRef = useRef(0);
useEffect(() => {
const trimmed = query.trim();
const requestId = ++requestRef.current;
setMineStatus({});
if (!trimmed) {
setResults([]);
setLoading(false);
setError(null);
return;
}
setLoading(true);
setError(null);
const timer = window.setTimeout(() => {
apiClient
.searchSentences(trimmed, SEARCH_LIMIT)
.then((nextResults) => {
if (requestId !== requestRef.current) return;
setResults(nextResults);
})
.catch((err: Error) => {
if (requestId !== requestRef.current) return;
setError(err.message);
setResults([]);
})
.finally(() => {
if (requestId !== requestRef.current) return;
setLoading(false);
});
}, SEARCH_DEBOUNCE_MS);
return () => {
window.clearTimeout(timer);
};
}, [query]);
const handleMine = async (
result: SentenceSearchResult,
index: number,
mode: MineMode,
): Promise<void> => {
const availability = getSentenceSearchMineAvailability(result, query);
if (mode === 'sentence' ? !availability.canMineSentence : !availability.canMineWordAudio) {
return;
}
if (!result.sourcePath || result.segmentStartMs == null || result.segmentEndMs == null) {
return;
}
const key = statusKey(result, index, mode);
setMineStatus((prev) => ({ ...prev, [key]: { loading: true } }));
try {
const searchedWord = availability.exactMatch ? query.trim() : '';
const response = await apiClient.mineCard({
sourcePath: result.sourcePath,
startMs: result.segmentStartMs,
endMs: result.segmentEndMs,
sentence: result.text,
word: searchedWord,
secondaryText: result.secondaryText,
videoTitle: result.videoTitle,
mode,
});
if (response.error) {
setMineStatus((prev) => ({ ...prev, [key]: { error: response.error } }));
return;
}
setMineStatus((prev) => ({ ...prev, [key]: { success: true } }));
} catch (err) {
setMineStatus((prev) => ({
...prev,
[key]: { error: err instanceof Error ? err.message : String(err) },
}));
}
};
const trimmedQuery = query.trim();
return (
<div className="space-y-4">
<section className="rounded-lg border border-ctp-surface1 bg-ctp-mantle/70 p-4">
<div className="flex flex-col gap-3 md:flex-row md:items-end md:justify-between">
<label className="min-w-0 flex-1">
<span className="mb-2 block text-xs font-semibold uppercase tracking-[0.18em] text-ctp-overlay1">
Sentence Search
</span>
<input
type="search"
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder="Search sentence text or media..."
className="w-full rounded-lg border border-ctp-surface1 bg-ctp-surface0 px-4 py-3 text-base text-ctp-text placeholder:text-ctp-overlay2 focus:border-ctp-yellow focus:outline-none"
autoComplete="off"
/>
</label>
<div className="rounded-lg border border-ctp-surface1 bg-ctp-surface0 px-4 py-2 text-right">
<div className="text-xl font-semibold text-ctp-yellow">
{loading ? '...' : results.length}
</div>
<div className="text-[11px] uppercase tracking-wide text-ctp-overlay1">Matches</div>
</div>
</div>
</section>
{error && (
<div className="rounded-lg border border-ctp-red/40 bg-ctp-red/10 p-3 text-sm text-ctp-red">
Error: {error}
</div>
)}
{!trimmedQuery && (
<div className="rounded-lg border border-ctp-surface1 bg-ctp-surface0/70 p-6 text-sm text-ctp-overlay2">
Search your tracked subtitle lines.
</div>
)}
{trimmedQuery && !loading && !error && results.length === 0 && (
<div className="rounded-lg border border-ctp-surface1 bg-ctp-surface0/70 p-6 text-sm text-ctp-overlay2">
No sentence matches.
</div>
)}
{results.length > 0 && (
<div className="grid gap-3">
{results.map((result, index) => {
const availability = getSentenceSearchMineAvailability(result, trimmedQuery);
const wordStatus = mineStatus[statusKey(result, index, 'word')];
const sentenceStatus = mineStatus[statusKey(result, index, 'sentence')];
const audioStatus = mineStatus[statusKey(result, index, 'audio')];
const wordAudioDisabledReason =
availability.unavailableReason ??
(availability.exactMatch ? '' : 'Exact searched word not found in sentence.');
const sentenceDisabledReason = availability.unavailableReason ?? '';
const errors = [wordStatus?.error, sentenceStatus?.error, audioStatus?.error].filter(
Boolean,
);
return (
<article
key={resultKey(result, index)}
className="rounded-lg border border-ctp-surface1 bg-ctp-surface0/90 p-4 shadow-lg shadow-ctp-crust/20"
>
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
<div className="min-w-0">
<div className="truncate text-sm font-semibold text-ctp-text">
{result.animeTitle ?? result.videoTitle}
</div>
<div className="mt-1 flex flex-wrap gap-x-2 gap-y-1 text-xs text-ctp-overlay1">
<span className="max-w-full truncate">{result.videoTitle}</span>
<span>line {result.lineIndex}</span>
<span>session {result.sessionId}</span>
<span>
{formatSegment(result.segmentStartMs)}-{formatSegment(result.segmentEndMs)}
</span>
</div>
</div>
<div className="flex flex-wrap gap-2">
{availability.exactMatch && (
<button
type="button"
title={wordAudioDisabledReason || 'Create a word card from this sentence'}
className="rounded-md border border-ctp-mauve/50 px-3 py-1.5 text-xs font-medium text-ctp-mauve transition hover:bg-ctp-mauve/10 disabled:cursor-not-allowed disabled:border-ctp-surface2 disabled:text-ctp-overlay1 disabled:opacity-60"
disabled={wordStatus?.loading || !availability.canMineWordAudio}
onClick={() => void handleMine(result, index, 'word')}
>
{buttonLabel(
'word',
wordStatus,
availability.canMineWordAudio ? '' : 'Unavailable',
)}
</button>
)}
<button
type="button"
title={sentenceDisabledReason || 'Create a sentence card from this line'}
className="rounded-md border border-ctp-green/50 px-3 py-1.5 text-xs font-medium text-ctp-green transition hover:bg-ctp-green/10 disabled:cursor-not-allowed disabled:border-ctp-surface2 disabled:text-ctp-overlay1 disabled:opacity-60"
disabled={sentenceStatus?.loading || !availability.canMineSentence}
onClick={() => void handleMine(result, index, 'sentence')}
>
{buttonLabel(
'sentence',
sentenceStatus,
availability.canMineSentence ? '' : 'Unavailable',
)}
</button>
{availability.exactMatch && (
<button
type="button"
title={wordAudioDisabledReason || 'Create an audio card from this sentence'}
className="rounded-md border border-ctp-blue/50 px-3 py-1.5 text-xs font-medium text-ctp-blue transition hover:bg-ctp-blue/10 disabled:cursor-not-allowed disabled:border-ctp-surface2 disabled:text-ctp-overlay1 disabled:opacity-60"
disabled={audioStatus?.loading || !availability.canMineWordAudio}
onClick={() => void handleMine(result, index, 'audio')}
>
{buttonLabel(
'audio',
audioStatus,
availability.canMineWordAudio ? '' : 'Unavailable',
)}
</button>
)}
</div>
</div>
<p className="mt-4 rounded-lg bg-ctp-base/70 px-4 py-3 text-base leading-7 text-ctp-text">
{renderSentenceWithMatches(result.text, trimmedQuery)}
</p>
{errors.length > 0 && <div className="mt-2 text-xs text-ctp-red">{errors[0]}</div>}
</article>
);
})}
</div>
)}
</div>
);
}
@@ -36,7 +36,6 @@ export function VocabularyTab({
}: VocabularyTabProps) { }: VocabularyTabProps) {
const { words, kanji, knownWords, loading, error } = useVocabulary(); const { words, kanji, knownWords, loading, error } = useVocabulary();
const [selectedKanjiId, setSelectedKanjiId] = useState<number | null>(null); const [selectedKanjiId, setSelectedKanjiId] = useState<number | null>(null);
const [search, setSearch] = useState('');
const [hideNames, setHideNames] = useState(false); const [hideNames, setHideNames] = useState(false);
const [showExclusionManager, setShowExclusionManager] = useState(false); const [showExclusionManager, setShowExclusionManager] = useState(false);
@@ -116,14 +115,7 @@ export function VocabularyTab({
/> />
</div> </div>
<div className="flex items-center gap-3"> <div className="flex items-center justify-end gap-3">
<input
type="text"
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Search words..."
className="flex-1 bg-ctp-surface0 border border-ctp-surface1 rounded-lg px-3 py-2 text-sm text-ctp-text placeholder:text-ctp-overlay2 focus:outline-none focus:border-ctp-blue"
/>
{hasNames && ( {hasNames && (
<button <button
type="button" type="button"
@@ -178,12 +170,7 @@ export function VocabularyTab({
onSelectWord={handleSelectWord} onSelectWord={handleSelectWord}
/> />
<WordList <WordList words={filteredWords} selectedKey={null} onSelectWord={handleSelectWord} />
words={filteredWords}
selectedKey={null}
onSelectWord={handleSelectWord}
search={search}
/>
<KanjiBreakdown <KanjiBreakdown
kanji={kanji} kanji={kanji}
@@ -179,15 +179,15 @@ export function WordDetailPanel({
}; };
return ( return (
<div className="fixed inset-0 z-40"> <div className="fixed inset-0 z-40 flex items-center justify-center p-4">
<button <button
type="button" type="button"
aria-label="Close word detail panel" aria-label="Close word detail panel"
className="absolute inset-0 bg-ctp-crust/70 backdrop-blur-[2px]" className="absolute inset-0 bg-ctp-crust/70 backdrop-blur-[2px]"
onClick={onClose} onClick={onClose}
/> />
<aside className="absolute right-0 top-0 h-full w-full max-w-xl border-l border-ctp-surface1 bg-ctp-mantle shadow-2xl"> <aside className="relative flex max-h-[85vh] w-full max-w-xl flex-col overflow-hidden rounded-xl border border-ctp-surface1 bg-ctp-mantle shadow-2xl">
<div className="flex h-full flex-col"> <div className="flex min-h-0 flex-1 flex-col">
<div className="flex items-start justify-between border-b border-ctp-surface1 px-5 py-4"> <div className="flex items-start justify-between border-b border-ctp-surface1 px-5 py-4">
<div className="min-w-0"> <div className="min-w-0">
<div className="text-xs uppercase tracking-[0.18em] text-ctp-overlay1"> <div className="text-xs uppercase tracking-[0.18em] text-ctp-overlay1">
@@ -22,7 +22,7 @@ export function posColor(pos: string): string {
export function PosBadge({ pos }: { pos: string }) { export function PosBadge({ pos }: { pos: string }) {
return ( return (
<span className={`rounded-full px-2 py-0.5 text-[11px] font-medium ${posColor(pos)}`}> <span className={`rounded-full px-2 py-0.5 text-[11px] font-medium whitespace-nowrap ${posColor(pos)}`}>
{pos.replace(/_/g, ' ')} {pos.replace(/_/g, ' ')}
</span> </span>
); );
+22
View File
@@ -88,6 +88,28 @@ 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`,
);
} finally {
globalThis.fetch = originalFetch;
}
});
test('deleteSession throws when the stats API delete request fails', async () => { test('deleteSession throws when the stats API delete request fails', async () => {
const originalFetch = globalThis.fetch; const originalFetch = globalThis.fetch;
globalThis.fetch = (async () => globalThis.fetch = (async () =>
+8
View File
@@ -6,6 +6,7 @@ import type {
SessionTimelinePoint, SessionTimelinePoint,
SessionEvent, SessionEvent,
VocabularyEntry, VocabularyEntry,
SentenceSearchResult,
KanjiEntry, KanjiEntry,
VocabularyOccurrenceEntry, VocabularyOccurrenceEntry,
MediaLibraryItem, MediaLibraryItem,
@@ -115,6 +116,13 @@ export const apiClient = {
fetchJson<VocabularyOccurrenceEntry[]>( fetchJson<VocabularyOccurrenceEntry[]>(
`/api/stats/vocabulary/occurrences?headword=${encodeURIComponent(headword)}&word=${encodeURIComponent(word)}&reading=${encodeURIComponent(reading)}&limit=${limit}&offset=${offset}`, `/api/stats/vocabulary/occurrences?headword=${encodeURIComponent(headword)}&word=${encodeURIComponent(word)}&reading=${encodeURIComponent(reading)}&limit=${limit}&offset=${offset}`,
), ),
searchSentences: (query: string, limit = 50) =>
fetchJson<SentenceSearchResult[]>(
`/api/stats/sentences/search?${new URLSearchParams({
q: query,
limit: String(limit),
}).toString()}`,
),
getKanji: (limit = 100) => fetchJson<KanjiEntry[]>(`/api/stats/kanji?limit=${limit}`), getKanji: (limit = 100) => fetchJson<KanjiEntry[]>(`/api/stats/kanji?limit=${limit}`),
getKanjiOccurrences: (kanji: string, limit = 50, offset = 0) => getKanjiOccurrences: (kanji: string, limit = 50, offset = 0) =>
fetchJson<VocabularyOccurrenceEntry[]>( fetchJson<VocabularyOccurrenceEntry[]>(
+2
View File
@@ -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\/anime\/AnimeTab'\)/);
assert.match(source, /lazy\(\(\) =>\s*import\('\.\/components\/trends\/TrendsTab'\)/); 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\/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\/sessions\/SessionsTab'\)/);
assert.match(source, /lazy\(\(\) =>\s*import\('\.\/components\/library\/MediaDetailView'\)/); assert.match(source, /lazy\(\(\) =>\s*import\('\.\/components\/library\/MediaDetailView'\)/);
assert.match(source, /lazy\(\(\) =>\s*import\('\.\/components\/vocabulary\/WordDetailPanel'\)/); 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, source,
/import \{ VocabularyTab \} from '\.\/components\/vocabulary\/VocabularyTab';/, /import \{ VocabularyTab \} from '\.\/components\/vocabulary\/VocabularyTab';/,
); );
assert.doesNotMatch(source, /import \{ SearchTab \} from '\.\/components\/search\/SearchTab';/);
assert.doesNotMatch( assert.doesNotMatch(
source, source,
/import \{ SessionsTab \} from '\.\/components\/sessions\/SessionsTab';/, /import \{ SessionsTab \} from '\.\/components\/sessions\/SessionsTab';/,
+76
View File
@@ -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);
});
+36
View File
@@ -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.
}
}
+69
View File
@@ -0,0 +1,69 @@
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('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('renderSentenceWithMatches highlights exact searched-word matches', () => {
const markup = renderToStaticMarkup(<>{renderSentenceWithMatches('猫が寝る', '猫')}</>);
assert.match(markup, /<mark/);
assert.match(markup, />猫<\/mark>/);
});
+84
View File
@@ -0,0 +1,84 @@
import type { ReactNode } from 'react';
import type { SentenceSearchResult } from '../types/stats';
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();
}
export function findExactSentenceMatches(text: string, query: string): SentenceMatchRange[] {
const needle = normalizedSearchWord(query);
if (!needle) return [];
const ranges: SentenceMatchRange[] = [];
const haystack = text.toLocaleLowerCase();
const normalizedNeedle = needle.toLocaleLowerCase();
let searchFrom = 0;
while (searchFrom < haystack.length) {
const index = haystack.indexOf(normalizedNeedle, searchFrom);
if (index < 0) break;
ranges.push({ start: index, end: index + needle.length });
searchFrom = index + needle.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 = !result.sourcePath
? 'This source has no local file path.'
: result.segmentStartMs == null || result.segmentEndMs == null
? 'This line is missing segment timing.'
: null;
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(text.slice(cursor, range.start));
}
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(text.slice(cursor));
}
return parts;
}
@@ -10,6 +10,7 @@ test('TabBar renders Library instead of Anime for the media library tab', () =>
assert.doesNotMatch(markup, />Anime</); assert.doesNotMatch(markup, />Anime</);
assert.match(markup, />Overview</); assert.match(markup, />Overview</);
assert.match(markup, />Library</); assert.match(markup, />Library</);
assert.match(markup, />Search</);
}); });
test('EpisodeList renders explicit episode detail button alongside quick peek row', () => { test('EpisodeList renders explicit episode detail button alongside quick peek row', () => {
+14
View File
@@ -115,6 +115,20 @@ export interface VocabularyOccurrenceEntry {
occurrenceCount: number; occurrenceCount: number;
} }
export interface SentenceSearchResult {
animeId: number | null;
animeTitle: string | null;
videoId: number;
videoTitle: string;
sourcePath: string | null;
secondaryText: string | null;
sessionId: number;
lineIndex: number;
segmentStartMs: number | null;
segmentEndMs: number | null;
text: string;
}
export interface OverviewData { export interface OverviewData {
sessions: SessionSummary[]; sessions: SessionSummary[];
rollups: DailyRollup[]; rollups: DailyRollup[];