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