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
This commit is contained in:
2026-06-06 14:26:02 -07:00
parent 01c2d9eb1d
commit 99401e5a70
31 changed files with 985 additions and 123 deletions
@@ -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 } }));
@@ -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,