mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-07-29 07:21:33 -07:00
Compare commits
3 Commits
01c2d9eb1d
...
e0e9c182ee
| Author | SHA1 | Date | |
|---|---|---|---|
|
e0e9c182ee
|
|||
|
3c3bf3bb18
|
|||
|
99401e5a70
|
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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 '';
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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`];
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import {
|
||||
buildStatsMineCardParams,
|
||||
getStatsMineCardError,
|
||||
getStatsMineCardUnavailableReason,
|
||||
} from './mining';
|
||||
import type { SentenceSearchResult } from '../types/stats';
|
||||
|
||||
function makeResult(overrides: Partial<SentenceSearchResult> = {}): SentenceSearchResult {
|
||||
return {
|
||||
animeId: null,
|
||||
animeTitle: 'Little Witch Academia',
|
||||
videoId: 4,
|
||||
videoTitle: 'Episode 4',
|
||||
sourcePath: '/tmp/lwa.mkv',
|
||||
secondaryText: 'Magic is gone',
|
||||
sessionId: 7,
|
||||
lineIndex: 12,
|
||||
segmentStartMs: 5_000,
|
||||
segmentEndMs: 6_000,
|
||||
text: '魔法がなくなった',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
test('buildStatsMineCardParams maps sentence result context to the shared mining payload', () => {
|
||||
assert.deepEqual(buildStatsMineCardParams(makeResult(), '魔法', 'sentence'), {
|
||||
sourcePath: '/tmp/lwa.mkv',
|
||||
startMs: 5_000,
|
||||
endMs: 6_000,
|
||||
sentence: '魔法がなくなった',
|
||||
word: '魔法',
|
||||
secondaryText: 'Magic is gone',
|
||||
videoTitle: 'Episode 4',
|
||||
mode: 'sentence',
|
||||
});
|
||||
});
|
||||
|
||||
test('buildStatsMineCardParams returns null when media context is incomplete', () => {
|
||||
assert.equal(
|
||||
buildStatsMineCardParams(makeResult({ sourcePath: null }), '魔法', 'sentence'),
|
||||
null,
|
||||
);
|
||||
assert.equal(
|
||||
buildStatsMineCardParams(makeResult({ segmentStartMs: null }), '魔法', 'sentence'),
|
||||
null,
|
||||
);
|
||||
assert.equal(
|
||||
buildStatsMineCardParams(makeResult({ segmentEndMs: null }), '魔法', 'sentence'),
|
||||
null,
|
||||
);
|
||||
});
|
||||
|
||||
test('buildStatsMineCardParams returns null when stored timing has no positive duration', () => {
|
||||
assert.equal(
|
||||
buildStatsMineCardParams(
|
||||
makeResult({ segmentStartMs: 5_000, segmentEndMs: 4_900 }),
|
||||
'魔法',
|
||||
'sentence',
|
||||
),
|
||||
null,
|
||||
);
|
||||
assert.equal(
|
||||
getStatsMineCardUnavailableReason(makeResult({ segmentStartMs: 5_000, segmentEndMs: 5_000 })),
|
||||
'This line has invalid segment timing.',
|
||||
);
|
||||
});
|
||||
|
||||
test('getStatsMineCardError surfaces partial media failures', () => {
|
||||
assert.equal(
|
||||
getStatsMineCardError({ noteId: 1, errors: ['audio: ffmpeg failed'] }),
|
||||
'audio: ffmpeg failed',
|
||||
);
|
||||
assert.equal(getStatsMineCardError({ error: 'File not found' }), 'File not found');
|
||||
assert.equal(getStatsMineCardError({ noteId: 1 }), null);
|
||||
});
|
||||
@@ -0,0 +1,68 @@
|
||||
import type { SentenceSearchResult } from '../types/stats';
|
||||
|
||||
export type StatsMineMode = 'word' | 'sentence' | 'audio';
|
||||
|
||||
export interface StatsMineCardParams {
|
||||
sourcePath: string;
|
||||
startMs: number;
|
||||
endMs: number;
|
||||
sentence: string;
|
||||
word: string;
|
||||
secondaryText?: string | null;
|
||||
videoTitle: string;
|
||||
mode: StatsMineMode;
|
||||
}
|
||||
|
||||
export interface StatsMineCardResponse {
|
||||
noteId?: number;
|
||||
error?: string;
|
||||
errors?: string[];
|
||||
}
|
||||
|
||||
export function getStatsMineCardUnavailableReason(
|
||||
result: Pick<SentenceSearchResult, 'sourcePath' | 'segmentStartMs' | 'segmentEndMs'>,
|
||||
): string | null {
|
||||
if (!result.sourcePath) {
|
||||
return 'This source has no local file path.';
|
||||
}
|
||||
if (result.segmentStartMs == null || result.segmentEndMs == null) {
|
||||
return 'This line is missing segment timing.';
|
||||
}
|
||||
if (
|
||||
!Number.isFinite(result.segmentStartMs) ||
|
||||
!Number.isFinite(result.segmentEndMs) ||
|
||||
result.segmentEndMs <= result.segmentStartMs
|
||||
) {
|
||||
return 'This line has invalid segment timing.';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function buildStatsMineCardParams(
|
||||
result: Pick<
|
||||
SentenceSearchResult,
|
||||
'sourcePath' | 'segmentStartMs' | 'segmentEndMs' | 'text' | 'secondaryText' | 'videoTitle'
|
||||
>,
|
||||
word: string,
|
||||
mode: StatsMineMode,
|
||||
): StatsMineCardParams | null {
|
||||
if (getStatsMineCardUnavailableReason(result)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
sourcePath: result.sourcePath!,
|
||||
startMs: result.segmentStartMs!,
|
||||
endMs: result.segmentEndMs!,
|
||||
sentence: result.text,
|
||||
word,
|
||||
secondaryText: result.secondaryText,
|
||||
videoTitle: result.videoTitle,
|
||||
mode,
|
||||
};
|
||||
}
|
||||
|
||||
export function getStatsMineCardError(response: StatsMineCardResponse): string | null {
|
||||
if (response.error) return response.error;
|
||||
return response.errors?.[0] ?? null;
|
||||
}
|
||||
@@ -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('猫が寝る', '猫')}</>);
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user