fix(anki): honor unlimited duration in stats mining (#258)

This commit is contained in:
2026-09-20 22:49:12 -07:00
committed by GitHub
parent 917d52dd94
commit 7959e1a4e5
11 changed files with 130 additions and 13 deletions
@@ -160,6 +160,52 @@ test('manual clipboard subtitle update replaces audio in the configured field',
);
});
test('manual clipboard mining treats a zero media duration cap as unlimited', async () => {
const audioRanges: Array<{ start: number; end: number; padding: number | undefined }> = [];
const scenarios = [
{ maxMediaDuration: 0, expectedEnd: 14 },
{ maxMediaDuration: 1, expectedEnd: 13 },
];
for (const scenario of scenarios) {
const { service } = createManualUpdateService({
getConfig: () =>
({
deck: 'Mining',
fields: {
word: 'Expression',
sentence: 'Sentence',
audio: 'ExpressionAudio',
},
media: {
generateAudio: true,
generateImage: false,
audioPadding: 0.25,
maxMediaDuration: scenario.maxMediaDuration,
},
behavior: {},
ai: false,
}) as AnkiConnectConfig,
mediaGenerator: {
generateAudio: async (_path, start, end, padding) => {
audioRanges.push({ start, end, padding });
return Buffer.from('audio');
},
generateScreenshot: async () => null,
generateAnimatedImage: async () => null,
},
});
await service.updateLastAddedFromClipboard('字幕');
assert.deepEqual(audioRanges.at(-1), {
start: 12,
end: scenario.expectedEnd,
padding: 0.25,
});
}
});
test('manual clipboard word-card update uses configured fields with Lapis and Kiku enabled', async () => {
const { service, updatedFields } = createManualUpdateService({
getConfig: () =>
+8 -7
View File
@@ -21,6 +21,7 @@ import {
resolveAudioStreamIndexForMediaGeneration,
type MediaGenerationInputResolverOptions,
} from './media-source';
import { clampMediaEndTime } from './media-duration';
import { resolveWordCardKind } from './note-field-utils';
import type { PendingYoutubeMediaUpdate } from './pending-youtube-media';
import { resolveMpvVolumeScale } from './mpv-volume';
@@ -233,11 +234,12 @@ export class CardCreationService {
let rangeEnd = Math.max(...timings.map((entry) => entry.endTime));
const maxMediaDuration = this.deps.getConfig().media?.maxMediaDuration ?? 30;
if (maxMediaDuration > 0 && rangeEnd - rangeStart > maxMediaDuration) {
const cappedRangeEnd = clampMediaEndTime(rangeStart, rangeEnd, maxMediaDuration);
if (cappedRangeEnd !== rangeEnd) {
log.warn(
`Media range ${(rangeEnd - rangeStart).toFixed(1)}s exceeds cap of ${maxMediaDuration}s, clamping`,
);
rangeEnd = rangeStart + maxMediaDuration;
rangeEnd = cappedRangeEnd;
}
this.deps.showOsdNotification('Updating card from clipboard...');
@@ -437,9 +439,7 @@ export class CardCreationService {
}
const maxMediaDuration = this.deps.getConfig().media?.maxMediaDuration ?? 30;
if (maxMediaDuration > 0 && endTime - startTime > maxMediaDuration) {
endTime = startTime + maxMediaDuration;
}
endTime = clampMediaEndTime(startTime, endTime, maxMediaDuration);
this.deps.showOsdNotification('Marking card as audio card...');
await this.deps.withUpdateProgress('Marking audio card', async () => {
@@ -600,11 +600,12 @@ export class CardCreationService {
}
const maxMediaDuration = this.deps.getConfig().media?.maxMediaDuration ?? 30;
if (maxMediaDuration > 0 && endTime - startTime > maxMediaDuration) {
const cappedEndTime = clampMediaEndTime(startTime, endTime, maxMediaDuration);
if (cappedEndTime !== endTime) {
log.warn(
`Sentence card media range ${(endTime - startTime).toFixed(1)}s exceeds cap of ${maxMediaDuration}s, clamping`,
);
endTime = startTime + maxMediaDuration;
endTime = cappedEndTime;
}
try {
+10
View File
@@ -0,0 +1,10 @@
/** Zero or a negative cap leaves the requested end time unchanged. */
export function clampMediaEndTime(
startTime: number,
endTime: number,
maxMediaDuration: number,
): number {
return maxMediaDuration > 0 && endTime - startTime > maxMediaDuration
? startTime + maxMediaDuration
: endTime;
}
@@ -295,7 +295,7 @@ export function buildIntegrationConfigOptionRegistry(
path: 'ankiConnect.media.maxMediaDuration',
kind: 'number',
defaultValue: defaultConfig.ankiConnect.media.maxMediaDuration,
description: 'Maximum allowed media clip duration in seconds.',
description: 'Maximum allowed media clip duration in seconds. 0 disables the cap.',
},
{
path: 'ankiConnect.knownWords.matchMode',
@@ -1762,6 +1762,60 @@ describe('stats server API routes', () => {
});
});
it('POST /api/stats/mine-card treats a zero media duration cap as unlimited', async () => {
await withTempDir(async (dir) => {
const sourcePath = path.join(dir, 'episode.mkv');
fs.writeFileSync(sourcePath, 'fake media');
const audioRanges: Array<{ start: number; end: number; padding: number | undefined }> = [];
const scenarios = [
{ maxMediaDuration: 0, expectedEnd: 12 },
{ maxMediaDuration: 1, expectedEnd: 11 },
];
for (const scenario of scenarios) {
const app = createStatsApp(createMockTracker(), {
addYomitanNote: async () => null,
createMediaGenerator: () => ({
generateAudio: async (_path, start, end, padding) => {
audioRanges.push({ start, end, padding });
return Buffer.from('audio');
},
generateScreenshot: async () => null,
generateAnimatedImage: async () => null,
}),
ankiConnectConfig: {
deck: 'Mining',
media: {
generateAudio: true,
generateImage: false,
audioPadding: 0.25,
maxMediaDuration: scenario.maxMediaDuration,
},
},
});
const res = await app.request('/api/stats/mine-card?mode=word', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
sourcePath,
startMs: 10_000,
endMs: 12_000,
sentence: '猫を見た',
word: '猫',
}),
});
assert.equal(res.status, 502);
assert.deepEqual(audioRanges.at(-1), {
start: 10,
end: scenario.expectedEnd,
padding: 0.25,
});
}
});
});
it('POST /api/stats/mine-card requires a non-empty word in word mode', async () => {
await withTempDir(async (dir) => {
const sourcePath = path.join(dir, 'episode.mkv');
@@ -4,6 +4,7 @@ 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 { clampMediaEndTime } from '../../../anki-integration/media-duration.js';
import { MediaGenerator } from '../../../media-generator.js';
import { statsJson } from '../../../types/stats-http-contract.js';
import {
@@ -113,8 +114,7 @@ export function registerStatsMiningRoutes(app: Hono, options?: StatsMiningRouteO
const startSec = startMs / 1000;
const endSec = endMs / 1000;
const rawDuration = endSec - startSec;
const clampedEndSec = rawDuration > maxMediaDuration ? startSec + maxMediaDuration : endSec;
const clampedEndSec = clampMediaEndTime(startSec, endSec, maxMediaDuration);
const highlightedSentence = word
? sentence.replace(