From eb5e07cfc037d7fe6f93525fd4efe817c0a5d5bd Mon Sep 17 00:00:00 2001 From: sudacode Date: Fri, 5 Jun 2026 01:39:06 -0700 Subject: [PATCH] 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 --- changes/fix-stats-vocab-example-mining.md | 5 + changes/stats-library-card-size.md | 4 + changes/stats-sentence-search.md | 6 + docs-site/configuration.md | 44 +- docs-site/immersion-tracking.md | 66 +-- docs-site/usage.md | 2 +- .../services/__tests__/stats-server.test.ts | 383 ++++++++++++++++++ .../services/immersion-tracker-service.ts | 6 + .../immersion-tracker/__tests__/query.test.ts | 96 +++++ .../immersion-tracker/query-lexical.ts | 62 +++ src/core/services/immersion-tracker/types.ts | 14 + src/core/services/mpv-protocol.test.ts | 21 + src/core/services/mpv-protocol.ts | 16 +- src/core/services/stats-server.ts | 267 +++++++++--- src/main.ts | 2 +- src/stats-daemon-runner.ts | 2 +- stats/src/App.tsx | 18 + stats/src/components/anime/AnimeTab.tsx | 25 +- stats/src/components/layout/TabBar.tsx | 3 +- stats/src/components/search/SearchTab.test.ts | 13 + stats/src/components/search/SearchTab.tsx | 268 ++++++++++++ .../components/vocabulary/VocabularyTab.tsx | 17 +- .../components/vocabulary/WordDetailPanel.tsx | 6 +- .../src/components/vocabulary/pos-helpers.tsx | 2 +- stats/src/lib/api-client.test.ts | 22 + stats/src/lib/api-client.ts | 8 + stats/src/lib/app-lazy-loading.test.ts | 2 + stats/src/lib/library-card-size.test.ts | 76 ++++ stats/src/lib/library-card-size.ts | 36 ++ stats/src/lib/sentence-search.test.tsx | 69 ++++ stats/src/lib/sentence-search.tsx | 84 ++++ stats/src/lib/stats-ui-navigation.test.tsx | 1 + stats/src/types/stats.ts | 14 + 33 files changed, 1532 insertions(+), 128 deletions(-) create mode 100644 changes/stats-library-card-size.md create mode 100644 changes/stats-sentence-search.md create mode 100644 stats/src/components/search/SearchTab.test.ts create mode 100644 stats/src/components/search/SearchTab.tsx create mode 100644 stats/src/lib/library-card-size.test.ts create mode 100644 stats/src/lib/library-card-size.ts create mode 100644 stats/src/lib/sentence-search.test.tsx create mode 100644 stats/src/lib/sentence-search.tsx diff --git a/changes/fix-stats-vocab-example-mining.md b/changes/fix-stats-vocab-example-mining.md index 846490b7..78267776 100644 --- a/changes/fix-stats-vocab-example-mining.md +++ b/changes/fix-stats-vocab-example-mining.md @@ -2,3 +2,8 @@ type: fixed 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 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. diff --git a/changes/stats-library-card-size.md b/changes/stats-library-card-size.md new file mode 100644 index 00000000..69c65304 --- /dev/null +++ b/changes/stats-library-card-size.md @@ -0,0 +1,4 @@ +type: fixed +area: stats + +- Library card size selection is now remembered across Stats window reloads and remounts. diff --git a/changes/stats-sentence-search.md b/changes/stats-sentence-search.md new file mode 100644 index 00000000..cda224d1 --- /dev/null +++ b/changes/stats-sentence-search.md @@ -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. diff --git a/docs-site/configuration.md b/docs-site/configuration.md index 558bb253..4c9bb93e 100644 --- a/docs-site/configuration.md +++ b/docs-site/configuration.md @@ -360,7 +360,7 @@ See `config.example.jsonc` for detailed configuration options. | Option | Values | Description | | ---------------------------------- | -------- | ---------------------------------------------------------------------------------------------------------------------------- | -| `primaryDefaultMode` | string | Default primary subtitle bar visibility mode: `"hidden"`, `"visible"`, or `"hover"` (default: `"visible"`) | +| `primaryDefaultMode` | string | Default primary subtitle bar visibility mode: `"hidden"`, `"visible"`, or `"hover"` (default: `"visible"`) | | `subtitleStyle.css` | object | CSS declaration object applied to primary subtitles after normal style defaults. Use CSS property names such as `font-size`. | | `secondary.css` | object | CSS declaration object applied to secondary subtitles after normal secondary style defaults. | | `enableJlpt` | boolean | Enable JLPT level underline styling (`false` by default) | @@ -516,11 +516,11 @@ See `config.example.jsonc` for detailed configuration options. } ``` -| Option | Values | Description | -| ----------------------- | ---------------------------------- | ------------------------------------------------------ | -| `secondarySubLanguages` | string[] | Language codes to auto-load (e.g., `["eng", "en"]`) | -| `autoLoadSecondarySub` | `true`, `false` | Auto-detect and load matching secondary subtitle track | -| `defaultMode` | `"hidden"`, `"visible"`, `"hover"` | Initial display mode (default: `"hover"`) | +| Option | Values | Description | +| ----------------------- | ---------------------------------- | ------------------------------------------------------------------------------------------------------------------- | +| `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 | +| `defaultMode` | `"hidden"`, `"visible"`, `"hover"` | Initial display mode (default: `"hover"`) | The secondary-subtitle language list also acts as the fallback secondary-language priority for managed startup subtitle selection on local playback and YouTube playback. @@ -1380,9 +1380,9 @@ Enable or disable local immersion analytics stored in SQLite for mined subtitles | `retention.dailyRollupsDays` | integer (`0`-`36500`) | Daily rollup retention window. Default `0` (keep all). | | `retention.monthlyRollupsDays` | integer (`0`-`36500`) | Monthly rollup retention window. Default `0` (keep all). | | `retention.vacuumIntervalDays` | integer (`0`-`3650`) | Minimum spacing between `VACUUM` passes. `0` disables vacuum. Default `0` (disabled). | -| `lifetimeSummaries.global` | `true`, `false` | Maintain global lifetime stats rows (default: `true`). | -| `lifetimeSummaries.anime` | `true`, `false` | Maintain per-anime lifetime stats rows (default: `true`). | -| `lifetimeSummaries.media` | `true`, `false` | Maintain per-media lifetime stats rows (default: `true`). | +| `lifetimeSummaries.global` | `true`, `false` | Maintain global lifetime stats rows (default: `true`). | +| `lifetimeSummaries.anime` | `true`, `false` | Maintain per-anime lifetime stats rows (default: `true`). | +| `lifetimeSummaries.media` | `true`, `false` | Maintain per-media lifetime stats rows (default: `true`). | You can also disable immersion tracking for a single session using: @@ -1433,7 +1433,7 @@ Usage notes: - The browser UI is served at `http://127.0.0.1:`. - 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 UI includes Overview, Library, Trends, Vocabulary, and Sessions tabs. +- The UI includes Overview, Library, Trends, Vocabulary, Search, and Sessions tabs. ### MPV Launcher @@ -1456,18 +1456,18 @@ Configure the mpv executable, profile, and window state for SubMiner-managed mpv } ``` -| 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 `""`) | -| `profile` | string | mpv profile name passed as `--profile=`. Leave empty to pass no profile (default `""`) | -| `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`) | -| `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`) | -| `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: `""`) | -| `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"`) | +| 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 `""`) | +| `profile` | string | mpv profile name passed as `--profile=`. Leave empty to pass no profile (default `""`) | +| `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`) | +| `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`) | +| `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: `""`) | +| `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"`) | If `mpv.profile` is configured and the launcher also receives `--profile`, SubMiner passes both as a comma-separated mpv profile list. diff --git a/docs-site/immersion-tracking.md b/docs-site/immersion-tracking.md index 52d6157c..38a3f9da 100644 --- a/docs-site/immersion-tracking.md +++ b/docs-site/immersion-tracking.md @@ -18,8 +18,8 @@ Episode completion for local `watched` state uses the shared `DEFAULT_MIN_WATCH_ { "immersionTracking": { "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) +#### 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`: ```jsonc @@ -78,8 +82,8 @@ Stats server config lives under `stats`: "toggleKey": "Backquote", "serverPort": 6969, "autoStartServer": true, - "autoOpenBrowser": false - } + "autoOpenBrowser": false, + }, } ``` @@ -96,15 +100,15 @@ Stats server config lives under `stats`: ## 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 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. 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 @@ -114,12 +118,12 @@ 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: -| Data type | Retention | -| -------------- | --------- | -| Raw events | 0 (keep all) | -| Telemetry | 0 (keep all) | -| Sessions | 0 (keep all) | -| Daily rollups | 0 (keep all) | +| Data type | Retention | +| --------------- | ------------ | +| Raw events | 0 (keep all) | +| Telemetry | 0 (keep all) | +| Sessions | 0 (keep all) | +| Daily rollups | 0 (keep all) | | Monthly rollups | 0 (keep all) | Maintenance runs on startup and every 24 hours. Vacuum runs only when `retention.vacuumIntervalDays` is non-zero. @@ -146,24 +150,24 @@ The tracker is optimized for "keep everything" defaults: All policy options live under `immersionTracking` in your config: -| Option | Description | -| ------ | ----------- | -| `batchSize` | Writes per flush batch | -| `flushIntervalMs` | Max delay between flushes (default: 500ms) | -| `queueCap` | Max queued writes before oldest are dropped | -| `payloadCapBytes` | Max payload size per write | -| `maintenanceIntervalMs` | How often maintenance runs | -| `retention.eventsDays` | Raw event retention | -| `retention.telemetryDays` | Telemetry retention | -| `retention.sessionsDays` | Session retention | -| `retention.dailyRollupsDays` | Daily rollup retention | -| `retention.monthlyRollupsDays` | Monthly rollup retention | -| `retention.vacuumIntervalDays` | Minimum spacing between vacuums | -| `retentionMode` | `preset` or `advanced` | -| `retentionPreset` | `minimal`, `balanced`, or `deep-history` (used by `retentionMode`) | -| `lifetimeSummaries.global` | Maintain global lifetime totals | -| `lifetimeSummaries.anime` | Maintain per-anime lifetime totals | -| `lifetimeSummaries.media` | Maintain per-media lifetime totals | +| Option | Description | +| ------------------------------ | ------------------------------------------------------------------ | +| `batchSize` | Writes per flush batch | +| `flushIntervalMs` | Max delay between flushes (default: 500ms) | +| `queueCap` | Max queued writes before oldest are dropped | +| `payloadCapBytes` | Max payload size per write | +| `maintenanceIntervalMs` | How often maintenance runs | +| `retention.eventsDays` | Raw event retention | +| `retention.telemetryDays` | Telemetry retention | +| `retention.sessionsDays` | Session retention | +| `retention.dailyRollupsDays` | Daily rollup retention | +| `retention.monthlyRollupsDays` | Monthly rollup retention | +| `retention.vacuumIntervalDays` | Minimum spacing between vacuums | +| `retentionMode` | `preset` or `advanced` | +| `retentionPreset` | `minimal`, `balanced`, or `deep-history` (used by `retentionMode`) | +| `lifetimeSummaries.global` | Maintain global lifetime totals | +| `lifetimeSummaries.anime` | Maintain per-anime lifetime totals | +| `lifetimeSummaries.media` | Maintain per-media lifetime totals | ## Query Templates diff --git a/docs-site/usage.md b/docs-site/usage.md index fb51d94a..aa0f5e41 100644 --- a/docs-site/usage.md +++ b/docs-site/usage.md @@ -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. - 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"]`). -- 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`. 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. diff --git a/src/core/services/__tests__/stats-server.test.ts b/src/core/services/__tests__/stats-server.test.ts index b94349bd..2f4095f7 100644 --- a/src/core/services/__tests__/stats-server.test.ts +++ b/src/core/services/__tests__/stats-server.test.ts @@ -338,7 +338,9 @@ function withTempDir(fn: (dir: string) => Promise | T): Promise | T { type CapturedAnkiRequest = { action?: string; params?: { + notes?: number[]; note?: { + id?: number; deckName?: string; modelName?: string; fields?: Record; @@ -365,6 +367,23 @@ async function withFakeAnkiConnect( } else { 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' }); @@ -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[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((resolve) => { + mediaRelease.audio = () => resolve(Buffer.from('audio')); + }), + generateScreenshot: async () => + await new Promise((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, 'を見た'); + 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 () => { const app = createStatsApp(createMockTracker()); const res = await app.request('/api/stats/episode/1/detail'); diff --git a/src/core/services/immersion-tracker-service.ts b/src/core/services/immersion-tracker-service.ts index 55bb17bf..82e192ae 100644 --- a/src/core/services/immersion-tracker-service.ts +++ b/src/core/services/immersion-tracker-service.ts @@ -55,6 +55,7 @@ import { getStatsExcludedWords, getVocabularyStats, replaceStatsExcludedWords, + searchSubtitleSentences, getWordAnimeAppearances, getWordDetail, getWordOccurrences, @@ -148,6 +149,7 @@ import { type MediaLibraryRow, type NewAnimePerDayRow, type QueuedWrite, + type SentenceSearchResultRow, type SessionEventRow, type SessionState, type SessionSummaryQueryRow, @@ -568,6 +570,10 @@ export class ImmersionTrackerService { return getKanjiOccurrences(this.db, kanji, limit, offset); } + async searchSubtitleSentences(query: string, limit = 50): Promise { + return searchSubtitleSentences(this.db, query, limit); + } + async getSessionEvents( sessionId: number, limit = 500, diff --git a/src/core/services/immersion-tracker/__tests__/query.test.ts b/src/core/services/immersion-tracker/__tests__/query.test.ts index ae0bf93c..4523e49f 100644 --- a/src/core/services/immersion-tracker/__tests__/query.test.ts +++ b/src/core/services/immersion-tracker/__tests__/query.test.ts @@ -35,6 +35,7 @@ import { getSessionTimeline, getSessionWordsByLine, getWordOccurrences, + searchSubtitleSentences, upsertCoverArt, } from '../query.js'; 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', () => { const dbPath = makeDbPath(); const db = new Database(dbPath); diff --git a/src/core/services/immersion-tracker/query-lexical.ts b/src/core/services/immersion-tracker/query-lexical.ts index fc8ea5d3..7f37cbab 100644 --- a/src/core/services/immersion-tracker/query-lexical.ts +++ b/src/core/services/immersion-tracker/query-lexical.ts @@ -7,6 +7,7 @@ import type { KanjiOccurrenceRow, KanjiStatsRow, KanjiWordRow, + SentenceSearchResultRow, SessionEventRow, SimilarWordRow, StatsExcludedWordRow, @@ -21,6 +22,19 @@ import { nowMs } from './time'; const VOCABULARY_STATS_FILTER_OVERSAMPLE_FACTOR = 4; 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 { const 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[]; } +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( db: DatabaseSync, sessionId: number, diff --git a/src/core/services/immersion-tracker/types.ts b/src/core/services/immersion-tracker/types.ts index b0dcf6ce..ee2844b6 100644 --- a/src/core/services/immersion-tracker/types.ts +++ b/src/core/services/immersion-tracker/types.ts @@ -367,6 +367,20 @@ export interface KanjiOccurrenceRow { 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 { eventType: number; tsMs: number; diff --git a/src/core/services/mpv-protocol.test.ts b/src/core/services/mpv-protocol.test.ts index b75485a3..40ccd2ee 100644 --- a/src/core/services/mpv-protocol.test.ts +++ b/src/core/services/mpv-protocol.test.ts @@ -235,6 +235,27 @@ test('dispatchMpvProtocolMessage prefers the already selected matching secondary 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 () => { const { deps, state } = createDeps(); diff --git a/src/core/services/mpv-protocol.ts b/src/core/services/mpv-protocol.ts index 4b499916..217e3e87 100644 --- a/src/core/services/mpv-protocol.ts +++ b/src/core/services/mpv-protocol.ts @@ -149,6 +149,11 @@ function getSubtitleTrackIdentity(track: SubtitleTrackCandidate): string { 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( tracks: Array>, preferredLanguages: string[], @@ -177,12 +182,19 @@ function pickSecondarySubtitleTrackId( const uniqueTracks = [...dedupedTracks.values()]; 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) { return selectedMatch.id; } - const match = uniqueTracks.find((track) => track.lang === language); + const match = candidateTracks[0]; if (match) { return match.id; } diff --git a/src/core/services/stats-server.ts b/src/core/services/stats-server.ts index c8fd0509..314ea3ab 100644 --- a/src/core/services/stats-server.ts +++ b/src/core/services/stats-server.ts @@ -7,6 +7,7 @@ import { Readable } from 'node:stream'; import { MediaGenerator } from '../../media-generator.js'; import { AnkiConnectClient } from '../../anki-connect.js'; import type { AnkiConnectConfig } from '../../types.js'; +import { createLogger } from '../../logger.js'; import { getConfiguredSentenceFieldName, getConfiguredTranslationFieldName, @@ -21,6 +22,23 @@ type StatsServerNoteInfo = { fields: Record; }; +type StatsServerMediaGenerator = { + generateAudio: (...args: Parameters) => Promise; + generateScreenshot: ( + ...args: Parameters + ) => Promise; + generateAnimatedImage: ( + ...args: Parameters + ) => Promise; +}; + +export type StatsMiningTimingEvent = { + mode: 'word' | 'sentence' | 'audio'; + phase: string; + elapsedMs: number; + noteId?: number; +}; + type StatsExcludedWordPayload = { headword: string; word: string; @@ -122,6 +140,52 @@ function resolveStatsNoteFieldName( return null; } +function uniqueFieldNames(...fieldNames: (string | null | undefined)[]): string[] { + const seen = new Set(); + 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 { const fetchHeaders = new Headers(); for (const [name, value] of Object.entries(headers)) { @@ -291,6 +355,7 @@ export interface StatsServerConfig { knownWordCachePath?: string; mpvSocketPath?: string; ankiConnectConfig?: AnkiConnectConfig; + getAnkiConnectConfig?: () => AnkiConnectConfig | undefined; anilistRateLimiter?: AnilistRateLimiter; addYomitanNote?: (word: string) => Promise; resolveAnkiNoteId?: (noteId: number) => number; @@ -314,6 +379,11 @@ const STATS_STATIC_CONTENT_TYPES: Record = { '.woff2': 'font/woff2', }; const ANKI_CONNECT_FETCH_TIMEOUT_MS = 3_000; +const statsMiningLogger = createLogger('stats:mining'); + +function defaultNowMs(): number { + return Date.now(); +} function buildAnkiNotePreview( fields: Record, @@ -375,12 +445,53 @@ export function createStatsApp( knownWordCachePath?: string; mpvSocketPath?: string; ankiConnectConfig?: AnkiConnectConfig; + getAnkiConnectConfig?: () => AnkiConnectConfig | undefined; anilistRateLimiter?: AnilistRateLimiter; addYomitanNote?: (word: string) => Promise; resolveAnkiNoteId?: (noteId: number) => number; + createMediaGenerator?: () => StatsServerMediaGenerator; + onMiningTiming?: (event: StatsMiningTimingEvent) => void; + nowMs?: () => number; }, ) { 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 ( + mode: StatsMiningTimingEvent['mode'], + phase: string, + fn: () => Promise, + details?: (value: T) => Partial, + ): Promise => { + 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) => { const [rawSessions, rollups, hints] = await Promise.all([ @@ -544,6 +655,14 @@ export function createStatsApp( 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) => { const limit = parseIntQuery(c.req.query('limit'), 100, 500); const kanji = await tracker.getKanjiStats(limit); @@ -863,7 +982,7 @@ export function createStatsApp( return c.json( (result.result ?? []).map((note) => ({ ...note, - preview: buildAnkiNotePreview(note.fields, options?.ankiConnectConfig), + preview: buildAnkiNotePreview(note.fields, getAnkiConnectConfig()), })), ); } catch { @@ -891,13 +1010,13 @@ export function createStatsApp( return c.json({ error: 'File not found' }, 404); } - const ankiConfig = options?.ankiConnectConfig; + const ankiConfig = getAnkiConnectConfig(); if (!ankiConfig) { return c.json({ error: 'AnkiConnect is not configured' }, 500); } 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 maxMediaDuration = ankiConfig.media?.maxMediaDuration ?? 30; @@ -921,7 +1040,9 @@ export function createStatsApp( imageType === 'avif' && ankiConfig.media?.syncAnimatedImageToWordAudio !== false; const audioPromise = generateAudio - ? mediaGen.generateAudio(sourcePath, startSec, clampedEndSec, audioPadding) + ? timeMiningPhase(mode, 'generateAudio', () => + mediaGen.generateAudio(sourcePath, startSec, clampedEndSec, audioPadding), + ) : Promise.resolve(null); const createImagePromise = (animatedLeadInSeconds = 0): Promise => { @@ -930,22 +1051,26 @@ export function createStatsApp( } if (imageType === 'avif') { - return mediaGen.generateAnimatedImage(sourcePath, startSec, clampedEndSec, audioPadding, { - fps: ankiConfig.media?.animatedFps ?? 10, - maxWidth: ankiConfig.media?.animatedMaxWidth ?? 640, - maxHeight: ankiConfig.media?.animatedMaxHeight, - crf: ankiConfig.media?.animatedCrf ?? 35, - leadingStillDuration: animatedLeadInSeconds, - }); + return timeMiningPhase(mode, 'generateAnimatedImage', () => + mediaGen.generateAnimatedImage(sourcePath, startSec, clampedEndSec, audioPadding, { + fps: ankiConfig.media?.animatedFps ?? 10, + maxWidth: ankiConfig.media?.animatedMaxWidth ?? 640, + maxHeight: ankiConfig.media?.animatedMaxHeight, + crf: ankiConfig.media?.animatedCrf ?? 35, + leadingStillDuration: animatedLeadInSeconds, + }), + ); } const midpointSec = (startSec + clampedEndSec) / 2; - return mediaGen.generateScreenshot(sourcePath, midpointSec, { - format: ankiConfig.media?.imageFormat ?? 'jpg', - quality: ankiConfig.media?.imageQuality ?? 92, - maxWidth: ankiConfig.media?.imageMaxWidth, - maxHeight: ankiConfig.media?.imageMaxHeight, - }); + return timeMiningPhase(mode, 'generateScreenshot', () => + mediaGen.generateScreenshot(sourcePath, midpointSec, { + format: ankiConfig.media?.imageFormat ?? 'jpg', + quality: ankiConfig.media?.imageQuality ?? 92, + maxWidth: ankiConfig.media?.imageMaxWidth, + maxHeight: ankiConfig.media?.imageMaxHeight, + }), + ); }; const imagePromise = @@ -962,7 +1087,12 @@ export function createStatsApp( } const [yomitanResult, audioResult, imageResult] = await Promise.allSettled([ - options.addYomitanNote(word), + timeMiningPhase( + 'word', + 'addYomitanNote', + () => options.addYomitanNote!(word), + (noteId) => (typeof noteId === 'number' ? { noteId } : {}), + ), audioPromise, imagePromise, ]); @@ -984,10 +1114,19 @@ export function createStatsApp( errors.push(`image: ${(imageResult.reason as Error).message}`); let imageBuffer = imageResult.status === 'fulfilled' ? imageResult.value : null; - if (syncAnimatedImageToWordAudio && generateImage) { + let noteInfo: StatsServerNoteInfo | null = null; + if (audioBuffer || (syncAnimatedImageToWordAudio && generateImage)) { try { 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 ? await resolveAnimatedImageLeadInSeconds({ config: ankiConfig, @@ -1006,18 +1145,17 @@ export function createStatsApp( const mediaFields: Record = {}; const timestamp = Date.now(); const sentenceFieldName = ankiConfig.fields?.sentence ?? 'Sentence'; - const audioFieldName = ankiConfig.fields?.audio ?? 'ExpressionAudio'; + const audioFieldName = getStatsWordMiningAudioFieldName(ankiConfig, noteInfo); const imageFieldName = ankiConfig.fields?.image ?? 'Picture'; mediaFields[sentenceFieldName] = highlightedSentence; - if (secondaryText) { - mediaFields[ankiConfig.fields?.translation ?? 'SelectionText'] = secondaryText; - } if (audioBuffer) { const audioFilename = `subminer_audio_${timestamp}.mp3`; try { - await client.storeMediaFile(audioFilename, audioBuffer); + await timeMiningPhase('word', 'uploadAudio', () => + client.storeMediaFile(audioFilename, audioBuffer), + ); mediaFields[audioFieldName] = `[sound:${audioFilename}]`; } catch (err) { 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 imageFilename = `subminer_image_${timestamp}.${imageExt}`; try { - await client.storeMediaFile(imageFilename, imageBuffer); + await timeMiningPhase('word', 'uploadImage', () => + client.storeMediaFile(imageFilename, imageBuffer), + ); mediaFields[imageFieldName] = ``; } catch (err) { errors.push(`image upload: ${(err as Error).message}`); @@ -1056,7 +1196,9 @@ export function createStatsApp( if (Object.keys(mediaFields).length > 0) { try { - await client.updateNoteFields(noteId, mediaFields); + await timeMiningPhase('word', 'updateNoteFields', () => + client.updateNoteFields(noteId, mediaFields), + ); } catch (err) { errors.push(`update fields: ${(err as Error).message}`); } @@ -1065,19 +1207,9 @@ export function createStatsApp( 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 sentenceFieldName = ankiConfig.fields?.sentence ?? 'Sentence'; const translationFieldName = ankiConfig.fields?.translation ?? 'SelectionText'; - const audioFieldName = ankiConfig.fields?.audio ?? 'ExpressionAudio'; const imageFieldName = ankiConfig.fields?.image ?? 'Picture'; const miscInfoFieldName = ankiConfig.fields?.miscInfo ?? ''; @@ -1085,7 +1217,7 @@ export function createStatsApp( [sentenceFieldName]: highlightedSentence, }; - if (secondaryText) { + if (mode === 'sentence' && secondaryText) { fields[translationFieldName] = secondaryText; } @@ -1104,20 +1236,58 @@ export function createStatsApp( const deck = ankiConfig.deck?.trim() || 'Default'; const tags = ankiConfig.tags ?? ['SubMiner']; - try { - noteId = await client.addNote(deck, model, fields, tags); - } catch (err) { - return c.json({ error: `Failed to add note: ${(err as Error).message}` }, 502); + const addNotePromise = timeMiningPhase( + mode, + 'addNote', + () => 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 = {}; 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) { const audioFilename = `subminer_audio_${timestamp}.mp3`; try { - await client.storeMediaFile(audioFilename, audioBuffer); - mediaFields[audioFieldName] = `[sound:${audioFilename}]`; + await timeMiningPhase(mode, 'uploadAudio', () => + client.storeMediaFile(audioFilename, audioBuffer), + ); + const audioValue = `[sound:${audioFilename}]`; + for (const fieldName of getStatsDirectMiningAudioFieldNames(ankiConfig, noteInfo)) { + mediaFields[fieldName] = audioValue; + } } catch (err) { 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 imageFilename = `subminer_image_${timestamp}.${imageExt}`; try { - await client.storeMediaFile(imageFilename, imageBuffer); + await timeMiningPhase(mode, 'uploadImage', () => + client.storeMediaFile(imageFilename, imageBuffer), + ); mediaFields[imageFieldName] = ``; } catch (err) { errors.push(`image upload: ${(err as Error).message}`); @@ -1155,7 +1327,9 @@ export function createStatsApp( if (Object.keys(mediaFields).length > 0) { try { - await client.updateNoteFields(noteId, mediaFields); + await timeMiningPhase(mode, 'updateNoteFields', () => + client.updateNoteFields(noteId, mediaFields), + ); } catch (err) { errors.push(`update fields: ${(err as Error).message}`); } @@ -1195,6 +1369,7 @@ export function startStatsServer(config: StatsServerConfig): { close: () => void knownWordCachePath: config.knownWordCachePath, mpvSocketPath: config.mpvSocketPath, ankiConnectConfig: config.ankiConnectConfig, + getAnkiConnectConfig: config.getAnkiConnectConfig, anilistRateLimiter: config.anilistRateLimiter, addYomitanNote: config.addYomitanNote, resolveAnkiNoteId: config.resolveAnkiNoteId, diff --git a/src/main.ts b/src/main.ts index 3e0b4d0e..074c7b0b 100644 --- a/src/main.ts +++ b/src/main.ts @@ -4445,7 +4445,7 @@ const startLocalStatsServer = (): void => { tracker, knownWordCachePath: path.join(USER_DATA_PATH, 'known-words-cache.json'), mpvSocketPath: appState.mpvSocketPath, - ankiConnectConfig: getResolvedConfig().ankiConnect, + getAnkiConnectConfig: () => getResolvedConfig().ankiConnect, anilistRateLimiter, resolveAnkiNoteId: (noteId: number) => appState.ankiIntegration?.resolveCurrentNoteId(noteId) ?? noteId, diff --git a/src/stats-daemon-runner.ts b/src/stats-daemon-runner.ts index 857d02b4..fd589c4d 100644 --- a/src/stats-daemon-runner.ts +++ b/src/stats-daemon-runner.ts @@ -195,7 +195,7 @@ async function main(): Promise { staticDir: statsDistPath, tracker, knownWordCachePath, - ankiConnectConfig: config.ankiConnect, + getAnkiConnectConfig: () => configService.reloadConfig().ankiConnect, addYomitanNote: async (word: string) => await invokeStatsWordHelper({ helperScriptPath: wordHelperScriptPath, diff --git a/stats/src/App.tsx b/stats/src/App.tsx index e8eab628..88d39dfa 100644 --- a/stats/src/App.tsx +++ b/stats/src/App.tsx @@ -30,6 +30,11 @@ const VocabularyTab = lazy(() => default: module.VocabularyTab, })), ); +const SearchTab = lazy(() => + import('./components/search/SearchTab').then((module) => ({ + default: module.SearchTab, + })), +); const SessionsTab = lazy(() => import('./components/sessions/SessionsTab').then((module) => ({ default: module.SessionsTab, @@ -239,6 +244,19 @@ export function App() { ) : null} + {mountedTabs.has('search') ? ( + + ) : null} {mountedTabs.has('sessions') ? (
= { +const GRID_CLASSES: Record = { 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', 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 [search, setSearch] = useState(''); const [sortKey, setSortKey] = useState('lastWatched'); - const [cardSize, setCardSize] = useState('md'); + const [cardSize, setCardSize] = useState(() => + readLibraryCardSizePreference( + getLibraryCardSizeStorage(typeof window === 'undefined' ? null : window), + ), + ); const [selectedAnimeId, setSelectedAnimeId] = useState(null); + function handleCardSizeChange(size: LibraryCardSize): void { + setCardSize(size); + writeLibraryCardSizePreference( + getLibraryCardSizeStorage(typeof window === 'undefined' ? null : window), + size, + ); + } + useEffect(() => { if (initialAnimeId != null) { setSelectedAnimeId(initialAnimeId); @@ -113,7 +130,7 @@ export function AnimeTab({ {(['sm', 'md', 'lg'] as const).map((size) => ( + )} + + {availability.exactMatch && ( + + )} + + + +

+ {renderSentenceWithMatches(result.text, trimmedQuery)} +

+ {errors.length > 0 &&
{errors[0]}
} + + ); + })} + + )} + + ); +} diff --git a/stats/src/components/vocabulary/VocabularyTab.tsx b/stats/src/components/vocabulary/VocabularyTab.tsx index dc0aa1ba..2e2e6ffe 100644 --- a/stats/src/components/vocabulary/VocabularyTab.tsx +++ b/stats/src/components/vocabulary/VocabularyTab.tsx @@ -36,7 +36,6 @@ export function VocabularyTab({ }: VocabularyTabProps) { const { words, kanji, knownWords, loading, error } = useVocabulary(); const [selectedKanjiId, setSelectedKanjiId] = useState(null); - const [search, setSearch] = useState(''); const [hideNames, setHideNames] = useState(false); const [showExclusionManager, setShowExclusionManager] = useState(false); @@ -116,14 +115,7 @@ export function VocabularyTab({ /> -
- 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 && (