diff --git a/changes/fix-stats-cover-refresh.md b/changes/fix-stats-cover-refresh.md deleted file mode 100644 index 4898ec90..00000000 --- a/changes/fix-stats-cover-refresh.md +++ /dev/null @@ -1,4 +0,0 @@ -type: fixed -area: stats - -- Retried and batched stats home cover images from stored DB art so AniList art appears without extra AniList lookups or a full page refresh. diff --git a/changes/fix-stats-vocab-example-mining.md b/changes/fix-stats-vocab-example-mining.md deleted file mode 100644 index 068aacdc..00000000 --- a/changes/fix-stats-vocab-example-mining.md +++ /dev/null @@ -1,9 +0,0 @@ -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 sentence mining audio updates so generated sentence clips populate `SentenceAudio` without also writing the same clip to the configured expression-audio field. -- 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/fix-stats-vocab-exclusions.md b/changes/fix-stats-vocab-exclusions.md deleted file mode 100644 index 0595b321..00000000 --- a/changes/fix-stats-vocab-exclusions.md +++ /dev/null @@ -1,4 +0,0 @@ -type: fixed -area: stats - -- Fixed vocabulary exclusions so adding a word once hides matching token variants across the vocabulary page and duplicate exclusions are collapsed. diff --git a/changes/related-seen-words.md b/changes/related-seen-words.md deleted file mode 100644 index 47386195..00000000 --- a/changes/related-seen-words.md +++ /dev/null @@ -1,4 +0,0 @@ -type: changed -area: stats - -- Renamed the vocabulary detail “Similar Words” section to “Related Seen Words” and tightened matches to same readings or shared kanji, avoiding noisy kana-suffix matches. diff --git a/changes/stats-delete-progress-indicator.md b/changes/stats-delete-progress-indicator.md deleted file mode 100644 index 8652f651..00000000 --- a/changes/stats-delete-progress-indicator.md +++ /dev/null @@ -1,4 +0,0 @@ -type: added -area: stats - -- Showed a progress indicator while sessions are being deleted on the home and sessions pages, so long batch deletes give visible feedback instead of appearing to do nothing. diff --git a/changes/stats-hide-kana-filter.md b/changes/stats-hide-kana-filter.md deleted file mode 100644 index 59a4b8ce..00000000 --- a/changes/stats-hide-kana-filter.md +++ /dev/null @@ -1,4 +0,0 @@ -type: added -area: stats - -- Added a Hide Kana filter to the common-words frequency table so kana-only headwords can be hidden while reviewing mining targets. diff --git a/changes/stats-library-card-size.md b/changes/stats-library-card-size.md deleted file mode 100644 index 69c65304..00000000 --- a/changes/stats-library-card-size.md +++ /dev/null @@ -1,4 +0,0 @@ -type: fixed -area: stats - -- Library card size selection is now remembered across Stats window reloads and remounts. diff --git a/changes/stats-sentence-search-headword.md b/changes/stats-sentence-search-headword.md deleted file mode 100644 index 004b5b89..00000000 --- a/changes/stats-sentence-search-headword.md +++ /dev/null @@ -1,5 +0,0 @@ -type: added -area: stats - -- Added headword-based sentence search by default, so searches like `知らない` can find tracked lines containing inflected variants such as `知らねえ` while exact sentence text searches still work. -- Added a Search by headword toggle on the Stats search page for switching back to exact text/title matching. diff --git a/changes/stats-sentence-search.md b/changes/stats-sentence-search.md deleted file mode 100644 index cda224d1..00000000 --- a/changes/stats-sentence-search.md +++ /dev/null @@ -1,6 +0,0 @@ -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/changes/stats-session-delete-speed.md b/changes/stats-session-delete-speed.md deleted file mode 100644 index f36a73bc..00000000 --- a/changes/stats-session-delete-speed.md +++ /dev/null @@ -1,4 +0,0 @@ -type: fixed -area: stats - -- Sped up deleting stats sessions by refreshing only affected rollups and rebuilding lifetime summaries with aggregate SQL. diff --git a/changes/stats-updates.md b/changes/stats-updates.md new file mode 100644 index 00000000..2de390fe --- /dev/null +++ b/changes/stats-updates.md @@ -0,0 +1,8 @@ +type: changed +area: stats + +- Added the Stats Search tab for realtime subtitle sentence search with media context, headword matching, and mining actions for source-backed sentence cards or exact-match word/audio cards. +- Improved Stats mining from Search and vocabulary examples: empty `ankiConnect.deck` can use Yomitan's mining deck, sentence cards are created before slow media generation finishes, secondary subtitles from stored lines, sidecar files, or temporary alass-retimed English sidecars populate sentence Selection Text, invalid stored timings are blocked before FFmpeg runs, future out-of-order subtitle timing pairs are skipped until valid timings arrive, and partial media failures are shown. +- Fixed Stats mining field/audio behavior so sentence clips update `SentenceAudio`, word audio uses the configured Yomitan sources, English subtitle text is not written onto word cards, and secondary subtitle auto-selection prefers regular English tracks over Signs/Songs tracks. +- Improved vocabulary review with a Hide Kana filter, duplicate-collapsed exclusions across token variants, and Related Seen Words matching based on shared readings or kanji. +- Improved Stats browsing reliability by remembering library card size, retrying stored cover art without extra AniList lookups, showing progress during session deletes, and making session deletes refresh faster. diff --git a/docs-site/configuration.md b/docs-site/configuration.md index 6103f37e..8054152c 100644 --- a/docs-site/configuration.md +++ b/docs-site/configuration.md @@ -1122,6 +1122,8 @@ Sync the active subtitle track from the overlay picker using `alass` or `ffsubsy | `ffmpeg_path` | string path | Path to `ffmpeg` (used for internal subtitle extraction). Empty or `null` falls back to `/usr/bin/ffmpeg`. | | `replace` | `true`, `false` | When `true` (default), overwrite the active subtitle file on successful sync. When `false`, write `_retimed.`. | +Stats dashboard sentence mining also uses `alass_path` when available to align a local English sidecar against the local Japanese sidecar before filling the card translation field. This stats-only retime writes a temporary cached copy and never edits the original subtitle files. + Default trigger is `Ctrl+Alt+S` via `shortcuts.triggerSubsync`. Customize it there, or set it to `null` to disable. diff --git a/docs-site/mining-workflow.md b/docs-site/mining-workflow.md index 41a78958..86c0e374 100644 --- a/docs-site/mining-workflow.md +++ b/docs-site/mining-workflow.md @@ -4,7 +4,7 @@ This guide walks through the sentence mining loop - from watching a video to cre ## Overview -*Sentence mining* means turning real sentences you encounter while watching native video into Anki flashcards, so you learn vocabulary in the context where you actually met it. SubMiner automates the tedious parts of that loop. +_Sentence mining_ means turning real sentences you encounter while watching native video into Anki flashcards, so you learn vocabulary in the context where you actually met it. SubMiner automates the tedious parts of that loop. SubMiner runs as a transparent overlay on top of mpv (the video player). As subtitles play, the overlay displays them as interactive text. You hover a word, trigger a Yomitan dictionary lookup with your configured lookup key/modifier, then create an Anki card with a single action. SubMiner automatically attaches the sentence, an audio clip, and a screenshot to that card - no manual copy-pasting or screen capturing. @@ -122,10 +122,10 @@ By default the **primary** bar is `visible` (`subtitleStyle.primaryDefaultMode`) Cycle each bar's mode at runtime with its own shortcut: -| Shortcut | Action | Config key | -| -------------------- | -------------------------------------------------------- | ------------------------------ | -| `V` | Cycle primary subtitle mode (hidden → visible → hover) | overlay-local | -| `Ctrl/Cmd+Shift+V` | Cycle secondary subtitle mode (hidden → visible → hover) | `shortcuts.toggleSecondarySub` | +| Shortcut | Action | Config key | +| ------------------ | -------------------------------------------------------- | ------------------------------ | +| `V` | Cycle primary subtitle mode (hidden → visible → hover) | overlay-local | +| `Ctrl/Cmd+Shift+V` | Cycle secondary subtitle mode (hidden → visible → hover) | `shortcuts.toggleSecondarySub` | ### Modal Surfaces @@ -166,6 +166,8 @@ If your subtitle file is out of sync with the audio, SubMiner can resynchronize For remote streams, including Jellyfin playback, the modal only offers alass. Jellyfin subtitle URLs are cached as temporary subtitle files so alass can read them, but the video stream is not downloaded. ffsubsync needs direct access to the local media file and is unavailable for stream URLs. +When you mine a sentence card from the stats dashboard, SubMiner can also use `alass` automatically to align a local English sidecar against the matching local Japanese sidecar before filling the card translation field. The source subtitle files are not modified; SubMiner writes a temporary retimed copy and reuses it while the stats server is running. + Install the sync tools separately - see [Troubleshooting](/troubleshooting#subtitle-sync-subsync) if the tools are not found. ## Texthooker diff --git a/src/core/services/__tests__/stats-server.test.ts b/src/core/services/__tests__/stats-server.test.ts index 0451fca0..fc88ff8f 100644 --- a/src/core/services/__tests__/stats-server.test.ts +++ b/src/core/services/__tests__/stats-server.test.ts @@ -7,6 +7,10 @@ import path from 'node:path'; import type { AddressInfo } from 'node:net'; import { createStatsApp, startStatsServer } from '../stats-server.js'; import type { ImmersionTrackerService } from '../immersion-tracker-service.js'; +import { + clearRetimedSecondarySubtitleCache, + resolveRetimedSecondarySubtitleTextFromSidecar, +} from '../secondary-subtitle-sidecar.js'; const SESSION_SUMMARIES = [ { @@ -1154,6 +1158,50 @@ describe('stats server API routes', () => { assert.ok(Array.isArray(body)); }); + it('POST /api/stats/mine-card rejects non-positive source timing before media generation', async () => { + await withTempDir(async (dir) => { + const sourcePath = path.join(dir, 'episode.mkv'); + fs.writeFileSync(sourcePath, 'fake media'); + let generatedAudio = false; + + const app = createStatsApp(createMockTracker(), { + createMediaGenerator: () => ({ + generateAudio: async () => { + generatedAudio = true; + return Buffer.from('audio'); + }, + generateScreenshot: async () => Buffer.from('image'), + generateAnimatedImage: async () => null, + }), + ankiConnectConfig: { + deck: 'Mining', + media: { + generateAudio: true, + generateImage: true, + }, + }, + }); + + const res = await app.request('/api/stats/mine-card?mode=sentence', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + sourcePath, + startMs: 953_991, + endMs: 953_891, + sentence: '猫を見た', + word: '猫', + videoTitle: 'Episode 1', + }), + }); + + const body = await res.json(); + assert.equal(res.status, 400, JSON.stringify(body)); + assert.deepEqual(body, { error: 'endMs must be greater than startMs' }); + assert.equal(generatedAudio, false); + }); + }); + it('POST /api/stats/mine-card falls back to Default deck for empty deck config', async () => { await withTempDir(async (dir) => { const sourcePath = path.join(dir, 'episode.mkv'); @@ -1317,6 +1365,125 @@ describe('stats server API routes', () => { }); }); + it('POST /api/stats/mine-card prefers retimed sidecar secondary 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 options = { + resolveRetimedSecondarySubtitleText: async () => 'Aligned English subtitle', + ankiConnectConfig: { + url, + deck: 'Mining', + fields: { + sentence: 'Sentence', + translation: 'SelectionText', + }, + media: { + generateAudio: false, + generateImage: false, + }, + isLapis: { + enabled: true, + sentenceCardModel: 'Lapis Morph', + }, + }, + } as Parameters[1] & { + resolveRetimedSecondarySubtitleText: () => Promise; + }; + const app = createStatsApp(createMockTracker(), options); + + 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: 'Stale stored English subtitle', + 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, + 'Aligned English subtitle', + ); + }); + }); + }); + + it('retimes secondary sidecar subtitles against the Japanese sidecar and caches the output', async () => { + await withTempDir(async (dir) => { + const sourcePath = path.join(dir, 'episode.mkv'); + const japanesePath = path.join(dir, 'episode.ja.srt'); + const englishPath = path.join(dir, 'episode.en.srt'); + const alassPath = path.join(dir, 'alass-cli'); + const originalEnglish = `1 +00:00:09,000 --> 00:00:10,000 +Stale English subtitle +`; + fs.writeFileSync(sourcePath, 'fake media'); + fs.writeFileSync(alassPath, 'fake alass'); + fs.writeFileSync( + japanesePath, + `1 +00:00:01,000 --> 00:00:02,000 +猫を見た +`, + ); + fs.writeFileSync(englishPath, originalEnglish); + + let alassRuns = 0; + try { + const first = await resolveRetimedSecondarySubtitleTextFromSidecar({ + sourcePath, + startMs: 1_000, + endMs: 2_000, + alassPath, + runAlass: async (_alassPath, referencePath, inputPath, outputPath) => { + alassRuns += 1; + assert.equal(referencePath, japanesePath); + assert.equal(inputPath, englishPath); + fs.writeFileSync( + outputPath, + `1 +00:00:01,000 --> 00:00:02,000 +Aligned English subtitle +`, + ); + return { ok: true, code: 0, stdout: '', stderr: '' }; + }, + }); + + const second = await resolveRetimedSecondarySubtitleTextFromSidecar({ + sourcePath, + startMs: 1_000, + endMs: 2_000, + alassPath, + runAlass: async () => { + alassRuns += 1; + return { ok: false, code: 1, stdout: '', stderr: 'should use cache' }; + }, + }); + + assert.equal(first, 'Aligned English subtitle'); + assert.equal(second, 'Aligned English subtitle'); + assert.equal(alassRuns, 1); + assert.equal(fs.readFileSync(englishPath, 'utf8'), originalEnglish); + } finally { + clearRetimedSecondarySubtitleCache(); + } + }); + }); + 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'); @@ -1392,7 +1559,7 @@ describe('stats server API routes', () => { }); }); - it('POST /api/stats/mine-card writes secondary subtitles to word card selection text', async () => { + it('POST /api/stats/mine-card leaves word card selection text to Yomitan glossary', async () => { await withTempDir(async (dir) => { const sourcePath = path.join(dir, 'episode.mkv'); fs.writeFileSync(sourcePath, 'fake media'); @@ -1438,7 +1605,7 @@ describe('stats server API routes', () => { 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, 'I saw a cat'); + assert.equal(updateRequest?.params?.note?.fields?.SelectionText, undefined); }); }); }); @@ -1775,6 +1942,216 @@ describe('stats server API routes', () => { }); }); + it('POST /api/stats/mine-card writes word mining sentence audio and image together', 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 () => Buffer.from('image'), + generateAnimatedImage: async () => null, + }), + ankiConnectConfig: { + url, + deck: 'Mining', + fields: { + audio: 'ExpressionAudio', + image: 'Picture', + sentence: 'Sentence', + }, + media: { + generateAudio: true, + generateImage: true, + imageType: 'static', + }, + }, + }); + + 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)); + assert.equal(body.errors, undefined); + + const updateRequest = requests.find((request) => request.action === 'updateNoteFields'); + const fields = updateRequest?.params?.note?.fields ?? {}; + assert.match(fields.SentenceAudio ?? '', /^\[sound:subminer_audio_\d+\.mp3\]$/); + assert.match(fields.Picture ?? '', /^$/); + assert.equal(fields.ExpressionAudio, undefined); + assert.equal(fields.SelectionText, undefined); + }); + }); + }); + + it('POST /api/stats/mine-card writes word mining sentence audio and animated image together', 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 () => Buffer.from('animated'), + }), + ankiConnectConfig: { + url, + deck: 'Mining', + fields: { + audio: 'ExpressionAudio', + image: 'Picture', + sentence: 'Sentence', + }, + media: { + generateAudio: true, + generateImage: true, + imageType: 'avif', + }, + }, + }); + + 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)); + assert.equal(body.errors, undefined); + + const updateRequest = requests.find((request) => request.action === 'updateNoteFields'); + const fields = updateRequest?.params?.note?.fields ?? {}; + assert.match(fields.SentenceAudio ?? '', /^\[sound:subminer_audio_\d+\.mp3\]$/); + assert.match(fields.Picture ?? '', /^$/); + assert.equal(fields.ExpressionAudio, undefined); + assert.equal(fields.SelectionText, undefined); + }); + }); + }); + + it('POST /api/stats/mine-card reports an error when requested word image generation returns no image', 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', + }, + media: { + generateAudio: true, + generateImage: true, + imageType: 'static', + }, + }, + }); + + 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)); + assert.deepEqual(body.errors, ['image: no image generated']); + }); + }); + }); + + it('POST /api/stats/mine-card reports an error when requested word audio generation returns no audio', 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 () => null, + generateScreenshot: async () => Buffer.from('image'), + generateAnimatedImage: async () => null, + }), + ankiConnectConfig: { + url, + deck: 'Mining', + fields: { + audio: 'ExpressionAudio', + image: 'Picture', + sentence: 'Sentence', + }, + media: { + generateAudio: true, + generateImage: true, + imageType: 'static', + }, + }, + }); + + 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)); + assert.deepEqual(body.errors, ['audio: no audio generated']); + }); + }); + }); + it('POST /api/stats/mine-card writes audio cards to configured audio field when note info is missing', async () => { await withTempDir(async (dir) => { const sourcePath = path.join(dir, 'episode.mkv'); diff --git a/src/core/services/immersion-tracker-service.test.ts b/src/core/services/immersion-tracker-service.test.ts index 01439d44..9e95a381 100644 --- a/src/core/services/immersion-tracker-service.test.ts +++ b/src/core/services/immersion-tracker-service.test.ts @@ -1164,6 +1164,54 @@ test('recordSubtitleLine leaves session token counts at zero when tokenization i } }); +test('recordSubtitleLine skips invalid cue timing and still stores the later valid cue', async () => { + const dbPath = makeDbPath(); + let tracker: ImmersionTrackerService | null = null; + + try { + const Ctor = await loadTrackerCtor(); + tracker = new Ctor({ dbPath }); + + tracker.handleMediaChange('/tmp/timing.mkv', 'Timing'); + tracker.recordSubtitleLine('same subtitle', 953.991, 953.891); + tracker.recordSubtitleLine('same subtitle', 953.991, 956.56); + + const privateApi = tracker as unknown as { + flushTelemetry: (force?: boolean) => void; + flushNow: () => void; + }; + privateApi.flushTelemetry(true); + privateApi.flushNow(); + + const db = new Database(dbPath); + const rows = db + .prepare( + `SELECT line_index, segment_start_ms, segment_end_ms, text + FROM imm_subtitle_lines + ORDER BY line_id ASC`, + ) + .all() as Array<{ + line_index: number; + segment_start_ms: number | null; + segment_end_ms: number | null; + text: string; + }>; + db.close(); + + assert.deepEqual(rows, [ + { + line_index: 1, + segment_start_ms: 953991, + segment_end_ms: 956560, + text: 'same subtitle', + }, + ]); + } finally { + tracker?.destroy(); + cleanupDbPath(dbPath); + } +}); + test('subtitle-line event payload omits duplicated subtitle text', async () => { const dbPath = makeDbPath(); let tracker: ImmersionTrackerService | null = null; diff --git a/src/core/services/immersion-tracker-service.ts b/src/core/services/immersion-tracker-service.ts index 5d69b570..7076ee9e 100644 --- a/src/core/services/immersion-tracker-service.ts +++ b/src/core/services/immersion-tracker-service.ts @@ -1305,7 +1305,7 @@ export class ImmersionTrackerService { const cleaned = normalizeText(text); if (!cleaned) return; - if (!endSec || endSec <= 0) { + if (!Number.isFinite(startSec) || !Number.isFinite(endSec) || endSec <= startSec) { return; } diff --git a/src/core/services/secondary-subtitle-sidecar.ts b/src/core/services/secondary-subtitle-sidecar.ts index c4b68587..ac290d81 100644 --- a/src/core/services/secondary-subtitle-sidecar.ts +++ b/src/core/services/secondary-subtitle-sidecar.ts @@ -1,12 +1,23 @@ -import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs'; +import { existsSync, mkdtempSync, readFileSync, readdirSync, rmSync, statSync } from 'node:fs'; +import os from 'node:os'; import path from 'node:path'; +import { runCommand, type CommandResult } from '../../subsync/utils'; import { parseSubtitleCues, type SubtitleCue } from './subtitle-cue-parser.js'; import { isEnglishYoutubeLang, normalizeYoutubeLangCode } from './youtube/labels.js'; const DEFAULT_SECONDARY_SUBTITLE_LANGUAGES = ['en', 'eng', 'english', 'en-us', 'enus']; +const DEFAULT_PRIMARY_SUBTITLE_LANGUAGES = ['ja', 'jpn', 'jp', 'japanese']; const SUPPORTED_SUBTITLE_EXTENSIONS = new Set(['.srt', '.vtt', '.ass', '.ssa']); const TIMING_TOLERANCE_SECONDS = 0.25; const SAME_TIMING_EPSILON_SECONDS = 0.001; +const RETIMED_SUBTITLE_TIMEOUT_MS = 30_000; +const FALLBACK_ALASS_PATHS = [ + '/opt/homebrew/bin/alass-cli', + '/opt/homebrew/bin/alass', + '/usr/local/bin/alass-cli', + '/usr/local/bin/alass', + '/usr/bin/alass', +]; type SidecarCandidate = { path: string; @@ -15,15 +26,43 @@ type SidecarCandidate = { name: string; }; +type RetimedSubtitleCacheEntry = { + path: string; + cleanupDir: string; +}; + +export type RetimedSubtitleCommandRunner = ( + alassPath: string, + referencePath: string, + inputPath: string, + outputPath: string, +) => Promise; + +export type RetimedSecondarySubtitleInput = { + sourcePath: string; + startMs: number; + endMs: number; + languages?: readonly string[]; + primaryLanguages?: readonly string[]; + alassPath?: string | null; + runAlass?: RetimedSubtitleCommandRunner; +}; + +const retimedSubtitleCache = new Map(); +let retimedSubtitleCleanupRegistered = false; + function unique(values: string[]): string[] { return values.filter((value, index) => value.length > 0 && values.indexOf(value) === index); } -function expandPreferredLanguages(languages: readonly string[] | undefined): string[] { +function expandPreferredLanguages( + languages: readonly string[] | undefined, + fallback: readonly string[], +): string[] { const normalized = unique( (languages ?? []).map((language) => normalizeYoutubeLangCode(language)).filter(Boolean), ); - const base = normalized.length > 0 ? normalized : DEFAULT_SECONDARY_SUBTITLE_LANGUAGES; + const base = normalized.length > 0 ? normalized : [...fallback]; const expanded: string[] = []; for (const language of base) { expanded.push(language); @@ -34,6 +73,105 @@ function expandPreferredLanguages(languages: readonly string[] | undefined): str return unique(expanded); } +function isExecutableFile(filePath: string): boolean { + try { + return statSync(filePath).isFile(); + } catch { + return false; + } +} + +function pathEntries(): string[] { + const entries = (process.env.PATH ?? '') + .split(path.delimiter) + .map((entry) => entry.trim()) + .filter(Boolean); + return unique([...entries, ...FALLBACK_ALASS_PATHS.map((candidate) => path.dirname(candidate))]); +} + +function executableNames(name: string): string[] { + if (process.platform !== 'win32') return [name]; + const extensions = (process.env.PATHEXT ?? '.EXE;.CMD;.BAT') + .split(';') + .map((entry) => entry.trim()) + .filter(Boolean); + if (path.extname(name)) return [name]; + return [name, ...extensions.map((extension) => `${name}${extension}`)]; +} + +function findExecutable(names: readonly string[]): string { + for (const name of names) { + if (path.dirname(name) !== '.') { + return isExecutableFile(name) ? name : ''; + } + } + + for (const dir of pathEntries()) { + for (const name of names) { + for (const executableName of executableNames(name)) { + const candidate = path.join(dir, executableName); + if (isExecutableFile(candidate)) return candidate; + } + } + } + + for (const candidate of FALLBACK_ALASS_PATHS) { + if (isExecutableFile(candidate)) return candidate; + } + + return ''; +} + +function resolveAlassPath(configuredPath: string | null | undefined): string { + const trimmed = configuredPath?.trim() ?? ''; + if (trimmed) { + return findExecutable([trimmed]); + } + return findExecutable(['alass', 'alass-cli']); +} + +function fileSignature(filePath: string): string | null { + try { + const stats = statSync(filePath); + if (!stats.isFile()) return null; + return `${stats.size}:${stats.mtimeMs}`; + } catch { + return null; + } +} + +function retimedCacheKey( + alassPath: string, + primaryPath: string, + secondaryPath: string, +): string | null { + const primarySignature = fileSignature(primaryPath); + const secondarySignature = fileSignature(secondaryPath); + if (!primarySignature || !secondarySignature) return null; + return [alassPath, primaryPath, primarySignature, secondaryPath, secondarySignature].join('\0'); +} + +function cleanupRetimedSubtitleCache(): void { + for (const entry of retimedSubtitleCache.values()) { + try { + rmSync(entry.cleanupDir, { recursive: true, force: true }); + } catch { + // Best-effort temp cleanup. + } + } + retimedSubtitleCache.clear(); +} + +function registerRetimedSubtitleCleanup(): void { + if (retimedSubtitleCleanupRegistered) return; + retimedSubtitleCleanupRegistered = true; + process.once('exit', cleanupRetimedSubtitleCache); +} + +export function clearRetimedSecondarySubtitleCache(): void { + cleanupRetimedSubtitleCache(); +} + function splitLanguageSuffix(value: string): string[] { const normalizedWhole = normalizeYoutubeLangCode(value); const tokens = value @@ -190,6 +328,64 @@ function findCueTextAtTiming(cues: SubtitleCue[], startMs: number, endMs: number return bestOverlap ? bestOverlap.cue.text.trim() : ''; } +function readCueTextAtTiming(filePath: string, startMs: number, endMs: number): string { + const content = readFileSync(filePath, 'utf8'); + const cues = parseSubtitleCues(content, filePath); + return findCueTextAtTiming(cues, startMs, endMs); +} + +async function defaultRunAlass( + alassPath: string, + referencePath: string, + inputPath: string, + outputPath: string, +): Promise { + return runCommand(alassPath, [referencePath, inputPath, outputPath], RETIMED_SUBTITLE_TIMEOUT_MS); +} + +async function retimeSecondarySubtitle(input: { + alassPath: string; + primaryPath: string; + secondaryPath: string; + runAlass: RetimedSubtitleCommandRunner; +}): Promise { + const key = retimedCacheKey(input.alassPath, input.primaryPath, input.secondaryPath); + if (!key) return ''; + + const cached = retimedSubtitleCache.get(key); + if (cached && existsSync(cached.path)) { + return cached.path; + } + if (cached) { + retimedSubtitleCache.delete(key); + try { + rmSync(cached.cleanupDir, { recursive: true, force: true }); + } catch {} + } + + registerRetimedSubtitleCleanup(); + const cleanupDir = mkdtempSync(path.join(os.tmpdir(), 'subminer-retimed-secondary-')); + const parsedSecondary = path.parse(input.secondaryPath); + const outputPath = path.join( + cleanupDir, + `${parsedSecondary.name}.retimed${parsedSecondary.ext || '.srt'}`, + ); + + const result = await input.runAlass( + input.alassPath, + input.primaryPath, + input.secondaryPath, + outputPath, + ); + if (!result.ok || !existsSync(outputPath)) { + rmSync(cleanupDir, { recursive: true, force: true }); + return ''; + } + + retimedSubtitleCache.set(key, { path: outputPath, cleanupDir }); + return outputPath; +} + export function resolveSecondarySubtitleTextFromSidecar(input: { sourcePath: string; startMs: number; @@ -207,13 +403,14 @@ export function resolveSecondarySubtitleTextFromSidecar(input: { return ''; } - const preferredLanguages = expandPreferredLanguages(input.languages); + const preferredLanguages = expandPreferredLanguages( + input.languages, + DEFAULT_SECONDARY_SUBTITLE_LANGUAGES, + ); const candidates = findSidecarSubtitleCandidates(input.sourcePath, preferredLanguages); for (const candidate of candidates) { try { - const content = readFileSync(candidate.path, 'utf8'); - const cues = parseSubtitleCues(content, candidate.path); - const text = findCueTextAtTiming(cues, input.startMs, input.endMs); + const text = readCueTextAtTiming(candidate.path, input.startMs, input.endMs); if (text) { return text; } @@ -224,3 +421,54 @@ export function resolveSecondarySubtitleTextFromSidecar(input: { return ''; } + +export async function resolveRetimedSecondarySubtitleTextFromSidecar( + input: RetimedSecondarySubtitleInput, +): Promise { + if (!input.sourcePath || !existsSync(input.sourcePath)) { + return ''; + } + try { + if (!statSync(input.sourcePath).isFile()) { + return ''; + } + } catch { + return ''; + } + + const alassPath = resolveAlassPath(input.alassPath); + if (!alassPath) return ''; + + const primaryLanguages = expandPreferredLanguages( + input.primaryLanguages, + DEFAULT_PRIMARY_SUBTITLE_LANGUAGES, + ); + const secondaryLanguages = expandPreferredLanguages( + input.languages, + DEFAULT_SECONDARY_SUBTITLE_LANGUAGES, + ); + const primaryCandidates = findSidecarSubtitleCandidates(input.sourcePath, primaryLanguages); + const secondaryCandidates = findSidecarSubtitleCandidates(input.sourcePath, secondaryLanguages); + const runAlass = input.runAlass ?? defaultRunAlass; + + for (const primary of primaryCandidates) { + for (const secondary of secondaryCandidates) { + if (primary.path === secondary.path) continue; + try { + const retimedPath = await retimeSecondarySubtitle({ + alassPath, + primaryPath: primary.path, + secondaryPath: secondary.path, + runAlass, + }); + if (!retimedPath) continue; + const text = readCueTextAtTiming(retimedPath, input.startMs, input.endMs); + if (text) return text; + } catch { + // Try the next sidecar pair. + } + } + } + + return ''; +} diff --git a/src/core/services/stats-server.ts b/src/core/services/stats-server.ts index 66ee08ef..a9a4866d 100644 --- a/src/core/services/stats-server.ts +++ b/src/core/services/stats-server.ts @@ -17,7 +17,11 @@ import { } from '../../anki-field-config.js'; import { resolveAnimatedImageLeadInSeconds } from '../../anki-integration/animated-image-sync.js'; import type { AnilistRateLimiter } from './anilist/rate-limiter.js'; -import { resolveSecondarySubtitleTextFromSidecar } from './secondary-subtitle-sidecar.js'; +import { + resolveRetimedSecondarySubtitleTextFromSidecar, + resolveSecondarySubtitleTextFromSidecar, + type RetimedSecondarySubtitleInput, +} from './secondary-subtitle-sidecar.js'; type StatsServerNoteInfo = { noteId: number; @@ -366,6 +370,11 @@ export interface StatsServerConfig { getYomitanAnkiDeckName?: () => Promise | string | null | undefined; secondarySubtitleLanguages?: string[]; getSecondarySubtitleLanguages?: () => string[] | undefined; + statsMiningAlassPath?: string; + getStatsMiningAlassPath?: () => string | null | undefined; + resolveRetimedSecondarySubtitleText?: ( + input: RetimedSecondarySubtitleInput, + ) => Promise | string; anilistRateLimiter?: AnilistRateLimiter; addYomitanNote?: (word: string) => Promise; resolveAnkiNoteId?: (noteId: number) => number; @@ -501,6 +510,11 @@ export function createStatsApp( getYomitanAnkiDeckName?: () => Promise | string | null | undefined; secondarySubtitleLanguages?: string[]; getSecondarySubtitleLanguages?: () => string[] | undefined; + statsMiningAlassPath?: string; + getStatsMiningAlassPath?: () => string | null | undefined; + resolveRetimedSecondarySubtitleText?: ( + input: RetimedSecondarySubtitleInput, + ) => Promise | string; anilistRateLimiter?: AnilistRateLimiter; addYomitanNote?: (word: string) => Promise; resolveAnkiNoteId?: (noteId: number) => number; @@ -516,6 +530,8 @@ export function createStatsApp( options?.getAnkiConnectConfig?.() ?? options?.ankiConnectConfig; const getSecondarySubtitleLanguages = (): string[] => options?.getSecondarySubtitleLanguages?.() ?? options?.secondarySubtitleLanguages ?? []; + const getStatsMiningAlassPath = (): string | null | undefined => + options?.getStatsMiningAlassPath?.() ?? options?.statsMiningAlassPath; const getEffectiveMiningDeckName = async (ankiConfig: AnkiConnectConfig): Promise => { const configuredDeckName = ankiConfig.deck?.trim() ?? ''; if (configuredDeckName) return configuredDeckName; @@ -1086,6 +1102,9 @@ export function createStatsApp( if (!sourcePath || !sentence || !Number.isFinite(startMs) || !Number.isFinite(endMs)) { return c.json({ error: 'sourcePath, sentence, startMs, and endMs are required' }, 400); } + if (endMs <= startMs) { + return c.json({ error: 'endMs must be greater than startMs' }, 400); + } if (!existsSync(sourcePath)) { return c.json({ error: 'File not found' }, 404); @@ -1095,13 +1114,35 @@ export function createStatsApp( if (!ankiConfig) { return c.json({ error: 'AnkiConnect is not configured' }, 500); } + const secondarySubtitleLanguages = getSecondarySubtitleLanguages(); + let retimedSecondaryText = ''; + if (mode === 'sentence') { + try { + retimedSecondaryText = await ( + options?.resolveRetimedSecondarySubtitleText ?? + resolveRetimedSecondarySubtitleTextFromSidecar + )({ + sourcePath, + startMs, + endMs, + languages: secondarySubtitleLanguages, + alassPath: getStatsMiningAlassPath(), + }); + } catch (error) { + statsMiningLogger.warn( + 'Failed to resolve retimed secondary subtitle for stats mining:', + error instanceof Error ? error.message : String(error), + ); + } + } const secondaryText = + retimedSecondaryText || bodySecondaryText || resolveSecondarySubtitleTextFromSidecar({ sourcePath, startMs, endMs, - languages: getSecondarySubtitleLanguages(), + languages: secondarySubtitleLanguages, }); const client = new AnkiConnectClient(ankiConfig.url ?? 'http://127.0.0.1:8765'); @@ -1250,18 +1291,20 @@ export function createStatsApp( errors.push(`image: ${(err as Error).message}`); } } + if (generateAudio && !audioBuffer && audioResult.status === 'fulfilled') { + errors.push('audio: no audio generated'); + } + if (generateImage && !imageBuffer) { + errors.push('image: no image generated'); + } const mediaFields: Record = {}; const timestamp = Date.now(); const sentenceFieldName = ankiConfig.fields?.sentence ?? 'Sentence'; - const translationFieldName = ankiConfig.fields?.translation ?? 'SelectionText'; const audioFieldName = getStatsWordMiningAudioFieldName(ankiConfig, noteInfo); const imageFieldName = ankiConfig.fields?.image ?? 'Picture'; mediaFields[sentenceFieldName] = highlightedSentence; - if (secondaryText) { - mediaFields[translationFieldName] = secondaryText; - } if (audioBuffer) { const audioFilename = `subminer_audio_${timestamp}.mp3`; @@ -1489,6 +1532,9 @@ export function startStatsServer(config: StatsServerConfig): { close: () => void getYomitanAnkiDeckName: config.getYomitanAnkiDeckName, secondarySubtitleLanguages: config.secondarySubtitleLanguages, getSecondarySubtitleLanguages: config.getSecondarySubtitleLanguages, + statsMiningAlassPath: config.statsMiningAlassPath, + getStatsMiningAlassPath: config.getStatsMiningAlassPath, + resolveRetimedSecondarySubtitleText: config.resolveRetimedSecondarySubtitleText, anilistRateLimiter: config.anilistRateLimiter, addYomitanNote: config.addYomitanNote, resolveAnkiNoteId: config.resolveAnkiNoteId, diff --git a/src/main.ts b/src/main.ts index efe66187..2ce9e91e 100644 --- a/src/main.ts +++ b/src/main.ts @@ -4471,6 +4471,7 @@ const startLocalStatsServer = (): void => { getAnkiConnectConfig: () => getResolvedConfig().ankiConnect, getYomitanAnkiDeckName: getCurrentYomitanAnkiDeckNameForRuntime, getSecondarySubtitleLanguages: () => getResolvedConfig().secondarySub.secondarySubLanguages, + getStatsMiningAlassPath: () => getResolvedConfig().subsync.alass_path, anilistRateLimiter, resolveAnkiNoteId: (noteId: number) => appState.ankiIntegration?.resolveCurrentNoteId(noteId) ?? noteId, diff --git a/src/main/runtime/mpv-client-event-bindings.test.ts b/src/main/runtime/mpv-client-event-bindings.test.ts index 52fcd4c7..74bf63f1 100644 --- a/src/main/runtime/mpv-client-event-bindings.test.ts +++ b/src/main/runtime/mpv-client-event-bindings.test.ts @@ -159,6 +159,30 @@ test('mpv subtitle timing handler runs AniList without timing tracker and passes assert.deepEqual(calls, ['immersion:line:899:901', 'post-watch:901']); }); +test('mpv subtitle timing handler skips invalid cue pairs until timing is complete', () => { + const calls: string[] = []; + const handler = createHandleMpvSubtitleTimingHandler({ + recordImmersionSubtitleLine: (text, start, end) => + calls.push(`immersion:${text}:${start}:${end}`), + hasSubtitleTimingTracker: () => true, + recordSubtitleTiming: (text, start, end) => calls.push(`timing:${text}:${start}:${end}`), + maybeRunAnilistPostWatchUpdate: async (options) => { + calls.push(`post-watch:${options?.watchedSeconds}`); + }, + logError: () => calls.push('error'), + }); + + handler({ text: 'line', start: 953.991, end: 953.891 }); + handler({ text: 'line', start: 953.991, end: 956.56 }); + + assert.deepEqual(calls, [ + 'post-watch:953.991', + 'immersion:line:953.991:956.56', + 'timing:line:953.991:956.56', + 'post-watch:956.56', + ]); +}); + test('mpv event bindings register all expected events', () => { const seenEvents: string[] = []; const bindHandlers = createBindMpvClientEventHandlers({ diff --git a/src/main/runtime/mpv-client-event-bindings.ts b/src/main/runtime/mpv-client-event-bindings.ts index 644185b5..8132c69a 100644 --- a/src/main/runtime/mpv-client-event-bindings.ts +++ b/src/main/runtime/mpv-client-event-bindings.ts @@ -72,7 +72,7 @@ export function createHandleMpvSubtitleTimingHandler(deps: { Number.isFinite(end) ? end : 0, ); const options = watchedSeconds > 0 ? { watchedSeconds } : undefined; - if (text.trim()) { + if (text.trim() && Number.isFinite(start) && Number.isFinite(end) && end > start) { deps.recordImmersionSubtitleLine(text, start, end); if (deps.hasSubtitleTimingTracker()) { deps.recordSubtitleTiming(text, start, end); diff --git a/src/stats-daemon-runner.ts b/src/stats-daemon-runner.ts index c481acb2..2e0f77e2 100644 --- a/src/stats-daemon-runner.ts +++ b/src/stats-daemon-runner.ts @@ -211,6 +211,7 @@ async function main(): Promise { }), getSecondarySubtitleLanguages: () => configService.reloadConfig().secondarySub.secondarySubLanguages, + getStatsMiningAlassPath: () => configService.reloadConfig().subsync.alass_path, addYomitanNote: async (word: string) => await invokeStatsWordHelper({ helperScriptPath: wordHelperScriptPath, diff --git a/stats/src/components/search/SearchTab.test.ts b/stats/src/components/search/SearchTab.test.ts index 34e35f41..dce21479 100644 --- a/stats/src/components/search/SearchTab.test.ts +++ b/stats/src/components/search/SearchTab.test.ts @@ -16,7 +16,7 @@ test('formatSentenceSearchMatchCountLabel uses singular label for one result', ( 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/); + assert.match(source, /buildStatsMineCardParams\(result,\s*searchedWord,\s*mode\)/); }); test('SearchTab enables headword sentence search by default and forwards the toggle', () => { diff --git a/stats/src/components/search/SearchTab.tsx b/stats/src/components/search/SearchTab.tsx index 90853e95..88b6b648 100644 --- a/stats/src/components/search/SearchTab.tsx +++ b/stats/src/components/search/SearchTab.tsx @@ -4,6 +4,7 @@ import { getSentenceSearchMineAvailability, renderSentenceWithMatches, } from '../../lib/sentence-search'; +import { buildStatsMineCardParams, getStatsMineCardError } from '../../lib/mining'; import type { SentenceSearchResult } from '../../types/stats'; const SEARCH_LIMIT = 50; @@ -100,27 +101,19 @@ export function SearchTab() { if (mode === 'sentence' ? !availability.canMineSentence : !availability.canMineWordAudio) { return; } - if (!result.sourcePath || result.segmentStartMs == null || result.segmentEndMs == null) { + const searchedWord = availability.exactMatch ? query.trim() : ''; + const params = buildStatsMineCardParams(result, searchedWord, mode); + if (!params) { 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 } })); + const response = await apiClient.mineCard(params); + const responseError = getStatsMineCardError(response); + if (responseError) { + setMineStatus((prev) => ({ ...prev, [key]: { error: responseError } })); return; } setMineStatus((prev) => ({ ...prev, [key]: { success: true } })); diff --git a/stats/src/components/vocabulary/WordDetailPanel.test.ts b/stats/src/components/vocabulary/WordDetailPanel.test.ts new file mode 100644 index 00000000..ff748585 --- /dev/null +++ b/stats/src/components/vocabulary/WordDetailPanel.test.ts @@ -0,0 +1,25 @@ +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 WORD_DETAIL_PANEL_PATH = path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + 'WordDetailPanel.tsx', +); + +test('WordDetailPanel uses the shared stats mining payload builder', () => { + const source = fs.readFileSync(WORD_DETAIL_PANEL_PATH, 'utf8'); + + assert.match(source, /buildStatsMineCardParams/); + assert.match(source, /getStatsMineCardUnavailableReason/); + assert.match(source, /buildStatsMineCardParams\(\s*occ,\s*data!\.detail\.headword,\s*mode\s*\)/); +}); + +test('WordDetailPanel shows partial media mining errors instead of silent success', () => { + const source = fs.readFileSync(WORD_DETAIL_PANEL_PATH, 'utf8'); + + assert.match(source, /getStatsMineCardError/); + assert.match(source, /const responseError = getStatsMineCardError\(result\);/); +}); diff --git a/stats/src/components/vocabulary/WordDetailPanel.tsx b/stats/src/components/vocabulary/WordDetailPanel.tsx index 93beda63..6828db5a 100644 --- a/stats/src/components/vocabulary/WordDetailPanel.tsx +++ b/stats/src/components/vocabulary/WordDetailPanel.tsx @@ -2,6 +2,11 @@ import { useRef, useState, useEffect } from 'react'; import { useWordDetail } from '../../hooks/useWordDetail'; import { apiClient } from '../../lib/api-client'; import { epochMsFromDbTimestamp, formatNumber, formatRelativeDate } from '../../lib/formatters'; +import { + buildStatsMineCardParams, + getStatsMineCardError, + getStatsMineCardUnavailableReason, +} from '../../lib/mining'; import { fullReading } from '../../lib/reading-utils'; import type { VocabularyOccurrenceEntry } from '../../types/stats'; import { PosBadge } from './pos-helpers'; @@ -135,25 +140,18 @@ export function WordDetailPanel({ occ: VocabularyOccurrenceEntry, mode: 'word' | 'sentence' | 'audio', ) => { - if (!occ.sourcePath || occ.segmentStartMs == null || occ.segmentEndMs == null) { + const params = buildStatsMineCardParams(occ, data!.detail.headword, mode); + if (!params) { return; } const key = `${occ.sessionId}-${occ.lineIndex}-${occ.segmentStartMs}-${mode}`; setMineStatus((prev) => ({ ...prev, [key]: { loading: true } })); try { - const result = await apiClient.mineCard({ - sourcePath: occ.sourcePath!, - startMs: occ.segmentStartMs!, - endMs: occ.segmentEndMs!, - sentence: occ.text, - word: data!.detail.headword, - secondaryText: occ.secondaryText, - videoTitle: occ.videoTitle, - mode, - }); - if (result.error) { - setMineStatus((prev) => ({ ...prev, [key]: { error: result.error } })); + const result = await apiClient.mineCard(params); + const responseError = getStatsMineCardError(result); + if (responseError) { + setMineStatus((prev) => ({ ...prev, [key]: { error: responseError } })); } else { setMineStatus((prev) => ({ ...prev, [key]: { success: true } })); const label = @@ -368,15 +366,7 @@ export function WordDetailPanel({ · session {occ.sessionId} {(() => { - const canMine = - !!occ.sourcePath && - occ.segmentStartMs != null && - occ.segmentEndMs != null; - const unavailableReason = canMine - ? null - : occ.sourcePath - ? 'This line is missing segment timing.' - : 'This source has no local file path.'; + const unavailableReason = getStatsMineCardUnavailableReason(occ); const baseKey = `${occ.sessionId}-${occ.lineIndex}-${occ.segmentStartMs}`; const wordStatus = mineStatus[`${baseKey}-word`]; const sentenceStatus = mineStatus[`${baseKey}-sentence`]; diff --git a/stats/src/lib/api-client.ts b/stats/src/lib/api-client.ts index 6bc9713d..f8a1d815 100644 --- a/stats/src/lib/api-client.ts +++ b/stats/src/lib/api-client.ts @@ -26,6 +26,7 @@ import type { StatsExcludedWord, StatsCoverImagesData, } from '../types/stats'; +import type { StatsMineCardParams, StatsMineCardResponse } from './mining'; import { appendCoverRetryToken } from './cover-retry'; type StatsLocationLike = Pick; @@ -234,16 +235,7 @@ export const apiClient = { body: JSON.stringify(info), }); }, - mineCard: async (params: { - sourcePath: string; - startMs: number; - endMs: number; - sentence: string; - word: string; - secondaryText?: string | null; - videoTitle: string; - mode: 'word' | 'sentence' | 'audio'; - }): Promise<{ noteId?: number; error?: string; errors?: string[] }> => { + mineCard: async (params: StatsMineCardParams): Promise => { const res = await fetch(`${BASE_URL}/api/stats/mine-card?mode=${params.mode}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, diff --git a/stats/src/lib/mining.test.ts b/stats/src/lib/mining.test.ts new file mode 100644 index 00000000..48331028 --- /dev/null +++ b/stats/src/lib/mining.test.ts @@ -0,0 +1,77 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { + buildStatsMineCardParams, + getStatsMineCardError, + getStatsMineCardUnavailableReason, +} from './mining'; +import type { SentenceSearchResult } from '../types/stats'; + +function makeResult(overrides: Partial = {}): SentenceSearchResult { + return { + animeId: null, + animeTitle: 'Little Witch Academia', + videoId: 4, + videoTitle: 'Episode 4', + sourcePath: '/tmp/lwa.mkv', + secondaryText: 'Magic is gone', + sessionId: 7, + lineIndex: 12, + segmentStartMs: 5_000, + segmentEndMs: 6_000, + text: '魔法がなくなった', + ...overrides, + }; +} + +test('buildStatsMineCardParams maps sentence result context to the shared mining payload', () => { + assert.deepEqual(buildStatsMineCardParams(makeResult(), '魔法', 'sentence'), { + sourcePath: '/tmp/lwa.mkv', + startMs: 5_000, + endMs: 6_000, + sentence: '魔法がなくなった', + word: '魔法', + secondaryText: 'Magic is gone', + videoTitle: 'Episode 4', + mode: 'sentence', + }); +}); + +test('buildStatsMineCardParams returns null when media context is incomplete', () => { + assert.equal( + buildStatsMineCardParams(makeResult({ sourcePath: null }), '魔法', 'sentence'), + null, + ); + assert.equal( + buildStatsMineCardParams(makeResult({ segmentStartMs: null }), '魔法', 'sentence'), + null, + ); + assert.equal( + buildStatsMineCardParams(makeResult({ segmentEndMs: null }), '魔法', 'sentence'), + null, + ); +}); + +test('buildStatsMineCardParams returns null when stored timing has no positive duration', () => { + assert.equal( + buildStatsMineCardParams( + makeResult({ segmentStartMs: 5_000, segmentEndMs: 4_900 }), + '魔法', + 'sentence', + ), + null, + ); + assert.equal( + getStatsMineCardUnavailableReason(makeResult({ segmentStartMs: 5_000, segmentEndMs: 5_000 })), + 'This line has invalid segment timing.', + ); +}); + +test('getStatsMineCardError surfaces partial media failures', () => { + assert.equal( + getStatsMineCardError({ noteId: 1, errors: ['audio: ffmpeg failed'] }), + 'audio: ffmpeg failed', + ); + assert.equal(getStatsMineCardError({ error: 'File not found' }), 'File not found'); + assert.equal(getStatsMineCardError({ noteId: 1 }), null); +}); diff --git a/stats/src/lib/mining.ts b/stats/src/lib/mining.ts new file mode 100644 index 00000000..d296edd0 --- /dev/null +++ b/stats/src/lib/mining.ts @@ -0,0 +1,68 @@ +import type { SentenceSearchResult } from '../types/stats'; + +export type StatsMineMode = 'word' | 'sentence' | 'audio'; + +export interface StatsMineCardParams { + sourcePath: string; + startMs: number; + endMs: number; + sentence: string; + word: string; + secondaryText?: string | null; + videoTitle: string; + mode: StatsMineMode; +} + +export interface StatsMineCardResponse { + noteId?: number; + error?: string; + errors?: string[]; +} + +export function getStatsMineCardUnavailableReason( + result: Pick, +): string | null { + if (!result.sourcePath) { + return 'This source has no local file path.'; + } + if (result.segmentStartMs == null || result.segmentEndMs == null) { + return 'This line is missing segment timing.'; + } + if ( + !Number.isFinite(result.segmentStartMs) || + !Number.isFinite(result.segmentEndMs) || + result.segmentEndMs <= result.segmentStartMs + ) { + return 'This line has invalid segment timing.'; + } + return null; +} + +export function buildStatsMineCardParams( + result: Pick< + SentenceSearchResult, + 'sourcePath' | 'segmentStartMs' | 'segmentEndMs' | 'text' | 'secondaryText' | 'videoTitle' + >, + word: string, + mode: StatsMineMode, +): StatsMineCardParams | null { + if (getStatsMineCardUnavailableReason(result)) { + return null; + } + + return { + sourcePath: result.sourcePath!, + startMs: result.segmentStartMs!, + endMs: result.segmentEndMs!, + sentence: result.text, + word, + secondaryText: result.secondaryText, + videoTitle: result.videoTitle, + mode, + }; +} + +export function getStatsMineCardError(response: StatsMineCardResponse): string | null { + if (response.error) return response.error; + return response.errors?.[0] ?? null; +} diff --git a/stats/src/lib/sentence-search.test.tsx b/stats/src/lib/sentence-search.test.tsx index 25274a0f..c0200130 100644 --- a/stats/src/lib/sentence-search.test.tsx +++ b/stats/src/lib/sentence-search.test.tsx @@ -61,6 +61,17 @@ test('getSentenceSearchMineAvailability disables every mining mode without sourc }); }); +test('getSentenceSearchMineAvailability disables every mining mode with invalid source timing', () => { + const result = makeResult({ segmentStartMs: 2500, segmentEndMs: 2400 }); + + assert.deepEqual(getSentenceSearchMineAvailability(result, '猫'), { + canMineSentence: false, + canMineWordAudio: false, + exactMatch: true, + unavailableReason: 'This line has invalid segment timing.', + }); +}); + test('renderSentenceWithMatches highlights exact searched-word matches', () => { const markup = renderToStaticMarkup(<>{renderSentenceWithMatches('猫が寝る', '猫')}); diff --git a/stats/src/lib/sentence-search.tsx b/stats/src/lib/sentence-search.tsx index 123cb883..fb13a214 100644 --- a/stats/src/lib/sentence-search.tsx +++ b/stats/src/lib/sentence-search.tsx @@ -1,5 +1,6 @@ import type { ReactNode } from 'react'; import type { SentenceSearchResult } from '../types/stats'; +import { getStatsMineCardUnavailableReason } from './mining'; export interface SentenceMatchRange { start: number; @@ -41,11 +42,7 @@ export function getSentenceSearchMineAvailability( 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; + const unavailableReason = getStatsMineCardUnavailableReason(result); return { canMineSentence: unavailableReason === null,