Files
SubMiner/src/anki-integration/card-creation.ts

728 lines
25 KiB
TypeScript

import { DEFAULT_ANKI_CONNECT_CONFIG } from "../config";
import { AnkiConnectConfig } from "../types";
import { createLogger } from "../logger";
import { SubtitleTimingTracker } from "../subtitle-timing-tracker";
import { MediaGenerator } from "../media-generator";
import { MpvClient } from "../types";
import { resolveSentenceBackText } from "./ai";
const log = createLogger("anki").child("integration.card-creation");
export interface CardCreationNoteInfo {
noteId: number;
fields: Record<string, { value: string }>;
}
type CardKind = "sentence" | "audio";
interface CardCreationClient {
addNote(
deck: string,
modelName: string,
fields: Record<string, string>,
): Promise<number>;
notesInfo(noteIds: number[]): Promise<unknown>;
updateNoteFields(noteId: number, fields: Record<string, string>): Promise<void>;
storeMediaFile(filename: string, data: Buffer): Promise<void>;
findNotes(query: string, options?: { maxRetries?: number }): Promise<number[]>;
}
interface CardCreationMediaGenerator {
generateAudio(
path: string,
startTime: number,
endTime: number,
audioPadding?: number,
audioStreamIndex?: number,
): Promise<Buffer | null>;
generateScreenshot(
path: string,
timestamp: number,
options: {
format: "jpg" | "png" | "webp";
quality?: number;
maxWidth?: number;
maxHeight?: number;
},
): Promise<Buffer | null>;
generateAnimatedImage(
path: string,
startTime: number,
endTime: number,
audioPadding?: number,
options?: {
fps?: number;
maxWidth?: number;
maxHeight?: number;
crf?: number;
},
): Promise<Buffer | null>;
}
interface CardCreationDeps {
getConfig: () => AnkiConnectConfig;
getTimingTracker: () => SubtitleTimingTracker;
getMpvClient: () => MpvClient;
getDeck?: () => string | undefined;
client: CardCreationClient;
mediaGenerator: CardCreationMediaGenerator;
showOsdNotification: (text: string) => void;
showStatusNotification: (message: string) => void;
showNotification: (noteId: number, label: string | number, errorSuffix?: string) => Promise<void>;
beginUpdateProgress: (initialMessage: string) => void;
endUpdateProgress: () => void;
withUpdateProgress: <T>(initialMessage: string, action: () => Promise<T>) => Promise<T>;
resolveConfiguredFieldName: (
noteInfo: CardCreationNoteInfo,
...preferredNames: (string | undefined)[]
) => string | null;
resolveNoteFieldName: (
noteInfo: CardCreationNoteInfo,
preferredName?: string,
) => string | null;
extractFields: (fields: Record<string, { value: string }>) => Record<string, string>;
processSentence: (mpvSentence: string, noteFields: Record<string, string>) => string;
setCardTypeFields: (
updatedFields: Record<string, string>,
availableFieldNames: string[],
cardKind: CardKind,
) => void;
mergeFieldValue: (existing: string, newValue: string, overwrite: boolean) => string;
formatMiscInfoPattern: (fallbackFilename: string, startTimeSeconds?: number) => string;
getEffectiveSentenceCardConfig: () => {
model?: string;
sentenceField: string;
audioField: string;
lapisEnabled: boolean;
kikuEnabled: boolean;
kikuFieldGrouping: "auto" | "manual" | "disabled";
kikuDeleteDuplicateInAuto: boolean;
};
getFallbackDurationSeconds: () => number;
appendKnownWordsFromNoteInfo: (noteInfo: CardCreationNoteInfo) => void;
isUpdateInProgress: () => boolean;
setUpdateInProgress: (value: boolean) => void;
trackLastAddedNoteId?: (noteId: number) => void;
}
export class CardCreationService {
constructor(private readonly deps: CardCreationDeps) {}
async updateLastAddedFromClipboard(clipboardText: string): Promise<void> {
try {
if (!clipboardText || !clipboardText.trim()) {
this.deps.showOsdNotification("Clipboard is empty");
return;
}
const mpvClient = this.deps.getMpvClient();
if (!mpvClient || !mpvClient.currentVideoPath) {
this.deps.showOsdNotification("No video loaded");
return;
}
const blocks = clipboardText
.split(/\n\s*\n/)
.map((block) => block.trim())
.filter((block) => block.length > 0);
if (blocks.length === 0) {
this.deps.showOsdNotification("No subtitle blocks found in clipboard");
return;
}
const timings: { startTime: number; endTime: number }[] = [];
const timingTracker = this.deps.getTimingTracker();
for (const block of blocks) {
const timing = timingTracker.findTiming(block);
if (timing) {
timings.push(timing);
}
}
if (timings.length === 0) {
this.deps.showOsdNotification("Subtitle timing not found; copy again while playing");
return;
}
const rangeStart = Math.min(...timings.map((entry) => entry.startTime));
let rangeEnd = Math.max(...timings.map((entry) => entry.endTime));
const maxMediaDuration = this.deps.getConfig().media?.maxMediaDuration ?? 30;
if (maxMediaDuration > 0 && rangeEnd - rangeStart > maxMediaDuration) {
log.warn(
`Media range ${(rangeEnd - rangeStart).toFixed(1)}s exceeds cap of ${maxMediaDuration}s, clamping`,
);
rangeEnd = rangeStart + maxMediaDuration;
}
this.deps.showOsdNotification("Updating card from clipboard...");
this.deps.beginUpdateProgress("Updating card from clipboard");
this.deps.setUpdateInProgress(true);
try {
const deck = this.deps.getDeck?.() ?? this.deps.getConfig().deck;
const query = deck ? `"deck:${deck}" added:1` : "added:1";
const noteIds = (await this.deps.client.findNotes(query, {
maxRetries: 0,
})) as number[];
if (!noteIds || noteIds.length === 0) {
this.deps.showOsdNotification("No recently added cards found");
return;
}
const noteId = Math.max(...noteIds);
const notesInfoResult = (await this.deps.client.notesInfo([noteId])) as CardCreationNoteInfo[];
if (!notesInfoResult || notesInfoResult.length === 0) {
this.deps.showOsdNotification("Card not found");
return;
}
const noteInfo = notesInfoResult[0];
const fields = this.deps.extractFields(noteInfo.fields);
const expressionText = fields.expression || fields.word || "";
const sentenceAudioField = this.getResolvedSentenceAudioFieldName(noteInfo);
const sentenceField = this.deps.getEffectiveSentenceCardConfig().sentenceField;
const sentence = blocks.join(" ");
const updatedFields: Record<string, string> = {};
let updatePerformed = false;
const errors: string[] = [];
let miscInfoFilename: string | null = null;
if (sentenceField) {
const processedSentence = this.deps.processSentence(sentence, fields);
updatedFields[sentenceField] = processedSentence;
updatePerformed = true;
}
log.info(
`Clipboard update: timing range ${rangeStart.toFixed(2)}s - ${rangeEnd.toFixed(2)}s`,
);
if (this.deps.getConfig().media?.generateAudio) {
try {
const audioFilename = this.generateAudioFilename();
const audioBuffer = await this.mediaGenerateAudio(
mpvClient.currentVideoPath,
rangeStart,
rangeEnd,
);
if (audioBuffer) {
await this.deps.client.storeMediaFile(audioFilename, audioBuffer);
if (sentenceAudioField) {
const existingAudio = noteInfo.fields[sentenceAudioField]?.value || "";
updatedFields[sentenceAudioField] = this.deps.mergeFieldValue(
existingAudio,
`[sound:${audioFilename}]`,
this.deps.getConfig().behavior?.overwriteAudio !== false,
);
}
miscInfoFilename = audioFilename;
updatePerformed = true;
}
} catch (error) {
log.error(
"Failed to generate audio:",
(error as Error).message,
);
errors.push("audio");
}
}
if (this.deps.getConfig().media?.generateImage) {
try {
const imageFilename = this.generateImageFilename();
const imageBuffer = await this.generateImageBuffer(
mpvClient.currentVideoPath,
rangeStart,
rangeEnd,
);
if (imageBuffer) {
await this.deps.client.storeMediaFile(imageFilename, imageBuffer);
const imageFieldName = this.deps.resolveConfiguredFieldName(
noteInfo,
this.deps.getConfig().fields?.image,
DEFAULT_ANKI_CONNECT_CONFIG.fields.image,
);
if (!imageFieldName) {
log.warn("Image field not found on note, skipping image update");
} else {
const existingImage = noteInfo.fields[imageFieldName]?.value || "";
updatedFields[imageFieldName] = this.deps.mergeFieldValue(
existingImage,
`<img src="${imageFilename}">`,
this.deps.getConfig().behavior?.overwriteImage !== false,
);
miscInfoFilename = imageFilename;
updatePerformed = true;
}
}
} catch (error) {
log.error(
"Failed to generate image:",
(error as Error).message,
);
errors.push("image");
}
}
if (this.deps.getConfig().fields?.miscInfo) {
const miscInfo = this.deps.formatMiscInfoPattern(
miscInfoFilename || "",
rangeStart,
);
const miscInfoField = this.deps.resolveConfiguredFieldName(
noteInfo,
this.deps.getConfig().fields?.miscInfo,
);
if (miscInfo && miscInfoField) {
updatedFields[miscInfoField] = miscInfo;
updatePerformed = true;
}
}
if (updatePerformed) {
await this.deps.client.updateNoteFields(noteId, updatedFields);
const label = expressionText || noteId;
log.info("Updated card from clipboard:", label);
const errorSuffix =
errors.length > 0 ? `${errors.join(", ")} failed` : undefined;
await this.deps.showNotification(noteId, label, errorSuffix);
}
} finally {
this.deps.setUpdateInProgress(false);
this.deps.endUpdateProgress();
}
} catch (error) {
log.error("Error updating card from clipboard:", (error as Error).message);
this.deps.showOsdNotification(`Update failed: ${(error as Error).message}`);
}
}
async markLastCardAsAudioCard(): Promise<void> {
if (this.deps.isUpdateInProgress()) {
this.deps.showOsdNotification("Anki update already in progress");
return;
}
try {
const mpvClient = this.deps.getMpvClient();
if (!mpvClient || !mpvClient.currentVideoPath) {
this.deps.showOsdNotification("No video loaded");
return;
}
if (!mpvClient.currentSubText) {
this.deps.showOsdNotification("No current subtitle");
return;
}
let startTime = mpvClient.currentSubStart;
let endTime = mpvClient.currentSubEnd;
if (startTime === undefined || endTime === undefined) {
const currentTime = mpvClient.currentTimePos || 0;
const fallback = this.deps.getFallbackDurationSeconds() / 2;
startTime = currentTime - fallback;
endTime = currentTime + fallback;
}
const maxMediaDuration = this.deps.getConfig().media?.maxMediaDuration ?? 30;
if (maxMediaDuration > 0 && endTime - startTime > maxMediaDuration) {
endTime = startTime + maxMediaDuration;
}
this.deps.showOsdNotification("Marking card as audio card...");
await this.deps.withUpdateProgress("Marking audio card", async () => {
const deck = this.deps.getDeck?.() ?? this.deps.getConfig().deck;
const query = deck ? `"deck:${deck}" added:1` : "added:1";
const noteIds = (await this.deps.client.findNotes(query)) as number[];
if (!noteIds || noteIds.length === 0) {
this.deps.showOsdNotification("No recently added cards found");
return;
}
const noteId = Math.max(...noteIds);
const notesInfoResult = (await this.deps.client.notesInfo([noteId])) as CardCreationNoteInfo[];
if (!notesInfoResult || notesInfoResult.length === 0) {
this.deps.showOsdNotification("Card not found");
return;
}
const noteInfo = notesInfoResult[0];
const fields = this.deps.extractFields(noteInfo.fields);
const expressionText = fields.expression || fields.word || "";
const updatedFields: Record<string, string> = {};
const errors: string[] = [];
let miscInfoFilename: string | null = null;
this.deps.setCardTypeFields(
updatedFields,
Object.keys(noteInfo.fields),
"audio",
);
const sentenceField = this.deps.getConfig().fields?.sentence;
if (sentenceField) {
const processedSentence = this.deps.processSentence(
mpvClient.currentSubText,
fields,
);
updatedFields[sentenceField] = processedSentence;
}
const sentenceCardConfig = this.deps.getEffectiveSentenceCardConfig();
const audioFieldName = sentenceCardConfig.audioField;
try {
const audioFilename = this.generateAudioFilename();
const audioBuffer = await this.mediaGenerateAudio(
mpvClient.currentVideoPath,
startTime,
endTime,
);
if (audioBuffer) {
await this.deps.client.storeMediaFile(audioFilename, audioBuffer);
updatedFields[audioFieldName] = `[sound:${audioFilename}]`;
miscInfoFilename = audioFilename;
}
} catch (error) {
log.error(
"Failed to generate audio for audio card:",
(error as Error).message,
);
errors.push("audio");
}
if (this.deps.getConfig().media?.generateImage) {
try {
const imageFilename = this.generateImageFilename();
const imageBuffer = await this.generateImageBuffer(
mpvClient.currentVideoPath,
startTime,
endTime,
);
const imageField = this.deps.getConfig().fields?.image;
if (imageBuffer && imageField) {
await this.deps.client.storeMediaFile(imageFilename, imageBuffer);
updatedFields[imageField] =
`<img src="${imageFilename}">`;
miscInfoFilename = imageFilename;
}
} catch (error) {
log.error(
"Failed to generate image for audio card:",
(error as Error).message,
);
errors.push("image");
}
}
if (this.deps.getConfig().fields?.miscInfo) {
const miscInfo = this.deps.formatMiscInfoPattern(
miscInfoFilename || "",
startTime,
);
const miscInfoField = this.deps.resolveConfiguredFieldName(
noteInfo,
this.deps.getConfig().fields?.miscInfo,
);
if (miscInfo && miscInfoField) {
updatedFields[miscInfoField] = miscInfo;
}
}
await this.deps.client.updateNoteFields(noteId, updatedFields);
const label = expressionText || noteId;
log.info("Marked card as audio card:", label);
const errorSuffix =
errors.length > 0 ? `${errors.join(", ")} failed` : undefined;
await this.deps.showNotification(noteId, label, errorSuffix);
});
} catch (error) {
log.error(
"Error marking card as audio card:",
(error as Error).message,
);
this.deps.showOsdNotification(
`Audio card failed: ${(error as Error).message}`,
);
}
}
async createSentenceCard(
sentence: string,
startTime: number,
endTime: number,
secondarySubText?: string,
): Promise<void> {
if (this.deps.isUpdateInProgress()) {
this.deps.showOsdNotification("Anki update already in progress");
return;
}
const sentenceCardConfig = this.deps.getEffectiveSentenceCardConfig();
const sentenceCardModel = sentenceCardConfig.model;
if (!sentenceCardModel) {
this.deps.showOsdNotification("sentenceCardModel not configured");
return;
}
const mpvClient = this.deps.getMpvClient();
if (!mpvClient || !mpvClient.currentVideoPath) {
this.deps.showOsdNotification("No video loaded");
return;
}
const maxMediaDuration = this.deps.getConfig().media?.maxMediaDuration ?? 30;
if (maxMediaDuration > 0 && endTime - startTime > maxMediaDuration) {
log.warn(
`Sentence card media range ${(endTime - startTime).toFixed(1)}s exceeds cap of ${maxMediaDuration}s, clamping`,
);
endTime = startTime + maxMediaDuration;
}
this.deps.showOsdNotification("Creating sentence card...");
await this.deps.withUpdateProgress("Creating sentence card", async () => {
const videoPath = mpvClient.currentVideoPath;
const fields: Record<string, string> = {};
const errors: string[] = [];
let miscInfoFilename: string | null = null;
const sentenceField = sentenceCardConfig.sentenceField;
const audioFieldName = sentenceCardConfig.audioField || "SentenceAudio";
const translationField = this.deps.getConfig().fields?.translation || "SelectionText";
let resolvedMiscInfoField: string | null = null;
let resolvedSentenceAudioField: string = audioFieldName;
let resolvedExpressionAudioField: string | null = null;
fields[sentenceField] = sentence;
const backText = await resolveSentenceBackText(
{
sentence,
secondarySubText,
config: this.deps.getConfig().ai || {},
},
{
logWarning: (message: string) => log.warn(message),
},
);
if (backText) {
fields[translationField] = backText;
}
if (sentenceCardConfig.lapisEnabled || sentenceCardConfig.kikuEnabled) {
fields.IsSentenceCard = "x";
fields.Expression = sentence;
}
const deck = this.deps.getConfig().deck || "Default";
let noteId: number;
try {
noteId = await this.deps.client.addNote(deck, sentenceCardModel, fields);
log.info("Created sentence card:", noteId);
this.deps.trackLastAddedNoteId?.(noteId);
} catch (error) {
log.error("Failed to create sentence card:", (error as Error).message);
this.deps.showOsdNotification(
`Sentence card failed: ${(error as Error).message}`,
);
return;
}
try {
const noteInfoResult = await this.deps.client.notesInfo([noteId]);
const noteInfos = noteInfoResult as CardCreationNoteInfo[];
if (noteInfos.length > 0) {
const createdNoteInfo = noteInfos[0];
this.deps.appendKnownWordsFromNoteInfo(createdNoteInfo);
resolvedSentenceAudioField =
this.deps.resolveNoteFieldName(createdNoteInfo, audioFieldName) ||
audioFieldName;
resolvedExpressionAudioField = this.deps.resolveConfiguredFieldName(
createdNoteInfo,
this.deps.getConfig().fields?.audio || "ExpressionAudio",
);
resolvedMiscInfoField = this.deps.resolveConfiguredFieldName(
createdNoteInfo,
this.deps.getConfig().fields?.miscInfo,
);
const cardTypeFields: Record<string, string> = {};
this.deps.setCardTypeFields(
cardTypeFields,
Object.keys(createdNoteInfo.fields),
"sentence",
);
if (Object.keys(cardTypeFields).length > 0) {
await this.deps.client.updateNoteFields(noteId, cardTypeFields);
}
}
} catch (error) {
log.error(
"Failed to normalize sentence card type fields:",
(error as Error).message,
);
errors.push("card type fields");
}
const mediaFields: Record<string, string> = {};
try {
const audioFilename = this.generateAudioFilename();
const audioBuffer = await this.mediaGenerateAudio(videoPath, startTime, endTime);
if (audioBuffer) {
await this.deps.client.storeMediaFile(audioFilename, audioBuffer);
const audioValue = `[sound:${audioFilename}]`;
mediaFields[resolvedSentenceAudioField] = audioValue;
if (
resolvedExpressionAudioField &&
resolvedExpressionAudioField !== resolvedSentenceAudioField
) {
mediaFields[resolvedExpressionAudioField] = audioValue;
}
miscInfoFilename = audioFilename;
}
} catch (error) {
log.error("Failed to generate sentence audio:", (error as Error).message);
errors.push("audio");
}
try {
const imageFilename = this.generateImageFilename();
const imageBuffer = await this.generateImageBuffer(videoPath, startTime, endTime);
const imageField = this.deps.getConfig().fields?.image;
if (imageBuffer && imageField) {
await this.deps.client.storeMediaFile(imageFilename, imageBuffer);
mediaFields[imageField] = `<img src="${imageFilename}">`;
miscInfoFilename = imageFilename;
}
} catch (error) {
log.error("Failed to generate sentence image:", (error as Error).message);
errors.push("image");
}
if (this.deps.getConfig().fields?.miscInfo) {
const miscInfo = this.deps.formatMiscInfoPattern(
miscInfoFilename || "",
startTime,
);
if (miscInfo && resolvedMiscInfoField) {
mediaFields[resolvedMiscInfoField] = miscInfo;
}
}
if (Object.keys(mediaFields).length > 0) {
try {
await this.deps.client.updateNoteFields(noteId, mediaFields);
} catch (error) {
log.error(
"Failed to update sentence card media:",
(error as Error).message,
);
errors.push("media update");
}
}
const label =
sentence.length > 30 ? sentence.substring(0, 30) + "..." : sentence;
const errorSuffix =
errors.length > 0 ? `${errors.join(", ")} failed` : undefined;
await this.deps.showNotification(noteId, label, errorSuffix);
});
}
private getResolvedSentenceAudioFieldName(noteInfo: CardCreationNoteInfo): string | null {
return (
this.deps.resolveNoteFieldName(
noteInfo,
this.deps.getEffectiveSentenceCardConfig().audioField || "SentenceAudio",
) ||
this.deps.resolveConfiguredFieldName(noteInfo, this.deps.getConfig().fields?.audio)
);
}
private async mediaGenerateAudio(
videoPath: string,
startTime: number,
endTime: number,
): Promise<Buffer | null> {
const mpvClient = this.deps.getMpvClient();
if (!mpvClient) {
return null;
}
return this.deps.mediaGenerator.generateAudio(
videoPath,
startTime,
endTime,
this.deps.getConfig().media?.audioPadding,
mpvClient.currentAudioStreamIndex ?? undefined,
);
}
private async generateImageBuffer(
videoPath: string,
startTime: number,
endTime: number,
): Promise<Buffer | null> {
const mpvClient = this.deps.getMpvClient();
if (!mpvClient) {
return null;
}
const timestamp = mpvClient.currentTimePos || 0;
if (this.deps.getConfig().media?.imageType === "avif") {
let imageStart = startTime;
let imageEnd = endTime;
if (!Number.isFinite(imageStart) || !Number.isFinite(imageEnd)) {
const fallback = this.deps.getFallbackDurationSeconds() / 2;
imageStart = timestamp - fallback;
imageEnd = timestamp + fallback;
}
return this.deps.mediaGenerator.generateAnimatedImage(
videoPath,
imageStart,
imageEnd,
this.deps.getConfig().media?.audioPadding,
{
fps: this.deps.getConfig().media?.animatedFps,
maxWidth: this.deps.getConfig().media?.animatedMaxWidth,
maxHeight: this.deps.getConfig().media?.animatedMaxHeight,
crf: this.deps.getConfig().media?.animatedCrf,
},
);
}
return this.deps.mediaGenerator.generateScreenshot(videoPath, timestamp, {
format: this.deps.getConfig().media?.imageFormat as "jpg" | "png" | "webp",
quality: this.deps.getConfig().media?.imageQuality,
maxWidth: this.deps.getConfig().media?.imageMaxWidth,
maxHeight: this.deps.getConfig().media?.imageMaxHeight,
});
}
private generateAudioFilename(): string {
const timestamp = Date.now();
return `audio_${timestamp}.mp3`;
}
private generateImageFilename(): string {
const timestamp = Date.now();
const ext =
this.deps.getConfig().media?.imageType === "avif" ? "avif" : this.deps.getConfig().media?.imageFormat;
return `image_${timestamp}.${ext}`;
}
}