mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-07-29 01:01:34 -07:00
refactor: split anki-connect and stats-server resolvers into modules (#169)
This commit is contained in:
@@ -0,0 +1,469 @@
|
||||
import type { Hono } from 'hono';
|
||||
import { existsSync } from 'node:fs';
|
||||
import { basename } from 'node:path';
|
||||
import { AnkiConnectClient } from '../../../anki-connect.js';
|
||||
import { getConfiguredWordFieldName } from '../../../anki-field-config.js';
|
||||
import { resolveAnimatedImageLeadInSeconds } from '../../../anki-integration/animated-image-sync.js';
|
||||
import { MediaGenerator } from '../../../media-generator.js';
|
||||
import { statsJson } from '../../../types/stats-http-contract.js';
|
||||
import {
|
||||
resolveRetimedSecondarySubtitleTextFromSidecar,
|
||||
resolveSecondarySubtitleTextFromSidecar,
|
||||
} from '../secondary-subtitle-sidecar.js';
|
||||
import {
|
||||
applyStatsWordAndSentenceCardFields,
|
||||
createStatsMiningContext,
|
||||
getStatsDirectMiningAudioFieldNames,
|
||||
getStatsWordMiningAudioFieldName,
|
||||
resolveStatsNoteFieldName,
|
||||
shouldUseStatsLapisKikuCardFields,
|
||||
statsMiningLogger,
|
||||
type StatsMiningRouteOptions,
|
||||
type StatsServerNoteInfo,
|
||||
} from './mining-support.js';
|
||||
|
||||
export function registerStatsMiningRoutes(app: Hono, options?: StatsMiningRouteOptions): void {
|
||||
const {
|
||||
getAnkiConnectConfig,
|
||||
getEffectiveMiningDeckName,
|
||||
getSecondarySubtitleLanguages,
|
||||
getStatsMiningAlassPath,
|
||||
timeMiningPhase,
|
||||
} = createStatsMiningContext(options);
|
||||
app.post('/api/stats/mine-card', async (c) => {
|
||||
const body = await c.req.json().catch(() => null);
|
||||
const sourcePath = typeof body?.sourcePath === 'string' ? body.sourcePath.trim() : '';
|
||||
const startMs = typeof body?.startMs === 'number' ? body.startMs : NaN;
|
||||
const endMs = typeof body?.endMs === 'number' ? body.endMs : NaN;
|
||||
const sentence = typeof body?.sentence === 'string' ? body.sentence.trim() : '';
|
||||
const word = typeof body?.word === 'string' ? body.word.trim() : '';
|
||||
const bodySecondaryText =
|
||||
typeof body?.secondaryText === 'string' ? body.secondaryText.trim() : '';
|
||||
const videoTitle = typeof body?.videoTitle === 'string' ? body.videoTitle.trim() : '';
|
||||
const rawMode = c.req.query('mode');
|
||||
const mode = rawMode === 'audio' ? 'audio' : rawMode === 'word' ? 'word' : 'sentence';
|
||||
|
||||
if (
|
||||
!sourcePath ||
|
||||
!sentence ||
|
||||
(mode === 'word' && !word) ||
|
||||
!Number.isFinite(startMs) ||
|
||||
!Number.isFinite(endMs)
|
||||
) {
|
||||
return c.json(
|
||||
statsJson('mineCard', {
|
||||
error: 'sourcePath, sentence, startMs, and endMs are required',
|
||||
}),
|
||||
400,
|
||||
);
|
||||
}
|
||||
if (endMs <= startMs) {
|
||||
return c.json(statsJson('mineCard', { error: 'endMs must be greater than startMs' }), 400);
|
||||
}
|
||||
|
||||
if (!existsSync(sourcePath)) {
|
||||
return c.json(statsJson('mineCard', { error: 'File not found' }), 404);
|
||||
}
|
||||
|
||||
const ankiConfig = getAnkiConnectConfig();
|
||||
if (!ankiConfig) {
|
||||
return c.json(statsJson('mineCard', { error: 'AnkiConnect is not configured' }), 500);
|
||||
}
|
||||
const addYomitanNote = options?.addYomitanNote;
|
||||
if (mode === 'word' && !addYomitanNote) {
|
||||
return c.json(statsJson('mineCard', { error: 'Yomitan bridge not available' }), 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: secondarySubtitleLanguages,
|
||||
});
|
||||
|
||||
const client = new AnkiConnectClient(ankiConfig.url ?? 'http://127.0.0.1:8765');
|
||||
const mediaGen = options?.createMediaGenerator?.() ?? new MediaGenerator();
|
||||
|
||||
const audioPadding = ankiConfig.media?.audioPadding ?? 0;
|
||||
const normalizeAudio = ankiConfig.media?.normalizeAudio !== false;
|
||||
const maxMediaDuration = ankiConfig.media?.maxMediaDuration ?? 30;
|
||||
|
||||
const startSec = startMs / 1000;
|
||||
const endSec = endMs / 1000;
|
||||
const rawDuration = endSec - startSec;
|
||||
const clampedEndSec = rawDuration > maxMediaDuration ? startSec + maxMediaDuration : endSec;
|
||||
|
||||
const highlightedSentence = word
|
||||
? sentence.replace(
|
||||
new RegExp(word.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'g'),
|
||||
`<b>${word}</b>`,
|
||||
)
|
||||
: sentence;
|
||||
|
||||
const generateAudio = ankiConfig.media?.generateAudio !== false;
|
||||
const generateImage = ankiConfig.media?.generateImage !== false && mode !== 'audio';
|
||||
const imageType = ankiConfig.media?.imageType ?? 'static';
|
||||
const syncAnimatedImageToWordAudio =
|
||||
imageType === 'avif' && ankiConfig.media?.syncAnimatedImageToWordAudio !== false;
|
||||
|
||||
const audioPromise = generateAudio
|
||||
? timeMiningPhase(mode, 'generateAudio', () =>
|
||||
mediaGen.generateAudio(
|
||||
sourcePath,
|
||||
startSec,
|
||||
clampedEndSec,
|
||||
audioPadding,
|
||||
null,
|
||||
normalizeAudio,
|
||||
),
|
||||
)
|
||||
: Promise.resolve(null);
|
||||
|
||||
const createImagePromise = (animatedLeadInSeconds = 0): Promise<Buffer | null> => {
|
||||
if (!generateImage) {
|
||||
return Promise.resolve(null);
|
||||
}
|
||||
|
||||
if (imageType === 'avif') {
|
||||
return timeMiningPhase(mode, 'generateAnimatedImage', () =>
|
||||
mediaGen.generateAnimatedImage(sourcePath, startSec, clampedEndSec, audioPadding, {
|
||||
fps: ankiConfig.media?.animatedFps ?? 10,
|
||||
maxWidth: ankiConfig.media?.animatedMaxWidth ?? 640,
|
||||
maxHeight: ankiConfig.media?.animatedMaxHeight,
|
||||
crf: ankiConfig.media?.animatedCrf ?? 35,
|
||||
leadingStillDuration: animatedLeadInSeconds,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
const midpointSec = (startSec + clampedEndSec) / 2;
|
||||
return timeMiningPhase(mode, 'generateScreenshot', () =>
|
||||
mediaGen.generateScreenshot(sourcePath, midpointSec, {
|
||||
format: ankiConfig.media?.imageFormat ?? 'jpg',
|
||||
quality: ankiConfig.media?.imageQuality ?? 92,
|
||||
maxWidth: ankiConfig.media?.imageMaxWidth,
|
||||
maxHeight: ankiConfig.media?.imageMaxHeight,
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
const imagePromise =
|
||||
mode === 'word' && syncAnimatedImageToWordAudio
|
||||
? Promise.resolve<Buffer | null>(null)
|
||||
: createImagePromise();
|
||||
|
||||
const errors: string[] = [];
|
||||
let noteId: number;
|
||||
let effectiveDeckNamePromise: Promise<string> | null = null;
|
||||
const getEffectiveDeckNameForRequest = (): Promise<string> => {
|
||||
effectiveDeckNamePromise ??= getEffectiveMiningDeckName(ankiConfig);
|
||||
return effectiveDeckNamePromise;
|
||||
};
|
||||
const moveNoteToConfiguredDeck = async (id: number): Promise<void> => {
|
||||
const deckName = await getEffectiveDeckNameForRequest();
|
||||
if (!deckName) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const cardIds = await timeMiningPhase(mode, 'findCards', () =>
|
||||
client.findCards(`nid:${id}`),
|
||||
);
|
||||
await timeMiningPhase(mode, 'changeDeck', () => client.changeDeck(cardIds, deckName));
|
||||
} catch (err) {
|
||||
errors.push(`deck: ${(err as Error).message}`);
|
||||
}
|
||||
};
|
||||
|
||||
if (mode === 'word') {
|
||||
const [yomitanResult, audioResult, imageResult] = await Promise.allSettled([
|
||||
timeMiningPhase(
|
||||
'word',
|
||||
'addYomitanNote',
|
||||
() => addYomitanNote!(word),
|
||||
(noteId) => (typeof noteId === 'number' ? { noteId } : {}),
|
||||
),
|
||||
audioPromise,
|
||||
imagePromise,
|
||||
]);
|
||||
|
||||
if (yomitanResult.status === 'rejected' || !yomitanResult.value) {
|
||||
return c.json(
|
||||
statsJson('mineCard', {
|
||||
error: `Yomitan failed to create note: ${yomitanResult.status === 'rejected' ? (yomitanResult.reason as Error).message : 'no result'}`,
|
||||
}),
|
||||
502,
|
||||
);
|
||||
}
|
||||
|
||||
noteId = yomitanResult.value;
|
||||
await moveNoteToConfiguredDeck(noteId);
|
||||
const audioBuffer = audioResult.status === 'fulfilled' ? audioResult.value : null;
|
||||
if (audioResult.status === 'rejected')
|
||||
errors.push(`audio: ${(audioResult.reason as Error).message}`);
|
||||
if (imageResult.status === 'rejected')
|
||||
errors.push(`image: ${(imageResult.reason as Error).message}`);
|
||||
|
||||
let imageBuffer = imageResult.status === 'fulfilled' ? imageResult.value : null;
|
||||
let noteInfo: StatsServerNoteInfo | null = null;
|
||||
if (
|
||||
audioBuffer ||
|
||||
(syncAnimatedImageToWordAudio && generateImage) ||
|
||||
shouldUseStatsLapisKikuCardFields(ankiConfig)
|
||||
) {
|
||||
try {
|
||||
const noteInfoResult = (await client.notesInfo([noteId])) as StatsServerNoteInfo[];
|
||||
noteInfo = noteInfoResult[0] ?? null;
|
||||
} catch (err) {
|
||||
if (syncAnimatedImageToWordAudio && generateImage) {
|
||||
errors.push(`image: ${(err as Error).message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (syncAnimatedImageToWordAudio && generateImage) {
|
||||
try {
|
||||
const animatedLeadInSeconds = noteInfo
|
||||
? await resolveAnimatedImageLeadInSeconds({
|
||||
config: ankiConfig,
|
||||
noteInfo,
|
||||
resolveConfiguredFieldName: (candidateNoteInfo, ...preferredNames) =>
|
||||
resolveStatsNoteFieldName(candidateNoteInfo, ...preferredNames),
|
||||
retrieveMediaFileBase64: (filename) => client.retrieveMediaFile(filename),
|
||||
})
|
||||
: 0;
|
||||
imageBuffer = await createImagePromise(animatedLeadInSeconds);
|
||||
} catch (err) {
|
||||
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 audioFieldName = getStatsWordMiningAudioFieldName(ankiConfig, noteInfo);
|
||||
const imageFieldName = ankiConfig.fields?.image ?? 'Picture';
|
||||
|
||||
mediaFields[sentenceFieldName] = highlightedSentence;
|
||||
applyStatsWordAndSentenceCardFields(mediaFields, noteInfo, ankiConfig);
|
||||
|
||||
if (audioBuffer) {
|
||||
const audioFilename = `subminer_audio_${timestamp}_${noteId}.mp3`;
|
||||
try {
|
||||
await timeMiningPhase('word', 'uploadAudio', () =>
|
||||
client.storeMediaFile(audioFilename, audioBuffer),
|
||||
);
|
||||
mediaFields[audioFieldName] = `[sound:${audioFilename}]`;
|
||||
} catch (err) {
|
||||
errors.push(`audio upload: ${(err as Error).message}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (imageBuffer) {
|
||||
const imageExt = imageType === 'avif' ? 'avif' : (ankiConfig.media?.imageFormat ?? 'jpg');
|
||||
const imageFilename = `subminer_image_${timestamp}_${noteId}.${imageExt}`;
|
||||
try {
|
||||
await timeMiningPhase('word', 'uploadImage', () =>
|
||||
client.storeMediaFile(imageFilename, imageBuffer),
|
||||
);
|
||||
mediaFields[imageFieldName] = `<img src="${imageFilename}">`;
|
||||
} catch (err) {
|
||||
errors.push(`image upload: ${(err as Error).message}`);
|
||||
}
|
||||
}
|
||||
|
||||
const miscInfoFieldName = ankiConfig.fields?.miscInfo ?? '';
|
||||
if (miscInfoFieldName) {
|
||||
const pattern = ankiConfig.metadata?.pattern ?? '[SubMiner] %f (%t)';
|
||||
const filenameWithExt = videoTitle || basename(sourcePath);
|
||||
const filenameWithoutExt = filenameWithExt.replace(/\.[^.]+$/, '');
|
||||
const totalMs = Math.floor(startMs);
|
||||
const totalSec2 = Math.floor(totalMs / 1000);
|
||||
const hours = String(Math.floor(totalSec2 / 3600)).padStart(2, '0');
|
||||
const minutes = String(Math.floor((totalSec2 % 3600) / 60)).padStart(2, '0');
|
||||
const secs = String(totalSec2 % 60).padStart(2, '0');
|
||||
const ms = String(totalMs % 1000).padStart(3, '0');
|
||||
mediaFields[miscInfoFieldName] = pattern
|
||||
.replace(/%f/g, filenameWithoutExt)
|
||||
.replace(/%F/g, filenameWithExt)
|
||||
.replace(/%t/g, `${hours}:${minutes}:${secs}`)
|
||||
.replace(/%T/g, `${hours}:${minutes}:${secs}:${ms}`)
|
||||
.replace(/<br>/g, '\n');
|
||||
}
|
||||
|
||||
if (Object.keys(mediaFields).length > 0) {
|
||||
try {
|
||||
await timeMiningPhase('word', 'updateNoteFields', () =>
|
||||
client.updateNoteFields(noteId, mediaFields),
|
||||
);
|
||||
} catch (err) {
|
||||
errors.push(`update fields: ${(err as Error).message}`);
|
||||
}
|
||||
}
|
||||
|
||||
return c.json(statsJson('mineCard', { noteId, ...(errors.length > 0 ? { errors } : {}) }));
|
||||
}
|
||||
|
||||
const wordFieldName = getConfiguredWordFieldName(ankiConfig);
|
||||
const sentenceFieldName = ankiConfig.fields?.sentence ?? 'Sentence';
|
||||
const translationFieldName = ankiConfig.fields?.translation ?? 'SelectionText';
|
||||
const imageFieldName = ankiConfig.fields?.image ?? 'Picture';
|
||||
const miscInfoFieldName = ankiConfig.fields?.miscInfo ?? '';
|
||||
|
||||
const fields: Record<string, string> = {
|
||||
[sentenceFieldName]: mode === 'sentence' ? sentence : highlightedSentence,
|
||||
};
|
||||
|
||||
if (mode === 'sentence' && secondaryText) {
|
||||
fields[translationFieldName] = secondaryText;
|
||||
}
|
||||
|
||||
if (ankiConfig.isLapis?.enabled || ankiConfig.isKiku?.enabled) {
|
||||
if (mode === 'sentence') {
|
||||
fields[wordFieldName] = sentence;
|
||||
} else if (word) {
|
||||
fields[wordFieldName] = word;
|
||||
}
|
||||
if (mode === 'sentence') {
|
||||
fields['IsSentenceCard'] = 'x';
|
||||
} else if (mode === 'audio') {
|
||||
fields['IsAudioCard'] = 'x';
|
||||
}
|
||||
}
|
||||
|
||||
const model = ankiConfig.isLapis?.sentenceCardModel || 'Basic';
|
||||
const tags = ankiConfig.tags ?? ['SubMiner'];
|
||||
|
||||
const addNotePromise = timeMiningPhase(
|
||||
mode,
|
||||
'addNote',
|
||||
async () =>
|
||||
client.addNote((await getEffectiveDeckNameForRequest()) || 'Default', model, fields, tags),
|
||||
(id) => ({
|
||||
noteId: id,
|
||||
}),
|
||||
);
|
||||
|
||||
const [audioResult, imageResult, addNoteResult] = await Promise.allSettled([
|
||||
audioPromise,
|
||||
imagePromise,
|
||||
addNotePromise,
|
||||
]);
|
||||
|
||||
const audioBuffer = audioResult.status === 'fulfilled' ? audioResult.value : null;
|
||||
const imageBuffer = imageResult.status === 'fulfilled' ? imageResult.value : null;
|
||||
if (audioResult.status === 'rejected')
|
||||
errors.push(`audio: ${(audioResult.reason as Error).message}`);
|
||||
if (imageResult.status === 'rejected')
|
||||
errors.push(`image: ${(imageResult.reason as Error).message}`);
|
||||
|
||||
if (addNoteResult.status === 'rejected') {
|
||||
return c.json(
|
||||
statsJson('mineCard', {
|
||||
error: `Failed to add note: ${(addNoteResult.reason as Error).message}`,
|
||||
}),
|
||||
502,
|
||||
);
|
||||
}
|
||||
noteId = addNoteResult.value;
|
||||
await moveNoteToConfiguredDeck(noteId);
|
||||
|
||||
const mediaFields: Record<string, string> = {};
|
||||
const timestamp = Date.now();
|
||||
let noteInfo: StatsServerNoteInfo | null = null;
|
||||
if (audioBuffer) {
|
||||
try {
|
||||
const noteInfoResult = (await client.notesInfo([noteId])) as StatsServerNoteInfo[];
|
||||
noteInfo = noteInfoResult[0] ?? null;
|
||||
} catch {
|
||||
noteInfo = null;
|
||||
}
|
||||
}
|
||||
|
||||
if (audioBuffer) {
|
||||
const audioFilename = `subminer_audio_${timestamp}_${noteId}.mp3`;
|
||||
try {
|
||||
await timeMiningPhase(mode, 'uploadAudio', () =>
|
||||
client.storeMediaFile(audioFilename, audioBuffer),
|
||||
);
|
||||
const audioValue = `[sound:${audioFilename}]`;
|
||||
for (const fieldName of getStatsDirectMiningAudioFieldNames(ankiConfig, noteInfo, mode)) {
|
||||
mediaFields[fieldName] = audioValue;
|
||||
}
|
||||
} catch (err) {
|
||||
errors.push(`audio upload: ${(err as Error).message}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (imageBuffer) {
|
||||
const imageExt = imageType === 'avif' ? 'avif' : (ankiConfig.media?.imageFormat ?? 'jpg');
|
||||
const imageFilename = `subminer_image_${timestamp}_${noteId}.${imageExt}`;
|
||||
try {
|
||||
await timeMiningPhase(mode, 'uploadImage', () =>
|
||||
client.storeMediaFile(imageFilename, imageBuffer),
|
||||
);
|
||||
mediaFields[imageFieldName] = `<img src="${imageFilename}">`;
|
||||
} catch (err) {
|
||||
errors.push(`image upload: ${(err as Error).message}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (miscInfoFieldName) {
|
||||
const pattern = ankiConfig.metadata?.pattern ?? '[SubMiner] %f (%t)';
|
||||
const filenameWithExt = videoTitle || basename(sourcePath);
|
||||
const filenameWithoutExt = filenameWithExt.replace(/\.[^.]+$/, '');
|
||||
const totalMs = Math.floor(startMs);
|
||||
const totalSec = Math.floor(totalMs / 1000);
|
||||
const hours = String(Math.floor(totalSec / 3600)).padStart(2, '0');
|
||||
const minutes = String(Math.floor((totalSec % 3600) / 60)).padStart(2, '0');
|
||||
const secs = String(totalSec % 60).padStart(2, '0');
|
||||
const ms = String(totalMs % 1000).padStart(3, '0');
|
||||
const miscInfo = pattern
|
||||
.replace(/%f/g, filenameWithoutExt)
|
||||
.replace(/%F/g, filenameWithExt)
|
||||
.replace(/%t/g, `${hours}:${minutes}:${secs}`)
|
||||
.replace(/%T/g, `${hours}:${minutes}:${secs}:${ms}`)
|
||||
.replace(/<br>/g, '\n');
|
||||
mediaFields[miscInfoFieldName] = miscInfo;
|
||||
}
|
||||
|
||||
if (Object.keys(mediaFields).length > 0) {
|
||||
try {
|
||||
await timeMiningPhase(mode, 'updateNoteFields', () =>
|
||||
client.updateNoteFields(noteId, mediaFields),
|
||||
);
|
||||
} catch (err) {
|
||||
errors.push(`update fields: ${(err as Error).message}`);
|
||||
}
|
||||
}
|
||||
|
||||
return c.json(statsJson('mineCard', { noteId, ...(errors.length > 0 ? { errors } : {}) }));
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user