Compare commits

..

3 Commits

Author SHA1 Message Date
sudacode e0e9c182ee fix(stats): detect cover MIME type, fix ankiConfig reuse, skip retiming
- Cover routes detect JPEG/PNG/WebP from magic bytes instead of hardcoding image/jpeg
- notesInfo builds previews with the same ankiConfig snapshot used for the fetch call
- Skip alass retiming when secondary text is already present in the request body
2026-06-06 15:12:07 -07:00
sudacode 3c3bf3bb18 fix(stats): persist filter prefs, fix cover MIME types, and dedup alass
- Remember Hide Known/Hide Kana filter state in localStorage across sessions
- Detect PNG and WebP cover art MIME types instead of hardcoding image/jpeg
- Use configured AnkiConnect URL for the browse action
- Deduplicate concurrent in-flight alass retime calls via promise caching
- Prefer request-provided secondary subtitle text over retimed sidecar fallback
- Fix cover image record key types from string to number
2026-06-06 14:48:12 -07:00
sudacode 99401e5a70 feat(stats): add alass sidecar retiming for sentence mining and fix timi
- Retime local English sidecars against the Japanese sidecar via alass before populating sentence card translation fields; cache retimed copies for the process lifetime
- Reject reversed or non-positive subtitle timings in immersion tracker and before media generation
- Fix word card mining so sentence audio goes to SentenceAudio (not ExpressionAudio) and English subtitle text is not written to SelectionText
- Consolidate 10 individual change fragments into stats-updates.md
2026-06-06 14:26:02 -07:00
34 changed files with 1323 additions and 148 deletions
-4
View File
@@ -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.
@@ -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.
-4
View File
@@ -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.
-4
View File
@@ -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.
@@ -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.
-4
View File
@@ -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.
-4
View File
@@ -1,4 +0,0 @@
type: fixed
area: stats
- Library card size selection is now remembered across Stats window reloads and remounts.
@@ -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.
-6
View File
@@ -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.
-4
View File
@@ -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.
+8
View File
@@ -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, stored/requested secondary subtitles are preserved before falling back to sidecar files or temporary alass-retimed English sidecars for 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 remembered Hide Known/Hide Kana filters, 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, preserving PNG/WebP cover MIME types, honoring custom AnkiConnect URLs for Browse, showing progress during session deletes, and making session deletes refresh faster.
+2
View File
@@ -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 `<name>_retimed.<ext>`. |
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.
+7 -5
View File
@@ -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
@@ -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 = [
{
@@ -946,6 +950,26 @@ describe('stats server API routes', () => {
assert.equal(res.headers.get('cache-control'), 'public, max-age=86400');
});
it('GET /api/stats/anime/:animeId/cover serves detected cover MIME type', async () => {
const app = createStatsApp(
createMockTracker({
getAnimeCoverArt: async () => ({
videoId: 1,
anilistId: 21858,
coverUrl: 'https://example.com/cover.png',
coverBlob: Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]),
titleRomaji: 'Little Witch Academia',
titleEnglish: 'Little Witch Academia',
episodesTotal: 25,
fetchedAtMs: Date.now(),
}),
}),
);
const res = await app.request('/api/stats/anime/1/cover');
assert.equal(res.status, 200);
assert.equal(res.headers.get('content-type'), 'image/png');
});
it('GET /api/stats/anime/:animeId/cover returns 404 for missing anime', async () => {
const app = createStatsApp(createMockTracker());
const res = await app.request('/api/stats/anime/99999/cover');
@@ -962,7 +986,7 @@ describe('stats server API routes', () => {
videoId,
anilistId: null,
coverUrl: null,
coverBlob: Buffer.from([0x89, 0x50]),
coverBlob: Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]),
titleRomaji: null,
titleEnglish: null,
episodesTotal: null,
@@ -993,8 +1017,8 @@ describe('stats server API routes', () => {
},
media: {
7: {
contentType: 'image/jpeg',
dataUrl: 'data:image/jpeg;base64,iVA=',
contentType: 'image/png',
dataUrl: 'data:image/png;base64,iVBORw0KGgo=',
},
99999: null,
},
@@ -1154,6 +1178,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 +1385,198 @@ describe('stats server API routes', () => {
});
});
it('POST /api/stats/mine-card prefers request secondary text over retimed fallback', async () => {
await withTempDir(async (dir) => {
const sourcePath = path.join(dir, 'episode.mkv');
fs.writeFileSync(sourcePath, 'fake media');
await withFakeAnkiConnect(async (requests, url) => {
let retimedCalls = 0;
const options = {
resolveRetimedSecondarySubtitleText: async () => {
retimedCalls += 1;
return '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<typeof createStatsApp>[1] & {
resolveRetimedSecondarySubtitleText: () => Promise<string>;
};
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,
'Stale stored English subtitle',
);
assert.equal(retimedCalls, 0);
});
});
});
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('shares in-flight retimed secondary subtitle work for concurrent requests', 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');
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,
`1
00:00:09,000 --> 00:00:10,000
Stale English subtitle
`,
);
let alassRuns = 0;
let releaseAlass!: () => void;
const alassGate = new Promise<void>((resolve) => {
releaseAlass = resolve;
});
const input = {
sourcePath,
startMs: 1_000,
endMs: 2_000,
alassPath,
runAlass: async (
_alassPath: string,
_referencePath: string,
_inputPath: string,
outputPath: string,
) => {
alassRuns += 1;
await alassGate;
fs.writeFileSync(
outputPath,
`1
00:00:01,000 --> 00:00:02,000
Aligned English subtitle
`,
);
return { ok: true, code: 0, stdout: '', stderr: '' };
},
};
try {
const first = resolveRetimedSecondarySubtitleTextFromSidecar(input);
const second = resolveRetimedSecondarySubtitleTextFromSidecar(input);
releaseAlass();
assert.deepEqual(await Promise.all([first, second]), [
'Aligned English subtitle',
'Aligned English subtitle',
]);
assert.equal(alassRuns, 1);
} 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 +1652,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 +1698,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, '<b>猫</b>を見た');
assert.equal(updateRequest?.params?.note?.fields?.SelectionText, 'I saw a cat');
assert.equal(updateRequest?.params?.note?.fields?.SelectionText, undefined);
});
});
});
@@ -1775,6 +2035,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 ?? '', /^<img src="subminer_image_\d+\.jpg">$/);
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 ?? '', /^<img src="subminer_image_\d+\.avif">$/);
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');
@@ -2010,6 +2480,20 @@ describe('stats server API routes', () => {
assert.equal(res.status, 400);
});
it('POST /api/stats/anki/browse uses configured AnkiConnect URL', async () => {
await withFakeAnkiConnect(async (requests, url) => {
const app = createStatsApp(createMockTracker(), {
ankiConnectConfig: { url },
});
const res = await app.request('/api/stats/anki/browse?noteId=12345', { method: 'POST' });
assert.equal(res.status, 200);
assert.equal(requests[0]?.action, 'guiBrowse');
assert.deepEqual(requests[0]?.params, { query: 'nid:12345' });
});
});
it('GET /api/stats/anilist/search uses the configured AniList rate limiter', async () => {
const originalFetch = globalThis.fetch;
let acquireCalls = 0;
@@ -2175,6 +2659,52 @@ describe('stats server API routes', () => {
}
});
it('POST /api/stats/anki/notesInfo builds previews with the same config used for fetch', async () => {
const originalFetch = globalThis.fetch;
globalThis.fetch = (async () =>
new Response(
JSON.stringify({
result: [
{
noteId: 444,
fields: {
TargetWord: { value: '一致' },
OtherWord: { value: '不一致' },
},
},
],
}),
{
status: 200,
headers: { 'Content-Type': 'application/json' },
},
)) as typeof fetch;
try {
let configCalls = 0;
const app = createStatsApp(createMockTracker(), {
getAnkiConnectConfig: () => {
configCalls += 1;
return {
url: 'http://127.0.0.1:8765',
fields: { word: configCalls === 1 ? 'TargetWord' : 'OtherWord' },
};
},
});
const res = await app.request('/api/stats/anki/notesInfo', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ noteIds: [444] }),
});
assert.equal(res.status, 200);
assert.equal(configCalls, 1);
assert.equal((await res.json())[0].preview.word, '一致');
} finally {
globalThis.fetch = originalFetch;
}
});
it('serves stats index and asset files from absolute static dir paths', async () => {
await withTempDir(async (dir) => {
const assetDir = path.join(dir, 'assets');
@@ -2208,7 +2738,9 @@ describe('stats server API routes', () => {
videoId: 1,
anilistId: 1,
coverUrl: 'https://example.com/cover.jpg',
coverBlob: Buffer.from([0xff, 0xd8, 0xff, 0xd9]),
coverBlob: Buffer.from([
0x52, 0x49, 0x46, 0x46, 0x00, 0x00, 0x00, 0x00, 0x57, 0x45, 0x42, 0x50,
]),
titleRomaji: 'Test',
titleEnglish: 'Test',
episodesTotal: 12,
@@ -2225,7 +2757,7 @@ describe('stats server API routes', () => {
const res = await app.request('/api/stats/media/1/cover');
assert.equal(res.status, 200);
assert.equal(res.headers.get('content-type'), 'image/jpeg');
assert.equal(res.headers.get('content-type'), 'image/webp');
assert.equal(ensureCalls, 1);
});
@@ -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;
@@ -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;
}
+265 -7
View File
@@ -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,44 @@ type SidecarCandidate = {
name: string;
};
type RetimedSubtitleCacheEntry = {
path: string;
cleanupDir: string;
promise?: Promise<string>;
};
export type RetimedSubtitleCommandRunner = (
alassPath: string,
referencePath: string,
inputPath: string,
outputPath: string,
) => Promise<CommandResult>;
export type RetimedSecondarySubtitleInput = {
sourcePath: string;
startMs: number;
endMs: number;
languages?: readonly string[];
primaryLanguages?: readonly string[];
alassPath?: string | null;
runAlass?: RetimedSubtitleCommandRunner;
};
const retimedSubtitleCache = new Map<string, RetimedSubtitleCacheEntry>();
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 +74,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 +329,73 @@ 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<CommandResult> {
return runCommand(alassPath, [referencePath, inputPath, outputPath], RETIMED_SUBTITLE_TIMEOUT_MS);
}
async function retimeSecondarySubtitle(input: {
alassPath: string;
primaryPath: string;
secondaryPath: string;
runAlass: RetimedSubtitleCommandRunner;
}): Promise<string> {
const key = retimedCacheKey(input.alassPath, input.primaryPath, input.secondaryPath);
if (!key) return '';
const cached = retimedSubtitleCache.get(key);
if (cached?.promise) {
return cached.promise;
}
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 entry: RetimedSubtitleCacheEntry = { path: outputPath, cleanupDir };
entry.promise = input
.runAlass(input.alassPath, input.primaryPath, input.secondaryPath, outputPath)
.then((result) => {
if (!result.ok || !existsSync(outputPath)) {
rmSync(cleanupDir, { recursive: true, force: true });
retimedSubtitleCache.delete(key);
return '';
}
entry.promise = undefined;
return outputPath;
})
.catch(() => {
rmSync(cleanupDir, { recursive: true, force: true });
retimedSubtitleCache.delete(key);
return '';
});
retimedSubtitleCache.set(key, entry);
return entry.promise;
}
export function resolveSecondarySubtitleTextFromSidecar(input: {
sourcePath: string;
startMs: number;
@@ -207,13 +413,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 +431,54 @@ export function resolveSecondarySubtitleTextFromSidecar(input: {
return '';
}
export async function resolveRetimedSecondarySubtitleTextFromSidecar(
input: RetimedSecondarySubtitleInput,
): Promise<string> {
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 '';
}
+99 -19
View File
@@ -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;
@@ -48,7 +52,7 @@ type StatsExcludedWordPayload = {
};
type StatsCoverImagePayload = {
contentType: 'image/jpeg';
contentType: string;
dataUrl: string;
} | null;
@@ -122,12 +126,43 @@ function coverImagePayload(
art: { coverBlob?: Uint8Array | null } | null | undefined,
): StatsCoverImagePayload {
if (!art?.coverBlob) return null;
const bytes = new Uint8Array(art.coverBlob);
const contentType = detectImageContentType(bytes);
return {
contentType: 'image/jpeg',
dataUrl: `data:image/jpeg;base64,${Buffer.from(art.coverBlob).toString('base64')}`,
contentType,
dataUrl: `data:${contentType};base64,${Buffer.from(bytes).toString('base64')}`,
};
}
function detectImageContentType(bytes: Uint8Array): string {
if (
bytes.length >= 8 &&
bytes[0] === 0x89 &&
bytes[1] === 0x50 &&
bytes[2] === 0x4e &&
bytes[3] === 0x47
) {
return 'image/png';
}
if (bytes.length >= 3 && bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff) {
return 'image/jpeg';
}
if (
bytes.length >= 12 &&
bytes[0] === 0x52 &&
bytes[1] === 0x49 &&
bytes[2] === 0x46 &&
bytes[3] === 0x46 &&
bytes[8] === 0x57 &&
bytes[9] === 0x45 &&
bytes[10] === 0x42 &&
bytes[11] === 0x50
) {
return 'image/webp';
}
return 'application/octet-stream';
}
function resolveStatsNoteFieldName(
noteInfo: StatsServerNoteInfo,
...preferredNames: (string | undefined)[]
@@ -366,6 +401,11 @@ export interface StatsServerConfig {
getYomitanAnkiDeckName?: () => Promise<string | null | undefined> | string | null | undefined;
secondarySubtitleLanguages?: string[];
getSecondarySubtitleLanguages?: () => string[] | undefined;
statsMiningAlassPath?: string;
getStatsMiningAlassPath?: () => string | null | undefined;
resolveRetimedSecondarySubtitleText?: (
input: RetimedSecondarySubtitleInput,
) => Promise<string> | string;
anilistRateLimiter?: AnilistRateLimiter;
addYomitanNote?: (word: string) => Promise<number | null>;
resolveAnkiNoteId?: (noteId: number) => number;
@@ -501,6 +541,11 @@ export function createStatsApp(
getYomitanAnkiDeckName?: () => Promise<string | null | undefined> | string | null | undefined;
secondarySubtitleLanguages?: string[];
getSecondarySubtitleLanguages?: () => string[] | undefined;
statsMiningAlassPath?: string;
getStatsMiningAlassPath?: () => string | null | undefined;
resolveRetimedSecondarySubtitleText?: (
input: RetimedSecondarySubtitleInput,
) => Promise<string> | string;
anilistRateLimiter?: AnilistRateLimiter;
addYomitanNote?: (word: string) => Promise<number | null>;
resolveAnkiNoteId?: (noteId: number) => number;
@@ -516,6 +561,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<string> => {
const configuredDeckName = ankiConfig.deck?.trim() ?? '';
if (configuredDeckName) return configuredDeckName;
@@ -944,17 +991,17 @@ export function createStatsApp(
const body = (await c.req.json().catch(() => null)) as StatsCoverBatchBody | null;
const animeIds = parsePositiveIdList(body?.animeIds);
const videoIds = parsePositiveIdList(body?.videoIds);
const anime: Record<string, StatsCoverImagePayload> = {};
const media: Record<string, StatsCoverImagePayload> = {};
const anime: Record<number, StatsCoverImagePayload> = {};
const media: Record<number, StatsCoverImagePayload> = {};
await Promise.all(
animeIds.map(async (animeId) => {
anime[String(animeId)] = coverImagePayload(await tracker.getAnimeCoverArt(animeId));
anime[animeId] = coverImagePayload(await tracker.getAnimeCoverArt(animeId));
}),
);
await Promise.all(
videoIds.map(async (videoId) => {
media[String(videoId)] = coverImagePayload(await tracker.getCoverArt(videoId));
media[videoId] = coverImagePayload(await tracker.getCoverArt(videoId));
}),
);
@@ -966,9 +1013,10 @@ export function createStatsApp(
if (animeId <= 0) return c.body(null, 404);
const art = await tracker.getAnimeCoverArt(animeId);
if (!art?.coverBlob) return c.body(null, 404);
return new Response(new Uint8Array(art.coverBlob), {
const bytes = new Uint8Array(art.coverBlob);
return new Response(bytes, {
headers: {
'Content-Type': 'image/jpeg',
'Content-Type': detectImageContentType(bytes),
'Cache-Control': 'public, max-age=86400',
},
});
@@ -983,9 +1031,10 @@ export function createStatsApp(
art = await tracker.getCoverArt(videoId);
}
if (!art?.coverBlob) return c.body(null, 404);
return new Response(new Uint8Array(art.coverBlob), {
const bytes = new Uint8Array(art.coverBlob);
return new Response(bytes, {
headers: {
'Content-Type': 'image/jpeg',
'Content-Type': detectImageContentType(bytes),
'Cache-Control': 'public, max-age=604800',
},
});
@@ -1008,8 +1057,9 @@ export function createStatsApp(
app.post('/api/stats/anki/browse', async (c) => {
const noteId = parseIntQuery(c.req.query('noteId'), 0);
if (noteId <= 0) return c.body(null, 400);
const ankiConfig = getAnkiConnectConfig();
try {
const response = await fetch('http://127.0.0.1:8765', {
const response = await fetch(ankiConfig?.url ?? 'http://127.0.0.1:8765', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
signal: AbortSignal.timeout(ANKI_CONNECT_FETCH_TIMEOUT_MS),
@@ -1062,7 +1112,7 @@ export function createStatsApp(
return c.json(
(result.result ?? []).map((note) => ({
...note,
preview: buildAnkiNotePreview(note.fields, getAnkiConnectConfig()),
preview: buildAnkiNotePreview(note.fields, ankiConfig),
})),
);
} catch {
@@ -1086,6 +1136,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 +1148,35 @@ export function createStatsApp(
if (!ankiConfig) {
return c.json({ error: 'AnkiConnect is not configured' }, 500);
}
const secondarySubtitleLanguages = getSecondarySubtitleLanguages();
let retimedSecondaryText = '';
if (mode === 'sentence' && !bodySecondaryText) {
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 =
bodySecondaryText ||
retimedSecondaryText ||
resolveSecondarySubtitleTextFromSidecar({
sourcePath,
startMs,
endMs,
languages: getSecondarySubtitleLanguages(),
languages: secondarySubtitleLanguages,
});
const client = new AnkiConnectClient(ankiConfig.url ?? 'http://127.0.0.1:8765');
@@ -1250,18 +1325,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<string, string> = {};
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 +1566,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,
+1
View File
@@ -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,
@@ -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({
@@ -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);
+1
View File
@@ -211,6 +211,7 @@ async function main(): Promise<void> {
}),
getSecondarySubtitleLanguages: () =>
configService.reloadConfig().secondarySub.secondarySubLanguages,
getStatsMiningAlassPath: () => configService.reloadConfig().subsync.alass_path,
addYomitanNote: async (word: string) =>
await invokeStatsWordHelper({
helperScriptPath: wordHelperScriptPath,
@@ -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', () => {
+8 -15
View File
@@ -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 } }));
@@ -24,6 +24,46 @@ function makeEntry(over: Partial<VocabularyEntry>): VocabularyEntry {
} as VocabularyEntry;
}
function withLocalStorage<T>(initial: Record<string, string>, run: () => T): T {
const previous = Object.getOwnPropertyDescriptor(globalThis, 'localStorage');
const values = new Map(Object.entries(initial));
const storage = {
get length() {
return values.size;
},
clear() {
values.clear();
},
getItem(key: string) {
return values.get(key) ?? null;
},
key(index: number) {
return Array.from(values.keys())[index] ?? null;
},
removeItem(key: string) {
values.delete(key);
},
setItem(key: string, value: string) {
values.set(key, value);
},
} as Storage;
Object.defineProperty(globalThis, 'localStorage', {
configurable: true,
value: storage,
});
try {
return run();
} finally {
if (previous) {
Object.defineProperty(globalThis, 'localStorage', previous);
} else {
delete (globalThis as { localStorage?: unknown }).localStorage;
}
}
}
test('renders headword and reading inline in a single column (no separate Reading header)', () => {
const entry = makeEntry({});
const markup = renderToStaticMarkup(
@@ -84,3 +124,40 @@ test('renders a Hide Kana filter button', () => {
);
assert.match(markup, /Hide Kana/);
});
test('uses saved Hide Kana preference on first render', () => {
const markup = withLocalStorage({ 'subminer.stats.frequencyRank.hideKanaOnly': 'true' }, () =>
renderToStaticMarkup(
<FrequencyRankTable
words={[
makeEntry({ wordId: 1, headword: 'さらに', word: 'さらに', frequencyRank: 10 }),
makeEntry({
wordId: 2,
headword: '前に',
word: '前に',
reading: 'まえに',
frequencyRank: 20,
}),
]}
knownWords={new Set()}
/>,
),
);
assert.doesNotMatch(markup, />さらに</);
assert.match(markup, />前に</);
});
test('uses saved Hide Known preference on first render', () => {
const markup = withLocalStorage({ 'subminer.stats.frequencyRank.hideKnown': 'false' }, () =>
renderToStaticMarkup(
<FrequencyRankTable
words={[makeEntry({ headword: '日本語', word: '日本語', frequencyRank: 10 })]}
knownWords={new Set(['日本語'])}
/>,
),
);
assert.match(markup, />Most Common Words Seen</);
assert.match(markup, />日本語</);
});
@@ -10,6 +10,8 @@ interface FrequencyRankTableProps {
}
const PAGE_SIZE = 25;
const HIDE_KNOWN_STORAGE_KEY = 'subminer.stats.frequencyRank.hideKnown';
const HIDE_KANA_ONLY_STORAGE_KEY = 'subminer.stats.frequencyRank.hideKanaOnly';
interface FrequencyRankOptions {
hideKnown: boolean;
@@ -31,6 +33,33 @@ function isKanaOnlyWord(w: VocabularyEntry): boolean {
return isKanaOnlyTokenText(w.headword || w.word);
}
function getPreferenceStorage(): Storage | null {
try {
return globalThis.localStorage ?? null;
} catch {
return null;
}
}
function readBooleanPreference(key: string, fallback: boolean): boolean {
try {
const value = getPreferenceStorage()?.getItem(key);
if (value === 'true') return true;
if (value === 'false') return false;
return fallback;
} catch {
return fallback;
}
}
function writeBooleanPreference(key: string, value: boolean): void {
try {
getPreferenceStorage()?.setItem(key, String(value));
} catch {
// Storage can be blocked in private/restricted contexts; keep the in-memory choice.
}
}
export function buildFrequencyRankRows(
words: VocabularyEntry[],
knownWords: Set<string>,
@@ -70,8 +99,12 @@ export function buildFrequencyRankRows(
export function FrequencyRankTable({ words, knownWords, onSelectWord }: FrequencyRankTableProps) {
const [page, setPage] = useState(0);
const [hideKnown, setHideKnown] = useState(true);
const [hideKanaOnly, setHideKanaOnly] = useState(false);
const [hideKnown, setHideKnown] = useState(() =>
readBooleanPreference(HIDE_KNOWN_STORAGE_KEY, true),
);
const [hideKanaOnly, setHideKanaOnly] = useState(() =>
readBooleanPreference(HIDE_KANA_ONLY_STORAGE_KEY, false),
);
const [collapsed, setCollapsed] = useState(false);
const hasKnownData = knownWords.size > 0;
@@ -116,7 +149,9 @@ export function FrequencyRankTable({ words, knownWords, onSelectWord }: Frequenc
type="button"
aria-pressed={hideKnown}
onClick={() => {
setHideKnown(!hideKnown);
const next = !hideKnown;
setHideKnown(next);
writeBooleanPreference(HIDE_KNOWN_STORAGE_KEY, next);
setPage(0);
}}
className={`px-2.5 py-1 rounded-lg text-xs transition-colors border ${
@@ -132,7 +167,9 @@ export function FrequencyRankTable({ words, knownWords, onSelectWord }: Frequenc
type="button"
aria-pressed={hideKanaOnly}
onClick={() => {
setHideKanaOnly(!hideKanaOnly);
const next = !hideKanaOnly;
setHideKanaOnly(next);
writeBooleanPreference(HIDE_KANA_ONLY_STORAGE_KEY, next);
setPage(0);
}}
className={`px-2.5 py-1 rounded-lg text-xs transition-colors border ${
@@ -150,7 +187,7 @@ export function FrequencyRankTable({ words, knownWords, onSelectWord }: Frequenc
<div className="text-xs text-ctp-overlay2 mt-3">
{hideKnown && hasKnownData && !hideKanaOnly
? 'All ranked words are already in Anki!'
: hideKnown || hideKanaOnly
: (hideKnown && hasKnownData) || hideKanaOnly
? 'No ranked words match the active filters.'
: 'No words with frequency data.'}
</div>
@@ -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\);/);
});
@@ -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}
</span>
{(() => {
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`];
+2 -10
View File
@@ -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<Location, 'protocol' | 'origin' | 'search'>;
@@ -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<StatsMineCardResponse> => {
const res = await fetch(`${BASE_URL}/api/stats/mine-card?mode=${params.mode}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
+77
View File
@@ -0,0 +1,77 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
buildStatsMineCardParams,
getStatsMineCardError,
getStatsMineCardUnavailableReason,
} from './mining';
import type { SentenceSearchResult } from '../types/stats';
function makeResult(overrides: Partial<SentenceSearchResult> = {}): SentenceSearchResult {
return {
animeId: null,
animeTitle: 'Little Witch Academia',
videoId: 4,
videoTitle: 'Episode 4',
sourcePath: '/tmp/lwa.mkv',
secondaryText: 'Magic is gone',
sessionId: 7,
lineIndex: 12,
segmentStartMs: 5_000,
segmentEndMs: 6_000,
text: '魔法がなくなった',
...overrides,
};
}
test('buildStatsMineCardParams maps sentence result context to the shared mining payload', () => {
assert.deepEqual(buildStatsMineCardParams(makeResult(), '魔法', 'sentence'), {
sourcePath: '/tmp/lwa.mkv',
startMs: 5_000,
endMs: 6_000,
sentence: '魔法がなくなった',
word: '魔法',
secondaryText: 'Magic is gone',
videoTitle: 'Episode 4',
mode: 'sentence',
});
});
test('buildStatsMineCardParams returns null when media context is incomplete', () => {
assert.equal(
buildStatsMineCardParams(makeResult({ sourcePath: null }), '魔法', 'sentence'),
null,
);
assert.equal(
buildStatsMineCardParams(makeResult({ segmentStartMs: null }), '魔法', 'sentence'),
null,
);
assert.equal(
buildStatsMineCardParams(makeResult({ segmentEndMs: null }), '魔法', 'sentence'),
null,
);
});
test('buildStatsMineCardParams returns null when stored timing has no positive duration', () => {
assert.equal(
buildStatsMineCardParams(
makeResult({ segmentStartMs: 5_000, segmentEndMs: 4_900 }),
'魔法',
'sentence',
),
null,
);
assert.equal(
getStatsMineCardUnavailableReason(makeResult({ segmentStartMs: 5_000, segmentEndMs: 5_000 })),
'This line has invalid segment timing.',
);
});
test('getStatsMineCardError surfaces partial media failures', () => {
assert.equal(
getStatsMineCardError({ noteId: 1, errors: ['audio: ffmpeg failed'] }),
'audio: ffmpeg failed',
);
assert.equal(getStatsMineCardError({ error: 'File not found' }), 'File not found');
assert.equal(getStatsMineCardError({ noteId: 1 }), null);
});
+68
View File
@@ -0,0 +1,68 @@
import type { SentenceSearchResult } from '../types/stats';
export type StatsMineMode = 'word' | 'sentence' | 'audio';
export interface StatsMineCardParams {
sourcePath: string;
startMs: number;
endMs: number;
sentence: string;
word: string;
secondaryText?: string | null;
videoTitle: string;
mode: StatsMineMode;
}
export interface StatsMineCardResponse {
noteId?: number;
error?: string;
errors?: string[];
}
export function getStatsMineCardUnavailableReason(
result: Pick<SentenceSearchResult, 'sourcePath' | 'segmentStartMs' | 'segmentEndMs'>,
): string | null {
if (!result.sourcePath) {
return 'This source has no local file path.';
}
if (result.segmentStartMs == null || result.segmentEndMs == null) {
return 'This line is missing segment timing.';
}
if (
!Number.isFinite(result.segmentStartMs) ||
!Number.isFinite(result.segmentEndMs) ||
result.segmentEndMs <= result.segmentStartMs
) {
return 'This line has invalid segment timing.';
}
return null;
}
export function buildStatsMineCardParams(
result: Pick<
SentenceSearchResult,
'sourcePath' | 'segmentStartMs' | 'segmentEndMs' | 'text' | 'secondaryText' | 'videoTitle'
>,
word: string,
mode: StatsMineMode,
): StatsMineCardParams | null {
if (getStatsMineCardUnavailableReason(result)) {
return null;
}
return {
sourcePath: result.sourcePath!,
startMs: result.segmentStartMs!,
endMs: result.segmentEndMs!,
sentence: result.text,
word,
secondaryText: result.secondaryText,
videoTitle: result.videoTitle,
mode,
};
}
export function getStatsMineCardError(response: StatsMineCardResponse): string | null {
if (response.error) return response.error;
return response.errors?.[0] ?? null;
}
+11
View File
@@ -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('猫が寝る', '猫')}</>);
+2 -5
View File
@@ -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,
+2 -2
View File
@@ -88,8 +88,8 @@ export interface StatsCoverImage {
}
export interface StatsCoverImagesData {
anime: Record<string, StatsCoverImage | null>;
media: Record<string, StatsCoverImage | null>;
anime: Record<number, StatsCoverImage | null>;
media: Record<number, StatsCoverImage | null>;
}
export interface KanjiEntry {