feat(anki): add media timing review before card creation (#203)

This commit is contained in:
2026-09-04 01:40:56 -07:00
committed by GitHub
parent 99266294b8
commit 84f718043a
78 changed files with 7022 additions and 135 deletions
@@ -85,6 +85,7 @@ function createManualUpdateService(overrides: Partial<CardCreationDeps> = {}): {
},
findNotes: async () => [42],
retrieveMediaFile: async () => '',
deleteNotes: async () => undefined,
},
mediaGenerator: {
generateAudio: async () => Buffer.from('audio'),
@@ -128,6 +129,7 @@ function createManualUpdateService(overrides: Partial<CardCreationDeps> = {}): {
}),
getFallbackDurationSeconds: () => 10,
appendKnownWordsFromNoteInfo: () => undefined,
removeKnownWordNote: () => undefined,
isUpdateInProgress: () => false,
setUpdateInProgress: () => undefined,
trackLastAddedNoteId: () => undefined,
@@ -200,6 +202,7 @@ test('manual clipboard word-card update uses configured fields with Lapis and Ki
storeMediaFile: async () => undefined,
findNotes: async () => [42],
retrieveMediaFile: async () => '',
deleteNotes: async () => undefined,
},
getEffectiveSentenceCardConfig: () => ({
model: 'Sentence',
@@ -266,6 +269,7 @@ test('audio-card action keeps Lapis and Kiku sentence fields', async () => {
storeMediaFile: async () => undefined,
findNotes: async () => [42],
retrieveMediaFile: async () => '',
deleteNotes: async () => undefined,
},
getEffectiveSentenceCardConfig: () => ({
model: 'Sentence',
@@ -328,6 +332,7 @@ test('manual clipboard subtitle update marks Kiku word cards as word-and-sentenc
storeMediaFile: async () => undefined,
findNotes: async () => [42],
retrieveMediaFile: async () => '',
deleteNotes: async () => undefined,
},
getEffectiveSentenceCardConfig: () => ({
model: 'Sentence',
@@ -374,6 +379,7 @@ test('manual clipboard subtitle update uses configured audio when SentenceAudio
},
findNotes: async () => [42],
retrieveMediaFile: async () => '',
deleteNotes: async () => undefined,
},
});
@@ -462,6 +468,7 @@ test('manual clipboard subtitle update uses resolved mpv stream URLs for remote
},
findNotes: async () => [42],
retrieveMediaFile: async () => '',
deleteNotes: async () => undefined,
},
mediaGenerator: {
generateAudio: async (path) => {
@@ -510,3 +517,98 @@ test('createSentenceCard relies on Anki progress notification without standalone
assert.deepEqual(progressMessages, ['Creating sentence card']);
assert.deepEqual(statusMessages, []);
});
test('discarding an audio-card timing review deletes the note before evicting its cache entry', async () => {
const events: string[] = [];
const statusMessages: string[] = [];
const { service } = createManualUpdateService({
getMpvClient: () =>
({
currentVideoPath: '/video.mp4',
currentSubText: '字幕',
currentSubStart: 4,
currentSubEnd: 6,
currentTimePos: 5,
}) as never,
client: {
addNote: async () => 0,
addTags: async () => undefined,
notesInfo: async () => [
{
noteId: 42,
fields: { Expression: { value: '単語' } },
},
],
updateNoteFields: async () => undefined,
storeMediaFile: async () => undefined,
findNotes: async () => [42],
retrieveMediaFile: async () => '',
deleteNotes: async (noteIds) => {
events.push(`delete:${noteIds.join(',')}`);
},
},
reviewMediaTiming: async () => ({ action: 'discard' }),
removeKnownWordNote: (noteId) => {
events.push(`cache:${noteId}`);
},
showStatusNotification: (message) => {
statusMessages.push(message);
},
});
await service.markLastCardAsAudioCard();
assert.deepEqual(events, ['delete:42', 'cache:42']);
assert.deepEqual(statusMessages, ['Card deleted.']);
});
test('keeping an audio card without media skips generation and preserves the note', async () => {
let generatedAudio = false;
let deleted = false;
const updates: Array<{ noteId: number; fields: Record<string, string> }> = [];
const { service, storedMedia } = createManualUpdateService({
getMpvClient: () =>
({
currentVideoPath: '/video.mp4',
currentSubText: '字幕',
currentSubStart: 4,
currentSubEnd: 6,
currentTimePos: 5,
}) as never,
client: {
addNote: async () => 0,
addTags: async () => undefined,
notesInfo: async () => [
{
noteId: 42,
fields: { Expression: { value: '単語' }, Sentence: { value: '' } },
},
],
updateNoteFields: async (noteId, fields) => {
updates.push({ noteId, fields });
},
storeMediaFile: async () => undefined,
findNotes: async () => [42],
retrieveMediaFile: async () => '',
deleteNotes: async () => {
deleted = true;
},
},
mediaGenerator: {
generateAudio: async () => {
generatedAudio = true;
return Buffer.from('audio');
},
generateScreenshot: async () => null,
generateAnimatedImage: async () => null,
},
reviewMediaTiming: async () => ({ action: 'skip-media' }),
});
await service.markLastCardAsAudioCard();
assert.equal(generatedAudio, false);
assert.equal(deleted, false);
assert.deepEqual(storedMedia, []);
assert.deepEqual(updates, [{ noteId: 42, fields: { Sentence: '字幕' } }]);
});
@@ -12,6 +12,7 @@ test('sentence card writes generated audio only to sentence audio field', async
const storedMedia: string[] = [];
const requestedProperties: string[] = [];
const audioVolumeScales: Array<number | undefined> = [];
const audioRanges: Array<{ start: number; end: number; padding: number | undefined }> = [];
const deps: CardCreationDeps = {
getConfig: () =>
@@ -73,17 +74,19 @@ test('sentence card writes generated audio only to sentence audio field', async
},
findNotes: async () => [],
retrieveMediaFile: async () => '',
deleteNotes: async () => undefined,
},
mediaGenerator: {
generateAudio: async (
_path,
_startTime,
_endTime,
_audioPadding,
startTime,
endTime,
audioPadding,
_audioStreamIndex,
_normalizeAudio,
volumeScale,
) => {
audioRanges.push({ start: startTime, end: endTime, padding: audioPadding });
audioVolumeScales.push(volumeScale);
return Buffer.from('audio');
},
@@ -121,17 +124,15 @@ test('sentence card writes generated audio only to sentence audio field', async
}),
getFallbackDurationSeconds: () => 10,
appendKnownWordsFromNoteInfo: () => undefined,
removeKnownWordNote: () => undefined,
isUpdateInProgress: () => false,
setUpdateInProgress: () => undefined,
trackLastAddedNoteId: () => undefined,
reviewMediaTiming: async () => ({ action: 'confirm', startTime: 11.4, endTime: 14.2 }),
};
const created = await new CardCreationService(deps).createSentenceCard(
'字幕',
12,
14,
'Subtitle',
);
const service = new CardCreationService(deps);
const created = await service.createSentenceCard('字幕', 12, 14, 'Subtitle');
assert.equal(created, true);
assert.deepEqual(addedFields[0], {
@@ -143,7 +144,19 @@ test('sentence card writes generated audio only to sentence audio field', async
assert.equal(storedMedia.length, 1);
assert.deepEqual(requestedProperties, ['volume']);
assert.deepEqual(audioVolumeScales, [0.4 ** 3]);
assert.deepEqual(audioRanges, [{ start: 11.4, end: 14.2, padding: 0 }]);
const mediaUpdate = updatedFields.find((fields) => 'SentenceAudio' in fields);
assert.equal(mediaUpdate?.SentenceAudio, `[sound:${storedMedia[0]}]`);
assert.equal('ExpressionAudio' in mediaUpdate!, false);
deps.reviewMediaTiming = async () => ({ action: 'discard' });
assert.equal(await service.createSentenceCard('作らない', 20, 22), false);
assert.equal(addedFields.length, 1);
deps.reviewMediaTiming = async () => ({ action: 'skip-media' });
assert.equal(await service.createSentenceCard('メディアなし', 30, 32), true);
assert.equal(addedFields.length, 2);
assert.equal(storedMedia.length, 1);
assert.deepEqual(audioRanges, [{ start: 11.4, end: 14.2, padding: 0 }]);
assert.deepEqual(requestedProperties, ['volume']);
});
@@ -42,6 +42,7 @@ test('CardCreationService counts locally created sentence cards', async () => {
storeMediaFile: async () => undefined,
findNotes: async () => [],
retrieveMediaFile: async () => '',
deleteNotes: async () => undefined,
},
mediaGenerator: {
generateAudio: async () => null,
@@ -73,6 +74,7 @@ test('CardCreationService counts locally created sentence cards', async () => {
}),
getFallbackDurationSeconds: () => 10,
appendKnownWordsFromNoteInfo: () => undefined,
removeKnownWordNote: () => undefined,
isUpdateInProgress: () => false,
setUpdateInProgress: () => undefined,
trackLastAddedNoteId: () => undefined,
@@ -138,6 +140,7 @@ test('CardCreationService keeps updating after trackLastAddedNoteId throws', asy
storeMediaFile: async () => undefined,
findNotes: async () => [],
retrieveMediaFile: async () => '',
deleteNotes: async () => undefined,
},
mediaGenerator: {
generateAudio: async () => null,
@@ -171,6 +174,7 @@ test('CardCreationService keeps updating after trackLastAddedNoteId throws', asy
}),
getFallbackDurationSeconds: () => 10,
appendKnownWordsFromNoteInfo: () => undefined,
removeKnownWordNote: () => undefined,
isUpdateInProgress: () => false,
setUpdateInProgress: () => undefined,
trackLastAddedNoteId: () => {
@@ -236,6 +240,7 @@ test('CardCreationService keeps updating after recordCardsMinedCallback throws',
storeMediaFile: async () => undefined,
findNotes: async () => [],
retrieveMediaFile: async () => '',
deleteNotes: async () => undefined,
},
mediaGenerator: {
generateAudio: async () => null,
@@ -269,6 +274,7 @@ test('CardCreationService keeps updating after recordCardsMinedCallback throws',
}),
getFallbackDurationSeconds: () => 10,
appendKnownWordsFromNoteInfo: () => undefined,
removeKnownWordNote: () => undefined,
isUpdateInProgress: () => false,
setUpdateInProgress: () => undefined,
recordCardsMinedCallback: () => {
@@ -345,6 +351,7 @@ test('CardCreationService uses stream-open-filename for remote media generation'
storeMediaFile: async () => undefined,
findNotes: async () => [],
retrieveMediaFile: async () => '',
deleteNotes: async () => undefined,
},
mediaGenerator: {
generateAudio: async (path) => {
@@ -388,6 +395,7 @@ test('CardCreationService uses stream-open-filename for remote media generation'
}),
getFallbackDurationSeconds: () => 10,
appendKnownWordsFromNoteInfo: () => undefined,
removeKnownWordNote: () => undefined,
isUpdateInProgress: () => false,
setUpdateInProgress: () => undefined,
trackLastAddedNoteId: () => undefined,
@@ -450,6 +458,7 @@ test('CardCreationService does not use mpv stream indexes for ready cached YouTu
storeMediaFile: async () => undefined,
findNotes: async () => [],
retrieveMediaFile: async () => '',
deleteNotes: async () => undefined,
},
mediaGenerator: {
generateAudio: async (path, _startTime, _endTime, _padding, audioStreamIndex) => {
@@ -490,6 +499,7 @@ test('CardCreationService does not use mpv stream indexes for ready cached YouTu
}),
getFallbackDurationSeconds: () => 10,
appendKnownWordsFromNoteInfo: () => undefined,
removeKnownWordNote: () => undefined,
isUpdateInProgress: () => false,
setUpdateInProgress: () => undefined,
trackLastAddedNoteId: () => undefined,
@@ -585,6 +595,7 @@ test('CardCreationService queues YouTube media when required cache is not ready'
storeMediaFile: async () => undefined,
findNotes: async () => [],
retrieveMediaFile: async () => '',
deleteNotes: async () => undefined,
},
mediaGenerator: {
generateAudio: async () => {
@@ -628,6 +639,7 @@ test('CardCreationService queues YouTube media when required cache is not ready'
}),
getFallbackDurationSeconds: () => 10,
appendKnownWordsFromNoteInfo: () => undefined,
removeKnownWordNote: () => undefined,
isUpdateInProgress: () => false,
setUpdateInProgress: () => undefined,
trackLastAddedNoteId: () => undefined,
@@ -695,6 +707,7 @@ test('CardCreationService tracks pre-add duplicate note ids for kiku sentence ca
storeMediaFile: async () => undefined,
findNotes: async () => [],
retrieveMediaFile: async () => '',
deleteNotes: async () => undefined,
},
mediaGenerator: {
generateAudio: async () => null,
@@ -726,6 +739,7 @@ test('CardCreationService tracks pre-add duplicate note ids for kiku sentence ca
}),
getFallbackDurationSeconds: () => 10,
appendKnownWordsFromNoteInfo: () => undefined,
removeKnownWordNote: () => undefined,
isUpdateInProgress: () => false,
setUpdateInProgress: () => undefined,
trackLastAddedNoteId: () => undefined,
@@ -783,6 +797,7 @@ test('CardCreationService does not track duplicate ids when pre-add lookup retur
storeMediaFile: async () => undefined,
findNotes: async () => [],
retrieveMediaFile: async () => '',
deleteNotes: async () => undefined,
},
mediaGenerator: {
generateAudio: async () => null,
@@ -814,6 +829,7 @@ test('CardCreationService does not track duplicate ids when pre-add lookup retur
}),
getFallbackDurationSeconds: () => 10,
appendKnownWordsFromNoteInfo: () => undefined,
removeKnownWordNote: () => undefined,
isUpdateInProgress: () => false,
setUpdateInProgress: () => undefined,
trackLastAddedNoteId: () => undefined,
+99 -24
View File
@@ -3,7 +3,13 @@ import {
getConfiguredWordFieldName,
getPreferredWordValueFromExtractedFields,
} from '../anki-field-config';
import { AnkiConnectConfig, type CardKind, type WordCardKind } from '../types/anki';
import {
AnkiConnectConfig,
type CardKind,
type MediaTimingReviewDecision,
type MediaTimingReviewRequest,
type WordCardKind,
} from '../types/anki';
import { createLogger } from '../logger';
import type { MediaInput } from '../media-input';
import { SubtitleTimingTracker } from '../subtitle-timing-tracker';
@@ -55,6 +61,7 @@ interface CardCreationClient {
storeMediaFile(filename: string, data: Buffer): Promise<void>;
findNotes(query: string, options?: { maxRetries?: number }): Promise<number[]>;
retrieveMediaFile(filename: string): Promise<string>;
deleteNotes(noteIds: number[]): Promise<void>;
}
interface CardCreationMediaGenerator {
@@ -137,12 +144,16 @@ interface CardCreationDeps {
};
getFallbackDurationSeconds: () => number;
appendKnownWordsFromNoteInfo: (noteInfo: CardCreationNoteInfo) => void;
removeKnownWordNote: (noteId: number) => void;
isUpdateInProgress: () => boolean;
setUpdateInProgress: (value: boolean) => void;
trackLastAddedNoteId?: (noteId: number) => void;
trackLastAddedDuplicateNoteIds?: (noteId: number, duplicateNoteIds: number[]) => void;
findDuplicateNoteIds?: (expression: string, noteInfo: CardCreationNoteInfo) => Promise<number[]>;
recordCardsMinedCallback?: (count: number, noteIds?: number[]) => void;
reviewMediaTiming?: (
request: Omit<MediaTimingReviewRequest, 'audioPadding' | 'maxMediaDuration'>,
) => Promise<MediaTimingReviewDecision>;
}
export class CardCreationService {
@@ -456,6 +467,30 @@ export class CardCreationService {
this.deps.getConfig(),
);
const timingDecision = this.deps.reviewMediaTiming
? await this.deps.reviewMediaTiming({
kind: 'audio',
text: mpvClient.currentSubText,
startTime,
endTime,
noteId,
})
: ({ action: 'use-original' } as const);
if (timingDecision.action === 'discard') {
await this.deps.client.deleteNotes([noteId]);
this.deps.removeKnownWordNote(noteId);
this.deps.showStatusNotification('Card deleted.');
return;
}
const skipMedia = timingDecision.action === 'skip-media';
const exactReviewedRange = timingDecision.action === 'confirm';
let sentenceText = mpvClient.currentSubText;
if (timingDecision.action === 'confirm') {
startTime = timingDecision.startTime;
endTime = timingDecision.endTime;
sentenceText = timingDecision.text?.trim() || sentenceText;
}
const updatedFields: Record<string, string> = {};
const errors: string[] = [];
let miscInfoFilename: string | null = null;
@@ -465,30 +500,33 @@ export class CardCreationService {
const sentenceCardConfig = this.deps.getEffectiveSentenceCardConfig();
const sentenceField = sentenceCardConfig.sentenceField;
if (sentenceField) {
const processedSentence = this.deps.processSentence(mpvClient.currentSubText, fields);
const processedSentence = this.deps.processSentence(sentenceText, fields);
updatedFields[sentenceField] = processedSentence;
}
const audioFieldName = sentenceCardConfig.audioField;
try {
const audioFilename = this.generateAudioFilename();
const audioBuffer = await this.mediaGenerateAudio(
mpvClient.currentVideoPath,
startTime,
endTime,
);
if (!skipMedia) {
try {
const audioFilename = this.generateAudioFilename();
const audioBuffer = await this.mediaGenerateAudio(
mpvClient.currentVideoPath,
startTime,
endTime,
exactReviewedRange ? 0 : undefined,
);
if (audioBuffer) {
await this.deps.client.storeMediaFile(audioFilename, audioBuffer);
updatedFields[audioFieldName] = `[sound:${audioFilename}]`;
miscInfoFilename = audioFilename;
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');
}
} catch (error) {
log.error('Failed to generate audio for audio card:', (error as Error).message);
errors.push('audio');
}
if (shouldGenerateImage(this.deps.getConfig())) {
if (!skipMedia && shouldGenerateImage(this.deps.getConfig())) {
try {
const animatedLeadInSeconds = await this.deps.getAnimatedImageLeadInSeconds(noteInfo);
const imageFilename = this.generateImageFilename();
@@ -497,6 +535,7 @@ export class CardCreationService {
startTime,
endTime,
animatedLeadInSeconds,
exactReviewedRange,
);
const imageField = this.deps.getConfig().fields?.image;
@@ -569,9 +608,29 @@ export class CardCreationService {
try {
return await this.deps.withUpdateProgress('Creating sentence card', async () => {
const timingDecision = this.deps.reviewMediaTiming
? await this.deps.reviewMediaTiming({
kind: 'sentence',
text: sentence,
startTime,
endTime,
})
: ({ action: 'use-original' } as const);
if (timingDecision.action === 'discard') {
this.deps.showStatusNotification('Card creation cancelled.');
return false;
}
const skipMedia = timingDecision.action === 'skip-media';
const exactReviewedRange = timingDecision.action === 'confirm';
if (timingDecision.action === 'confirm') {
startTime = timingDecision.startTime;
endTime = timingDecision.endTime;
sentence = timingDecision.text?.trim() || sentence;
}
const config = this.deps.getConfig();
const generateAudio = shouldGenerateAudio(config);
const generateImage = shouldGenerateImage(config);
const generateAudio = !skipMedia && shouldGenerateAudio(config);
const generateImage = !skipMedia && shouldGenerateImage(config);
const mediaResolverOptions = this.getMediaResolverOptions();
const videoPath = generateImage
? await resolveMediaGenerationInput(mpvClient, 'video', mediaResolverOptions)
@@ -736,6 +795,7 @@ export class CardCreationService {
generateAudio,
generateImage,
volumeScale,
...(exactReviewedRange ? { mediaPaddingSeconds: 0 } : {}),
});
await this.deps.showNotification(noteId, label, 'media queued');
return true;
@@ -751,7 +811,12 @@ export class CardCreationService {
try {
const audioFilename = this.generateAudioFilename();
const audioBuffer = audioSourcePath
? await this.mediaGenerateAudio(audioSourcePath, startTime, endTime)
? await this.mediaGenerateAudio(
audioSourcePath,
startTime,
endTime,
exactReviewedRange ? 0 : undefined,
)
: null;
if (audioBuffer) {
@@ -769,7 +834,13 @@ export class CardCreationService {
if (generateImage) {
try {
const imageFilename = this.generateImageFilename();
const imageBuffer = await this.generateImageBuffer(videoPath!, startTime, endTime);
const imageBuffer = await this.generateImageBuffer(
videoPath!,
startTime,
endTime,
0,
exactReviewedRange,
);
const imageField = config.fields?.image;
if (imageBuffer && imageField) {
@@ -821,6 +892,7 @@ export class CardCreationService {
videoPath: MediaInput,
startTime: number,
endTime: number,
audioPaddingOverride?: number,
): Promise<Buffer | null> {
const mpvClient = this.deps.getMpvClient();
if (!mpvClient) {
@@ -831,7 +903,7 @@ export class CardCreationService {
videoPath,
startTime,
endTime,
this.deps.getConfig().media?.audioPadding,
audioPaddingOverride ?? this.deps.getConfig().media?.audioPadding,
resolveAudioStreamIndexForMediaGeneration(
videoPath,
mpvClient.currentAudioStreamIndex ?? undefined,
@@ -849,13 +921,16 @@ export class CardCreationService {
startTime: number,
endTime: number,
animatedLeadInSeconds = 0,
exactReviewedRange = false,
): Promise<Buffer | null> {
const mpvClient = this.deps.getMpvClient();
if (!mpvClient) {
return null;
}
const timestamp = mpvClient.currentTimePos || 0;
const timestamp = exactReviewedRange
? startTime + (endTime - startTime) / 2
: mpvClient.currentTimePos || 0;
if (this.deps.getConfig().media?.imageType === 'avif') {
let imageStart = startTime;
@@ -871,7 +946,7 @@ export class CardCreationService {
videoPath,
imageStart,
imageEnd,
this.deps.getConfig().media?.audioPadding,
exactReviewedRange ? 0 : this.deps.getConfig().media?.audioPadding,
{
fps: this.deps.getConfig().media?.animatedFps,
maxWidth: this.deps.getConfig().media?.animatedMaxWidth,
@@ -261,6 +261,32 @@ test('KnownWordCacheManager invalidates persisted cache when fields.word changes
}
});
test('KnownWordCacheManager removes a deleted note from memory and persisted state', () => {
const config: AnkiConnectConfig = {
fields: { word: 'Word' },
knownWords: { highlightEnabled: true },
};
const { manager, statePath, cleanup } = createKnownWordCacheHarness(config);
try {
manager.appendFromNoteInfo({
noteId: 42,
fields: { Word: { value: '猫' } },
});
assert.equal(manager.removeNote(42), true);
assert.equal(manager.removeNote(42), false);
assert.equal(manager.isKnownWord('猫'), false);
const persisted = JSON.parse(fs.readFileSync(statePath, 'utf-8')) as {
notes?: Record<string, unknown>;
};
assert.deepEqual(persisted.notes, {});
} finally {
cleanup();
}
});
test('KnownWordCacheManager refresh incrementally reconciles deleted and edited note words', async () => {
const config: AnkiConnectConfig = {
fields: {
+11
View File
@@ -350,6 +350,17 @@ export class KnownWordCacheManager {
return true;
}
removeNote(noteId: number): boolean {
if (!this.noteEntriesById.has(noteId)) {
return false;
}
this.removeNoteSnapshot(noteId);
this.persistKnownWordCacheState();
log.info('Known-word cache removed deleted note', `noteId=${noteId}`);
return true;
}
clearKnownWordCacheState(): void {
this.clearInMemoryState();
this.knownWordsStateKey = this.getKnownWordCacheStateKey();
@@ -44,6 +44,7 @@ function createWorkflowHarness() {
updates.push({ noteId, fields });
},
storeMediaFile: async () => undefined,
deleteNotes: async () => undefined,
},
getConfig: () => ({
fields: {
@@ -61,6 +62,7 @@ function createWorkflowHarness() {
fieldGroupingMode: 'disabled' as const,
}),
appendKnownWordsFromNoteInfo: (_noteInfo: NoteUpdateWorkflowNoteInfo) => undefined,
removeKnownWordNote: (_noteId: number) => undefined,
extractFields: (fields: Record<string, { value: string }>) => {
const out: Record<string, string> = {};
for (const [key, value] of Object.entries(fields)) {
@@ -634,3 +636,141 @@ test('NoteUpdateWorkflow queues media updates when YouTube cache is pending', as
assert.equal(queuedUpdates[0]?.context, undefined);
assert.deepEqual(harness.updates, [{ noteId: 42, fields: { Sentence: 'subtitle-text' } }]);
});
test('NoteUpdateWorkflow deletes an existing word card when timing review discards it', async () => {
const harness = createWorkflowHarness();
const deletedNoteIds: number[][] = [];
const removedKnownWordNoteIds: number[] = [];
let appendedKnownWords = false;
harness.deps.captureSubtitleMediaContext = () => ({
source: 'overlay',
text: 'subtitle-text',
startTime: 4,
endTime: 6,
});
harness.deps.client.deleteNotes = async (noteIds) => {
deletedNoteIds.push(noteIds);
};
harness.deps.appendKnownWordsFromNoteInfo = () => {
appendedKnownWords = true;
};
harness.deps.removeKnownWordNote = (noteId) => {
removedKnownWordNoteIds.push(noteId);
};
harness.deps.reviewMediaTiming = async () => ({ action: 'discard' });
await harness.workflow.execute(42);
assert.deepEqual(deletedNoteIds, [[42]]);
assert.deepEqual(removedKnownWordNoteIds, [42]);
assert.equal(appendedKnownWords, false);
assert.deepEqual(harness.updates, []);
assert.deepEqual(harness.notifications, []);
});
test('NoteUpdateWorkflow keeps the word card but skips media after timing review', async () => {
const harness = createWorkflowHarness();
const mediaCalls: string[] = [];
const deletedNoteIds: number[][] = [];
const queuedUpdates: unknown[] = [];
harness.deps.captureSubtitleMediaContext = () => ({
source: 'overlay',
text: 'subtitle-text',
startTime: 4,
endTime: 6,
});
harness.deps.getConfig = () => ({
fields: { sentence: 'Sentence', image: 'Picture' },
media: { generateAudio: true, generateImage: true },
behavior: {},
});
harness.deps.reviewMediaTiming = async () => ({ action: 'skip-media' });
harness.deps.generateAudio = async () => {
mediaCalls.push('audio');
return Buffer.from('audio');
};
harness.deps.generateImage = async () => {
mediaCalls.push('image');
return Buffer.from('image');
};
harness.deps.queuePendingYoutubeMediaUpdate = async (update) => {
queuedUpdates.push(update);
return true;
};
harness.deps.client.deleteNotes = async (noteIds) => {
deletedNoteIds.push(noteIds);
};
await harness.workflow.execute(42);
assert.deepEqual(mediaCalls, []);
assert.deepEqual(queuedUpdates, []);
assert.deepEqual(deletedNoteIds, []);
assert.deepEqual(harness.updates, [{ noteId: 42, fields: { Sentence: 'subtitle-text' } }]);
assert.deepEqual(harness.notifications, [{ noteId: 42, label: 'taberu' }]);
});
test('NoteUpdateWorkflow uses the combined review sentence for the card and media range', async () => {
const harness = createWorkflowHarness();
const audioContexts: Array<SubtitleMiningContext | undefined> = [];
harness.deps.captureSubtitleMediaContext = () => ({
source: 'overlay',
text: 'current-line',
startTime: 4,
endTime: 6,
});
harness.deps.getConfig = () => ({
fields: { sentence: 'Sentence' },
media: { generateAudio: true, generateImage: false },
behavior: {},
});
harness.deps.reviewMediaTiming = async () => ({
action: 'confirm',
startTime: 2,
endTime: 7,
text: 'previous-line current-line next-line',
});
harness.deps.generateAudio = async (context) => {
audioContexts.push(context);
return null;
};
await harness.workflow.execute(42);
assert.deepEqual(harness.updates, [
{ noteId: 42, fields: { Sentence: 'previous-line current-line next-line' } },
]);
assert.equal(audioContexts.length, 1);
assert.equal(audioContexts[0]?.text, 'previous-line current-line next-line');
assert.equal(audioContexts[0]?.startTime, 2);
assert.equal(audioContexts[0]?.endTime, 7);
assert.equal(audioContexts[0]?.mediaPaddingSeconds, 0);
});
test('NoteUpdateWorkflow keeps cache unchanged and reports when deletion fails', async () => {
const harness = createWorkflowHarness();
const statusMessages: string[] = [];
let removedKnownWord = false;
harness.deps.captureSubtitleMediaContext = () => ({
source: 'overlay',
text: 'subtitle-text',
startTime: 4,
endTime: 6,
});
harness.deps.client.deleteNotes = async () => {
throw new Error('delete failed');
};
harness.deps.removeKnownWordNote = () => {
removedKnownWord = true;
};
harness.deps.showOsdNotification = (message) => {
statusMessages.push(message);
};
harness.deps.reviewMediaTiming = async () => ({ action: 'discard' });
await harness.workflow.execute(42);
assert.equal(removedKnownWord, false);
assert.deepEqual(statusMessages, ['Card deletion failed: delete failed']);
assert.ok(harness.warnings.length === 0);
});
+57 -6
View File
@@ -1,7 +1,12 @@
import { DEFAULT_ANKI_CONNECT_CONFIG } from '../config';
import { getPreferredWordValueFromExtractedFields } from '../anki-field-config';
import type { SubtitleMiningContext } from '../types/subtitle';
import type { CardKind, WordCardKind } from '../types/anki';
import type {
CardKind,
MediaTimingReviewDecision,
MediaTimingReviewRequest,
WordCardKind,
} from '../types/anki';
import { resolveWordCardKind } from './note-field-utils';
export interface NoteUpdateWorkflowNoteInfo {
@@ -14,6 +19,7 @@ export interface NoteUpdateWorkflowDeps {
notesInfo(noteIds: number[]): Promise<unknown>;
updateNoteFields(noteId: number, fields: Record<string, string>): Promise<void>;
storeMediaFile(filename: string, data: Buffer): Promise<void>;
deleteNotes(noteIds: number[]): Promise<void>;
};
getConfig: () => {
fields?: {
@@ -44,6 +50,7 @@ export interface NoteUpdateWorkflowDeps {
wordCardKind?: WordCardKind;
};
appendKnownWordsFromNoteInfo: (noteInfo: NoteUpdateWorkflowNoteInfo) => void;
removeKnownWordNote: (noteId: number) => void;
extractFields: (fields: Record<string, { value: string }>) => Record<string, string>;
findDuplicateNote: (
expression: string,
@@ -102,6 +109,9 @@ export interface NoteUpdateWorkflowDeps {
logWarn: (message: string, ...args: unknown[]) => void;
logInfo: (message: string, ...args: unknown[]) => void;
logError: (message: string, ...args: unknown[]) => void;
reviewMediaTiming?: (
request: Omit<MediaTimingReviewRequest, 'audioPadding' | 'maxMediaDuration'>,
) => Promise<MediaTimingReviewDecision>;
}
function normalizeSubtitleContextText(text: string): string {
@@ -171,7 +181,6 @@ export class NoteUpdateWorkflow {
}
const noteInfo = notesInfo[0]!;
this.deps.appendKnownWordsFromNoteInfo(noteInfo);
const fields = this.deps.extractFields(noteInfo.fields);
const config = this.deps.getConfig();
@@ -207,11 +216,53 @@ export class NoteUpdateWorkflow {
// Audio and image generation run sequentially and audio extraction can take tens of
// seconds, so resolve the clip range exactly once up front; reading live mpv sub
// timings per generator clips whichever line is on screen when each one starts.
const mediaTimingContext =
let mediaTimingContext =
subtitleMiningContext ?? this.deps.captureSubtitleMediaContext?.() ?? null;
let skipMedia = false;
let reviewedSentenceText: string | undefined;
const noteLabel = hasExpressionText ? expressionText : noteId;
const currentSubtitleText = subtitleMiningContext?.text ?? this.deps.getCurrentSubtitleText();
if (mediaTimingContext) {
const timingDecision = this.deps.reviewMediaTiming
? await this.deps.reviewMediaTiming({
kind: 'word',
text: mediaTimingContext.text,
startTime: mediaTimingContext.startTime,
endTime: mediaTimingContext.endTime,
noteId,
})
: ({ action: 'use-original' } as const);
if (timingDecision.action === 'discard') {
try {
await this.deps.client.deleteNotes([noteId]);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
this.deps.logError('Failed to delete discarded card:', message);
this.deps.showOsdNotification(`Card deletion failed: ${message}`);
return;
}
this.deps.removeKnownWordNote(noteId);
this.deps.showOsdNotification('Card deleted.');
return;
}
if (timingDecision.action === 'confirm') {
reviewedSentenceText = timingDecision.text?.trim() || undefined;
mediaTimingContext = {
...mediaTimingContext,
...(reviewedSentenceText !== undefined ? { text: reviewedSentenceText } : {}),
startTime: timingDecision.startTime,
endTime: timingDecision.endTime,
mediaPaddingSeconds: 0,
};
} else if (timingDecision.action === 'skip-media') {
skipMedia = true;
}
}
this.deps.appendKnownWordsFromNoteInfo(noteInfo);
const currentSubtitleText =
reviewedSentenceText ?? subtitleMiningContext?.text ?? this.deps.getCurrentSubtitleText();
if (sentenceField && currentSubtitleText) {
const processedSentence = this.deps.processSentence(currentSubtitleText, fields);
updatedFields[sentenceField] = processedSentence;
@@ -239,8 +290,8 @@ export class NoteUpdateWorkflow {
}
}
const generateAudio = config.media?.generateAudio !== false;
const generateImage = config.media?.generateImage !== false;
const generateAudio = !skipMedia && config.media?.generateAudio !== false;
const generateImage = !skipMedia && config.media?.generateImage !== false;
const mediaCacheQueued =
(generateAudio || generateImage) && this.deps.queuePendingYoutubeMediaUpdate
? await this.deps.queuePendingYoutubeMediaUpdate({
@@ -147,6 +147,9 @@ export class PendingYoutubeMediaQueue {
generateAudio: shouldGenerateAudio(config),
generateImage: shouldGenerateImage(config),
volumeScale,
...(job.context?.mediaPaddingSeconds !== undefined
? { mediaPaddingSeconds: job.context.mediaPaddingSeconds }
: {}),
});
return true;
}
@@ -282,7 +285,7 @@ export class PendingYoutubeMediaQueue {
cachedMediaInput,
job.startTime,
job.endTime,
config.media?.audioPadding,
job.mediaPaddingSeconds ?? config.media?.audioPadding,
undefined,
config.media?.normalizeAudio !== false,
job.volumeScale,
@@ -316,6 +319,7 @@ export class PendingYoutubeMediaQueue {
job.startTime,
job.endTime,
animatedLeadInSeconds,
job.mediaPaddingSeconds,
);
if (imageBuffer) {
await this.deps.client.storeMediaFile(imageFilename, imageBuffer);
@@ -376,6 +380,7 @@ export class PendingYoutubeMediaQueue {
startTime: number,
endTime: number,
animatedLeadInSeconds = 0,
mediaPaddingSeconds?: number,
): Promise<Buffer | null> {
const config = this.deps.getConfig();
if (config.media?.imageType === 'avif') {
@@ -383,7 +388,7 @@ export class PendingYoutubeMediaQueue {
videoPath,
startTime,
endTime,
config.media?.audioPadding,
mediaPaddingSeconds ?? config.media?.audioPadding,
{
fps: config.media?.animatedFps,
maxWidth: config.media?.animatedMaxWidth,
@@ -10,6 +10,7 @@ export interface PendingYoutubeMediaUpdate {
generateAudio: boolean;
generateImage: boolean;
volumeScale?: number;
mediaPaddingSeconds?: number;
}
function trimToNonEmptyString(value: unknown): string | null {