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
@@ -7,6 +7,10 @@ import path from 'node:path';
import type { AddressInfo } from 'node:net';
import { createStatsApp, startStatsServer } from '../stats-server.js';
import type { ImmersionTrackerService } from '../immersion-tracker-service.js';
import {
clearRetimedSecondarySubtitleCache,
resolveRetimedSecondarySubtitleTextFromSidecar,
} from '../secondary-subtitle-sidecar.js';
const SESSION_SUMMARIES = [
{
@@ -1154,6 +1158,50 @@ describe('stats server API routes', () => {
assert.ok(Array.isArray(body));
});
it('POST /api/stats/mine-card rejects non-positive source timing before media generation', async () => {
await withTempDir(async (dir) => {
const sourcePath = path.join(dir, 'episode.mkv');
fs.writeFileSync(sourcePath, 'fake media');
let generatedAudio = false;
const app = createStatsApp(createMockTracker(), {
createMediaGenerator: () => ({
generateAudio: async () => {
generatedAudio = true;
return Buffer.from('audio');
},
generateScreenshot: async () => Buffer.from('image'),
generateAnimatedImage: async () => null,
}),
ankiConnectConfig: {
deck: 'Mining',
media: {
generateAudio: true,
generateImage: true,
},
},
});
const res = await app.request('/api/stats/mine-card?mode=sentence', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
sourcePath,
startMs: 953_991,
endMs: 953_891,
sentence: '猫を見た',
word: '猫',
videoTitle: 'Episode 1',
}),
});
const body = await res.json();
assert.equal(res.status, 400, JSON.stringify(body));
assert.deepEqual(body, { error: 'endMs must be greater than startMs' });
assert.equal(generatedAudio, false);
});
});
it('POST /api/stats/mine-card falls back to Default deck for empty deck config', async () => {
await withTempDir(async (dir) => {
const sourcePath = path.join(dir, 'episode.mkv');
@@ -1317,6 +1365,125 @@ describe('stats server API routes', () => {
});
});
it('POST /api/stats/mine-card prefers retimed sidecar secondary text for sentence cards', async () => {
await withTempDir(async (dir) => {
const sourcePath = path.join(dir, 'episode.mkv');
fs.writeFileSync(sourcePath, 'fake media');
await withFakeAnkiConnect(async (requests, url) => {
const options = {
resolveRetimedSecondarySubtitleText: async () => 'Aligned English subtitle',
ankiConnectConfig: {
url,
deck: 'Mining',
fields: {
sentence: 'Sentence',
translation: 'SelectionText',
},
media: {
generateAudio: false,
generateImage: false,
},
isLapis: {
enabled: true,
sentenceCardModel: 'Lapis Morph',
},
},
} as Parameters<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,
'Aligned English subtitle',
);
});
});
});
it('retimes secondary sidecar subtitles against the Japanese sidecar and caches the output', async () => {
await withTempDir(async (dir) => {
const sourcePath = path.join(dir, 'episode.mkv');
const japanesePath = path.join(dir, 'episode.ja.srt');
const englishPath = path.join(dir, 'episode.en.srt');
const alassPath = path.join(dir, 'alass-cli');
const originalEnglish = `1
00:00:09,000 --> 00:00:10,000
Stale English subtitle
`;
fs.writeFileSync(sourcePath, 'fake media');
fs.writeFileSync(alassPath, 'fake alass');
fs.writeFileSync(
japanesePath,
`1
00:00:01,000 --> 00:00:02,000
猫を見た
`,
);
fs.writeFileSync(englishPath, originalEnglish);
let alassRuns = 0;
try {
const first = await resolveRetimedSecondarySubtitleTextFromSidecar({
sourcePath,
startMs: 1_000,
endMs: 2_000,
alassPath,
runAlass: async (_alassPath, referencePath, inputPath, outputPath) => {
alassRuns += 1;
assert.equal(referencePath, japanesePath);
assert.equal(inputPath, englishPath);
fs.writeFileSync(
outputPath,
`1
00:00:01,000 --> 00:00:02,000
Aligned English subtitle
`,
);
return { ok: true, code: 0, stdout: '', stderr: '' };
},
});
const second = await resolveRetimedSecondarySubtitleTextFromSidecar({
sourcePath,
startMs: 1_000,
endMs: 2_000,
alassPath,
runAlass: async () => {
alassRuns += 1;
return { ok: false, code: 1, stdout: '', stderr: 'should use cache' };
},
});
assert.equal(first, 'Aligned English subtitle');
assert.equal(second, 'Aligned English subtitle');
assert.equal(alassRuns, 1);
assert.equal(fs.readFileSync(englishPath, 'utf8'), originalEnglish);
} finally {
clearRetimedSecondarySubtitleCache();
}
});
});
it('POST /api/stats/mine-card adds direct sentence cards before slow media finishes', async () => {
await withTempDir(async (dir) => {
const sourcePath = path.join(dir, 'episode.mkv');
@@ -1392,7 +1559,7 @@ describe('stats server API routes', () => {
});
});
it('POST /api/stats/mine-card writes secondary subtitles to word card selection text', async () => {
it('POST /api/stats/mine-card leaves word card selection text to Yomitan glossary', async () => {
await withTempDir(async (dir) => {
const sourcePath = path.join(dir, 'episode.mkv');
fs.writeFileSync(sourcePath, 'fake media');
@@ -1438,7 +1605,7 @@ describe('stats server API routes', () => {
const updateRequest = requests.find((request) => request.action === 'updateNoteFields');
assert.equal(updateRequest?.params?.note?.id, 777);
assert.equal(updateRequest?.params?.note?.fields?.Sentence, '<b>猫</b>を見た');
assert.equal(updateRequest?.params?.note?.fields?.SelectionText, 'I saw a cat');
assert.equal(updateRequest?.params?.note?.fields?.SelectionText, undefined);
});
});
});
@@ -1775,6 +1942,216 @@ describe('stats server API routes', () => {
});
});
it('POST /api/stats/mine-card writes word mining sentence audio and image together', async () => {
await withTempDir(async (dir) => {
const sourcePath = path.join(dir, 'episode.mkv');
fs.writeFileSync(sourcePath, 'fake media');
await withFakeAnkiConnect(async (requests, url) => {
const app = createStatsApp(createMockTracker(), {
addYomitanNote: async () => 777,
createMediaGenerator: () => ({
generateAudio: async () => Buffer.from('audio'),
generateScreenshot: async () => Buffer.from('image'),
generateAnimatedImage: async () => null,
}),
ankiConnectConfig: {
url,
deck: 'Mining',
fields: {
audio: 'ExpressionAudio',
image: 'Picture',
sentence: 'Sentence',
},
media: {
generateAudio: true,
generateImage: true,
imageType: 'static',
},
},
});
const res = await app.request('/api/stats/mine-card?mode=word', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
sourcePath,
startMs: 1_000,
endMs: 2_000,
sentence: '猫を見た',
word: '猫',
videoTitle: 'Episode 1',
}),
});
const body = await res.json();
assert.equal(res.status, 200, JSON.stringify(body));
assert.equal(body.errors, undefined);
const updateRequest = requests.find((request) => request.action === 'updateNoteFields');
const fields = updateRequest?.params?.note?.fields ?? {};
assert.match(fields.SentenceAudio ?? '', /^\[sound:subminer_audio_\d+\.mp3\]$/);
assert.match(fields.Picture ?? '', /^<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');
@@ -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;
}
+255 -7
View File
@@ -1,12 +1,23 @@
import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
import { existsSync, mkdtempSync, readFileSync, readdirSync, rmSync, statSync } from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { runCommand, type CommandResult } from '../../subsync/utils';
import { parseSubtitleCues, type SubtitleCue } from './subtitle-cue-parser.js';
import { isEnglishYoutubeLang, normalizeYoutubeLangCode } from './youtube/labels.js';
const DEFAULT_SECONDARY_SUBTITLE_LANGUAGES = ['en', 'eng', 'english', 'en-us', 'enus'];
const DEFAULT_PRIMARY_SUBTITLE_LANGUAGES = ['ja', 'jpn', 'jp', 'japanese'];
const SUPPORTED_SUBTITLE_EXTENSIONS = new Set(['.srt', '.vtt', '.ass', '.ssa']);
const TIMING_TOLERANCE_SECONDS = 0.25;
const SAME_TIMING_EPSILON_SECONDS = 0.001;
const RETIMED_SUBTITLE_TIMEOUT_MS = 30_000;
const FALLBACK_ALASS_PATHS = [
'/opt/homebrew/bin/alass-cli',
'/opt/homebrew/bin/alass',
'/usr/local/bin/alass-cli',
'/usr/local/bin/alass',
'/usr/bin/alass',
];
type SidecarCandidate = {
path: string;
@@ -15,15 +26,43 @@ type SidecarCandidate = {
name: string;
};
type RetimedSubtitleCacheEntry = {
path: string;
cleanupDir: 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 +73,105 @@ function expandPreferredLanguages(languages: readonly string[] | undefined): str
return unique(expanded);
}
function isExecutableFile(filePath: string): boolean {
try {
return statSync(filePath).isFile();
} catch {
return false;
}
}
function pathEntries(): string[] {
const entries = (process.env.PATH ?? '')
.split(path.delimiter)
.map((entry) => entry.trim())
.filter(Boolean);
return unique([...entries, ...FALLBACK_ALASS_PATHS.map((candidate) => path.dirname(candidate))]);
}
function executableNames(name: string): string[] {
if (process.platform !== 'win32') return [name];
const extensions = (process.env.PATHEXT ?? '.EXE;.CMD;.BAT')
.split(';')
.map((entry) => entry.trim())
.filter(Boolean);
if (path.extname(name)) return [name];
return [name, ...extensions.map((extension) => `${name}${extension}`)];
}
function findExecutable(names: readonly string[]): string {
for (const name of names) {
if (path.dirname(name) !== '.') {
return isExecutableFile(name) ? name : '';
}
}
for (const dir of pathEntries()) {
for (const name of names) {
for (const executableName of executableNames(name)) {
const candidate = path.join(dir, executableName);
if (isExecutableFile(candidate)) return candidate;
}
}
}
for (const candidate of FALLBACK_ALASS_PATHS) {
if (isExecutableFile(candidate)) return candidate;
}
return '';
}
function resolveAlassPath(configuredPath: string | null | undefined): string {
const trimmed = configuredPath?.trim() ?? '';
if (trimmed) {
return findExecutable([trimmed]);
}
return findExecutable(['alass', 'alass-cli']);
}
function fileSignature(filePath: string): string | null {
try {
const stats = statSync(filePath);
if (!stats.isFile()) return null;
return `${stats.size}:${stats.mtimeMs}`;
} catch {
return null;
}
}
function retimedCacheKey(
alassPath: string,
primaryPath: string,
secondaryPath: string,
): string | null {
const primarySignature = fileSignature(primaryPath);
const secondarySignature = fileSignature(secondaryPath);
if (!primarySignature || !secondarySignature) return null;
return [alassPath, primaryPath, primarySignature, secondaryPath, secondarySignature].join('\0');
}
function cleanupRetimedSubtitleCache(): void {
for (const entry of retimedSubtitleCache.values()) {
try {
rmSync(entry.cleanupDir, { recursive: true, force: true });
} catch {
// Best-effort temp cleanup.
}
}
retimedSubtitleCache.clear();
}
function registerRetimedSubtitleCleanup(): void {
if (retimedSubtitleCleanupRegistered) return;
retimedSubtitleCleanupRegistered = true;
process.once('exit', cleanupRetimedSubtitleCache);
}
export function clearRetimedSecondarySubtitleCache(): void {
cleanupRetimedSubtitleCache();
}
function splitLanguageSuffix(value: string): string[] {
const normalizedWhole = normalizeYoutubeLangCode(value);
const tokens = value
@@ -190,6 +328,64 @@ function findCueTextAtTiming(cues: SubtitleCue[], startMs: number, endMs: number
return bestOverlap ? bestOverlap.cue.text.trim() : '';
}
function readCueTextAtTiming(filePath: string, startMs: number, endMs: number): string {
const content = readFileSync(filePath, 'utf8');
const cues = parseSubtitleCues(content, filePath);
return findCueTextAtTiming(cues, startMs, endMs);
}
async function defaultRunAlass(
alassPath: string,
referencePath: string,
inputPath: string,
outputPath: string,
): Promise<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 && existsSync(cached.path)) {
return cached.path;
}
if (cached) {
retimedSubtitleCache.delete(key);
try {
rmSync(cached.cleanupDir, { recursive: true, force: true });
} catch {}
}
registerRetimedSubtitleCleanup();
const cleanupDir = mkdtempSync(path.join(os.tmpdir(), 'subminer-retimed-secondary-'));
const parsedSecondary = path.parse(input.secondaryPath);
const outputPath = path.join(
cleanupDir,
`${parsedSecondary.name}.retimed${parsedSecondary.ext || '.srt'}`,
);
const result = await input.runAlass(
input.alassPath,
input.primaryPath,
input.secondaryPath,
outputPath,
);
if (!result.ok || !existsSync(outputPath)) {
rmSync(cleanupDir, { recursive: true, force: true });
return '';
}
retimedSubtitleCache.set(key, { path: outputPath, cleanupDir });
return outputPath;
}
export function resolveSecondarySubtitleTextFromSidecar(input: {
sourcePath: string;
startMs: number;
@@ -207,13 +403,14 @@ export function resolveSecondarySubtitleTextFromSidecar(input: {
return '';
}
const preferredLanguages = expandPreferredLanguages(input.languages);
const preferredLanguages = expandPreferredLanguages(
input.languages,
DEFAULT_SECONDARY_SUBTITLE_LANGUAGES,
);
const candidates = findSidecarSubtitleCandidates(input.sourcePath, preferredLanguages);
for (const candidate of candidates) {
try {
const content = readFileSync(candidate.path, 'utf8');
const cues = parseSubtitleCues(content, candidate.path);
const text = findCueTextAtTiming(cues, input.startMs, input.endMs);
const text = readCueTextAtTiming(candidate.path, input.startMs, input.endMs);
if (text) {
return text;
}
@@ -224,3 +421,54 @@ export function resolveSecondarySubtitleTextFromSidecar(input: {
return '';
}
export async function resolveRetimedSecondarySubtitleTextFromSidecar(
input: RetimedSecondarySubtitleInput,
): Promise<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 '';
}
+52 -6
View File
@@ -17,7 +17,11 @@ import {
} from '../../anki-field-config.js';
import { resolveAnimatedImageLeadInSeconds } from '../../anki-integration/animated-image-sync.js';
import type { AnilistRateLimiter } from './anilist/rate-limiter.js';
import { resolveSecondarySubtitleTextFromSidecar } from './secondary-subtitle-sidecar.js';
import {
resolveRetimedSecondarySubtitleTextFromSidecar,
resolveSecondarySubtitleTextFromSidecar,
type RetimedSecondarySubtitleInput,
} from './secondary-subtitle-sidecar.js';
type StatsServerNoteInfo = {
noteId: number;
@@ -366,6 +370,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 +510,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 +530,8 @@ export function createStatsApp(
options?.getAnkiConnectConfig?.() ?? options?.ankiConnectConfig;
const getSecondarySubtitleLanguages = (): string[] =>
options?.getSecondarySubtitleLanguages?.() ?? options?.secondarySubtitleLanguages ?? [];
const getStatsMiningAlassPath = (): string | null | undefined =>
options?.getStatsMiningAlassPath?.() ?? options?.statsMiningAlassPath;
const getEffectiveMiningDeckName = async (ankiConfig: AnkiConnectConfig): Promise<string> => {
const configuredDeckName = ankiConfig.deck?.trim() ?? '';
if (configuredDeckName) return configuredDeckName;
@@ -1086,6 +1102,9 @@ export function createStatsApp(
if (!sourcePath || !sentence || !Number.isFinite(startMs) || !Number.isFinite(endMs)) {
return c.json({ error: 'sourcePath, sentence, startMs, and endMs are required' }, 400);
}
if (endMs <= startMs) {
return c.json({ error: 'endMs must be greater than startMs' }, 400);
}
if (!existsSync(sourcePath)) {
return c.json({ error: 'File not found' }, 404);
@@ -1095,13 +1114,35 @@ export function createStatsApp(
if (!ankiConfig) {
return c.json({ error: 'AnkiConnect is not configured' }, 500);
}
const secondarySubtitleLanguages = getSecondarySubtitleLanguages();
let retimedSecondaryText = '';
if (mode === 'sentence') {
try {
retimedSecondaryText = await (
options?.resolveRetimedSecondarySubtitleText ??
resolveRetimedSecondarySubtitleTextFromSidecar
)({
sourcePath,
startMs,
endMs,
languages: secondarySubtitleLanguages,
alassPath: getStatsMiningAlassPath(),
});
} catch (error) {
statsMiningLogger.warn(
'Failed to resolve retimed secondary subtitle for stats mining:',
error instanceof Error ? error.message : String(error),
);
}
}
const secondaryText =
retimedSecondaryText ||
bodySecondaryText ||
resolveSecondarySubtitleTextFromSidecar({
sourcePath,
startMs,
endMs,
languages: getSecondarySubtitleLanguages(),
languages: secondarySubtitleLanguages,
});
const client = new AnkiConnectClient(ankiConfig.url ?? 'http://127.0.0.1:8765');
@@ -1250,18 +1291,20 @@ export function createStatsApp(
errors.push(`image: ${(err as Error).message}`);
}
}
if (generateAudio && !audioBuffer && audioResult.status === 'fulfilled') {
errors.push('audio: no audio generated');
}
if (generateImage && !imageBuffer) {
errors.push('image: no image generated');
}
const mediaFields: Record<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 +1532,9 @@ export function startStatsServer(config: StatsServerConfig): { close: () => void
getYomitanAnkiDeckName: config.getYomitanAnkiDeckName,
secondarySubtitleLanguages: config.secondarySubtitleLanguages,
getSecondarySubtitleLanguages: config.getSecondarySubtitleLanguages,
statsMiningAlassPath: config.statsMiningAlassPath,
getStatsMiningAlassPath: config.getStatsMiningAlassPath,
resolveRetimedSecondarySubtitleText: config.resolveRetimedSecondarySubtitleText,
anilistRateLimiter: config.anilistRateLimiter,
addYomitanNote: config.addYomitanNote,
resolveAnkiNoteId: config.resolveAnkiNoteId,