mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-08-01 19:21:34 -07:00
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:
@@ -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 } }));
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user