mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-11-10 04:46:07 -08:00
1039 lines
36 KiB
TypeScript
1039 lines
36 KiB
TypeScript
import test from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import http from 'node:http';
|
|
import { once } from 'node:events';
|
|
import * as fs from 'fs';
|
|
import * as os from 'os';
|
|
import * as path from 'path';
|
|
import { AnkiIntegration } from './anki-integration';
|
|
import type { PluginMinedNote, PluginMiningProvenance } from './plugin-api';
|
|
import type { MediaInput } from './media-input';
|
|
import { AnkiConnectConfig } from './types';
|
|
|
|
type TestOverlayNotificationPayload = {
|
|
id?: string;
|
|
title: string;
|
|
body?: string;
|
|
image?: string;
|
|
variant?: string;
|
|
persistent?: boolean;
|
|
actions?: Array<{ id: string; label: string; noteId?: number }>;
|
|
};
|
|
|
|
type GenerateAudioFn = (
|
|
path: MediaInput,
|
|
startTime: number,
|
|
endTime: number,
|
|
audioPadding?: number,
|
|
audioStreamIndex?: number,
|
|
normalizeAudio?: boolean,
|
|
volumeScale?: number,
|
|
) => Promise<Buffer>;
|
|
|
|
type PendingYoutubeJob = {
|
|
sourceUrl: string;
|
|
noteId: number;
|
|
startTime: number;
|
|
endTime: number;
|
|
label: string | number;
|
|
audioStreamIndex?: number;
|
|
audioFieldName?: string;
|
|
imageFieldName?: string;
|
|
generateAudio: boolean;
|
|
generateImage: boolean;
|
|
volumeScale?: number;
|
|
};
|
|
|
|
/**
|
|
* The private members these tests swap or call. AnkiIntegration has no constructor seam for its
|
|
* AnkiConnect client or media generator, so tests replace them after construction.
|
|
*/
|
|
type IntegrationInternals = {
|
|
client: {
|
|
findNotes?: () => Promise<number[]>;
|
|
notesInfo?: (noteIds: number[]) => Promise<unknown[]>;
|
|
updateNoteFields?: (noteId: number, fields: Record<string, string>) => Promise<void>;
|
|
storeMediaFile?: (filename: string, data: Buffer) => Promise<void>;
|
|
};
|
|
mediaGenerator: {
|
|
generateAudio?: GenerateAudioFn;
|
|
generateScreenshot?: (path: MediaInput) => Promise<Buffer>;
|
|
generateNotificationIcon?: (videoPath: string, timestamp: number) => Promise<Buffer>;
|
|
writeNotificationIconToFile?: (iconBuffer: Buffer, noteId: number) => string;
|
|
scheduleNotificationIconCleanup?: (filePath: string) => void;
|
|
};
|
|
config: AnkiConnectConfig;
|
|
cardCreationService: { createSentenceCard: () => Promise<boolean> };
|
|
fieldGroupingService: { triggerFieldGroupingForLastAddedCard: () => Promise<void> };
|
|
showNotification: (noteId: number, label: string | number, errorSuffix?: string) => Promise<void>;
|
|
beginUpdateProgress: (message: string) => void;
|
|
endUpdateProgress: () => void;
|
|
processSentence: (sentence: string, fields: Record<string, string>) => string;
|
|
processSentenceFurigana: (sentence: string, fields: Record<string, string>) => string;
|
|
queuePendingYoutubeMediaUpdate: (job: PendingYoutubeJob) => void;
|
|
queuePendingYoutubeMediaUpdateForNote: (job: {
|
|
noteId: number;
|
|
noteInfo: { noteId: number; fields: Record<string, { value: string }> };
|
|
label: string | number;
|
|
}) => Promise<boolean>;
|
|
generateAudio: () => Promise<Buffer | null>;
|
|
formatMiscInfoPattern: (fallbackFilename: string, startTimeSeconds?: number) => string;
|
|
appendKnownWordsFromNoteInfo: (noteInfo: {
|
|
noteId: number;
|
|
fields: Record<string, { value: string }>;
|
|
}) => void;
|
|
rememberMergedNoteIds: (deletedNoteId: number, keptNoteId: number) => void;
|
|
reportCardsReady: (noteIds: number[]) => void;
|
|
rememberMiningProvenance: (noteIds: number[], provenance: PluginMiningProvenance | null) => void;
|
|
};
|
|
|
|
/** Named stand-ins for AnkiIntegration's long positional constructor arguments. */
|
|
type IntegrationHooks = {
|
|
mpvClient?: Record<string, unknown>;
|
|
osd?: (text: string) => void;
|
|
desktop?: (title: string, options: { body?: string; icon?: string }) => void;
|
|
overlay?: (payload: TestOverlayNotificationPayload) => void;
|
|
dismissOverlay?: (id: string) => void;
|
|
knownWordCacheStatePath?: string;
|
|
getCachedMediaPath?: () => Promise<string | null>;
|
|
shouldRequireRemoteMediaCache?: () => boolean;
|
|
getYoutubeMediaSourceUrl?: () => string | null;
|
|
};
|
|
|
|
function createIntegration(
|
|
config: Partial<AnkiConnectConfig>,
|
|
hooks: IntegrationHooks = {},
|
|
): AnkiIntegration {
|
|
return new AnkiIntegration(
|
|
config as AnkiConnectConfig,
|
|
{} as never,
|
|
(hooks.mpvClient ?? {}) as never,
|
|
hooks.osd,
|
|
hooks.desktop as never,
|
|
undefined,
|
|
hooks.knownWordCacheStatePath,
|
|
{},
|
|
undefined,
|
|
hooks.overlay as never,
|
|
hooks.getCachedMediaPath,
|
|
hooks.shouldRequireRemoteMediaCache,
|
|
hooks.getYoutubeMediaSourceUrl,
|
|
hooks.dismissOverlay,
|
|
);
|
|
}
|
|
|
|
function internalsOf(integration: AnkiIntegration): IntegrationInternals {
|
|
return integration as unknown as IntegrationInternals;
|
|
}
|
|
|
|
function describeMediaInputForTest(input: MediaInput): string {
|
|
if (typeof input === 'string') {
|
|
return input;
|
|
}
|
|
return `${input.path}:${input.source ?? 'raw'}`;
|
|
}
|
|
|
|
// --- Known-word cache -------------------------------------------------------
|
|
|
|
type KnownWordsOptions = {
|
|
highlightEnabled?: boolean;
|
|
nPlusOneEnabled?: boolean;
|
|
onFindNotes?: () => Promise<number[]>;
|
|
};
|
|
|
|
type KnownWordsContext = {
|
|
integration: AnkiIntegration;
|
|
calls: { findNotes: number; notesInfo: number };
|
|
};
|
|
|
|
/** Runs `run` against an integration whose AnkiConnect client is a counting fake; cleans up its cache dir. */
|
|
async function withKnownWordsIntegration(
|
|
options: KnownWordsOptions,
|
|
run: (ctx: KnownWordsContext) => Promise<void> | void,
|
|
): Promise<void> {
|
|
const calls = { findNotes: 0, notesInfo: 0 };
|
|
const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-anki-integration-'));
|
|
|
|
try {
|
|
const integration = createIntegration(
|
|
{
|
|
knownWords: { highlightEnabled: options.highlightEnabled ?? true },
|
|
nPlusOne:
|
|
options.nPlusOneEnabled === undefined ? undefined : { enabled: options.nPlusOneEnabled },
|
|
},
|
|
{ knownWordCacheStatePath: path.join(stateDir, 'known-words-cache.json') },
|
|
);
|
|
internalsOf(integration).client = {
|
|
findNotes: async () => {
|
|
calls.findNotes += 1;
|
|
return options.onFindNotes ? options.onFindNotes() : [];
|
|
},
|
|
notesInfo: async () => {
|
|
calls.notesInfo += 1;
|
|
return [];
|
|
},
|
|
};
|
|
await run({ integration, calls });
|
|
} finally {
|
|
fs.rmSync(stateDir, { recursive: true, force: true });
|
|
}
|
|
}
|
|
|
|
const KNOWN_WORD_REFRESH_CASES: Array<{
|
|
name: string;
|
|
options: KnownWordsOptions;
|
|
expected: { findNotes: number; notifications: number };
|
|
}> = [
|
|
{
|
|
name: 'refreshes once, skipping stale checks, and notifies annotation cache listeners',
|
|
options: {},
|
|
expected: { findNotes: 1, notifications: 1 },
|
|
},
|
|
{
|
|
name: 'notifies when n+1 is enabled without highlights',
|
|
options: { highlightEnabled: false, nPlusOneEnabled: true },
|
|
expected: { findNotes: 1, notifications: 1 },
|
|
},
|
|
{
|
|
name: 'skips work when highlight mode is disabled',
|
|
options: { highlightEnabled: false },
|
|
expected: { findNotes: 0, notifications: 0 },
|
|
},
|
|
];
|
|
|
|
for (const c of KNOWN_WORD_REFRESH_CASES) {
|
|
test(`AnkiIntegration.refreshKnownWordCache ${c.name}`, async () => {
|
|
await withKnownWordsIntegration(c.options, async ({ integration, calls }) => {
|
|
let notifications = 0;
|
|
integration.setKnownWordCacheUpdatedCallback(() => {
|
|
notifications += 1;
|
|
});
|
|
|
|
await integration.refreshKnownWordCache();
|
|
|
|
assert.equal(calls.findNotes, c.expected.findNotes);
|
|
assert.equal(calls.notesInfo, 0);
|
|
assert.equal(notifications, c.expected.notifications);
|
|
});
|
|
});
|
|
}
|
|
|
|
test('AnkiIntegration.refreshKnownWordCache deduplicates concurrent refreshes', async () => {
|
|
let releaseFindNotes: () => void = () => {};
|
|
const findNotesGate = new Promise<void>((resolve) => {
|
|
releaseFindNotes = resolve;
|
|
});
|
|
|
|
await withKnownWordsIntegration(
|
|
{
|
|
onFindNotes: async () => {
|
|
await findNotesGate;
|
|
return [];
|
|
},
|
|
},
|
|
async ({ integration, calls }) => {
|
|
const first = integration.refreshKnownWordCache();
|
|
await Promise.resolve();
|
|
const second = integration.refreshKnownWordCache();
|
|
releaseFindNotes();
|
|
await Promise.all([first, second]);
|
|
|
|
assert.equal(calls.findNotes, 1);
|
|
assert.equal(calls.notesInfo, 0);
|
|
},
|
|
);
|
|
});
|
|
|
|
test('AnkiIntegration notifies when mined note info updates known words', async () => {
|
|
await withKnownWordsIntegration({}, ({ integration }) => {
|
|
let notifications = 0;
|
|
const internals = internalsOf(integration);
|
|
internals.config.deck = 'Mining';
|
|
internals.config.knownWords = {
|
|
...internals.config.knownWords,
|
|
decks: { Mining: ['Word'] },
|
|
};
|
|
integration.setKnownWordCacheUpdatedCallback(() => {
|
|
notifications += 1;
|
|
});
|
|
|
|
internals.appendKnownWordsFromNoteInfo({ noteId: 42, fields: { Word: { value: '食べる' } } });
|
|
|
|
assert.equal(integration.isKnownWord('食べる'), true);
|
|
assert.equal(notifications, 1);
|
|
});
|
|
});
|
|
|
|
test('AnkiIntegration resolves merged-away note ids to the kept note id', () => {
|
|
const integration = createIntegration({});
|
|
const internals = internalsOf(integration);
|
|
internals.rememberMergedNoteIds(111, 222);
|
|
internals.rememberMergedNoteIds(222, 333);
|
|
|
|
assert.equal(integration.resolveCurrentNoteId(111), 333);
|
|
assert.equal(integration.resolveCurrentNoteId(222), 333);
|
|
assert.equal(integration.resolveCurrentNoteId(333), 333);
|
|
assert.equal(integration.resolveCurrentNoteId(444), 444);
|
|
});
|
|
|
|
test('AnkiIntegration reports ready cards under the note that kept them after a merge', () => {
|
|
const integration = createIntegration({});
|
|
const ready: number[][] = [];
|
|
integration.setCardsReadyCallback((noteIds) => ready.push(noteIds));
|
|
const internals = internalsOf(integration);
|
|
internals.rememberMergedNoteIds(111, 222);
|
|
|
|
internals.reportCardsReady([111, 222, 444]);
|
|
|
|
assert.deepEqual(ready, [[222, 444]]);
|
|
});
|
|
|
|
test('ready events preserve each mining source when multiple notes merge into an older note', () => {
|
|
const integration = createIntegration({});
|
|
const ready: PluginMinedNote[][] = [];
|
|
integration.setCardsReadyCallback((_ids, notes) => ready.push(notes));
|
|
const internals = internalsOf(integration);
|
|
const provenance = {
|
|
minedAt: 1234,
|
|
media: { playbackId: 1, path: 'Show - 02.mkv', title: null },
|
|
subtitle: { text: 'テスト', startTime: 1, endTime: 2 },
|
|
};
|
|
internals.rememberMiningProvenance([111, 222], provenance);
|
|
provenance.media.path = 'Show - 03.mkv';
|
|
internals.rememberMergedNoteIds(111, 7);
|
|
internals.rememberMergedNoteIds(222, 7);
|
|
internals.reportCardsReady([111, 222]);
|
|
assert.deepEqual(
|
|
ready[0]?.map((note) => [note.originalNoteId, note.noteId, note.provenance?.media?.path]),
|
|
[
|
|
[111, 7, 'Show - 02.mkv'],
|
|
[222, 7, 'Show - 02.mkv'],
|
|
],
|
|
);
|
|
internals.reportCardsReady([333]);
|
|
assert.equal(ready[1]?.[0]?.provenance, null);
|
|
});
|
|
|
|
// --- Sentence highlighting --------------------------------------------------
|
|
|
|
const MPV_SENTENCE = '先日 貴様らが潜入した キールダンジョンから―';
|
|
|
|
const HIGHLIGHT_CASES = [
|
|
{
|
|
name: 'highlights mined word from expression field when sentence has no bold marker',
|
|
highlightWord: true,
|
|
noteSentence: MPV_SENTENCE,
|
|
expected: '先日 貴様らが<b>潜入</b>した キールダンジョンから―',
|
|
},
|
|
{
|
|
name: 'keeps existing Yomitan bold target when present',
|
|
highlightWord: true,
|
|
noteSentence: '<b>潜入した</b>',
|
|
expected: '先日 貴様らが<b>潜入した</b> キールダンジョンから―',
|
|
},
|
|
{
|
|
name: 'leaves sentence plain when word highlighting is disabled',
|
|
highlightWord: false,
|
|
noteSentence: '<b>潜入</b>',
|
|
expected: MPV_SENTENCE,
|
|
},
|
|
];
|
|
|
|
for (const c of HIGHLIGHT_CASES) {
|
|
test(`AnkiIntegration ${c.name}`, () => {
|
|
const integration = createIntegration({
|
|
fields: { word: 'Expression', sentence: 'Sentence' },
|
|
behavior: { highlightWord: c.highlightWord },
|
|
});
|
|
|
|
const processed = internalsOf(integration).processSentence(MPV_SENTENCE, {
|
|
expression: '潜入',
|
|
sentence: c.noteSentence,
|
|
});
|
|
|
|
assert.equal(processed, c.expected);
|
|
});
|
|
}
|
|
|
|
test('AnkiIntegration highlights mined word in sentence furigana field', () => {
|
|
const integration = createIntegration({
|
|
fields: { word: 'Expression', sentence: 'Sentence' },
|
|
behavior: { highlightWord: true },
|
|
});
|
|
|
|
const processed = internalsOf(integration).processSentenceFurigana(
|
|
'<span class="term"><ruby>不思議<rt>ふしぎ</rt></ruby></span><span class="term">な</span><span class="term"><ruby>特技<rt>とくぎ</rt></ruby></span><span class="term">を</span>',
|
|
{ expression: '特技', sentence: '不思議な特技を' },
|
|
);
|
|
|
|
assert.equal(
|
|
processed,
|
|
'<span class="term"><ruby>不思議<rt>ふしぎ</rt></ruby></span><span class="term">な</span><b><span class="term"><ruby>特技<rt>とくぎ</rt></ruby></span></b><span class="term">を</span>',
|
|
);
|
|
});
|
|
|
|
// --- Proxy, field grouping, notifications -----------------------------------
|
|
|
|
test('AnkiIntegration reports an occupied proxy address through its notification seam', async () => {
|
|
const occupiedServer = http.createServer();
|
|
occupiedServer.listen(0, '127.0.0.1');
|
|
await once(occupiedServer, 'listening');
|
|
const occupiedAddress = occupiedServer.address();
|
|
assert.ok(occupiedAddress && typeof occupiedAddress === 'object');
|
|
const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-anki-proxy-collision-'));
|
|
const overlayNotifications: TestOverlayNotificationPayload[] = [];
|
|
const integration = createIntegration(
|
|
{
|
|
enabled: true,
|
|
url: 'http://127.0.0.1:8765',
|
|
proxy: {
|
|
enabled: true,
|
|
host: '127.0.0.1',
|
|
port: occupiedAddress.port,
|
|
upstreamUrl: 'http://127.0.0.1:8765',
|
|
},
|
|
behavior: { notificationType: 'overlay' },
|
|
knownWords: { highlightEnabled: false },
|
|
nPlusOne: { enabled: false },
|
|
},
|
|
{
|
|
knownWordCacheStatePath: path.join(stateDir, 'known-words-cache.json'),
|
|
overlay: (payload) => {
|
|
overlayNotifications.push(payload);
|
|
},
|
|
},
|
|
);
|
|
|
|
try {
|
|
integration.start();
|
|
await integration.waitUntilReady();
|
|
|
|
assert.deepEqual(overlayNotifications, [
|
|
{
|
|
title: 'SubMiner',
|
|
body: `AnkiConnect proxy unavailable because http://127.0.0.1:${occupiedAddress.port} is already in use. Change ankiConnect.proxy.port or stop the process using that address.`,
|
|
variant: 'info',
|
|
},
|
|
]);
|
|
} finally {
|
|
integration.stop();
|
|
occupiedServer.close();
|
|
await once(occupiedServer, 'close');
|
|
fs.rmSync(stateDir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test('AnkiIntegration triggers field grouping after a local duplicate sentence card is created', async () => {
|
|
const integration = createIntegration({
|
|
isKiku: { enabled: true, fieldGrouping: 'manual' },
|
|
});
|
|
let groupingTriggered = 0;
|
|
const internals = internalsOf(integration);
|
|
internals.cardCreationService = {
|
|
createSentenceCard: async () => {
|
|
integration.trackDuplicateNoteIdsForNote(42, [7]);
|
|
return true;
|
|
},
|
|
};
|
|
internals.fieldGroupingService = {
|
|
triggerFieldGroupingForLastAddedCard: async () => {
|
|
groupingTriggered += 1;
|
|
},
|
|
};
|
|
|
|
assert.equal(await integration.createSentenceCard('duplicate sentence', 0, 1), true);
|
|
assert.equal(groupingTriggered, 1);
|
|
});
|
|
|
|
test('AnkiIntegration reports a new sentence card ready only after field grouping', async () => {
|
|
const integration = createIntegration({
|
|
isKiku: { enabled: true, fieldGrouping: 'manual' },
|
|
});
|
|
const events: string[] = [];
|
|
integration.setCardsReadyCallback((noteIds) => events.push(`ready ${noteIds.join(',')}`));
|
|
const internals = internalsOf(integration);
|
|
internals.cardCreationService = {
|
|
createSentenceCard: async () => {
|
|
integration.trackDuplicateNoteIdsForNote(42, [7]);
|
|
internals.reportCardsReady([42]);
|
|
return true;
|
|
},
|
|
};
|
|
internals.fieldGroupingService = {
|
|
triggerFieldGroupingForLastAddedCard: async () => {
|
|
events.push('group');
|
|
},
|
|
};
|
|
|
|
assert.equal(await integration.createSentenceCard('duplicate sentence', 0, 1), true);
|
|
assert.deepEqual(events, ['group', 'ready 42']);
|
|
});
|
|
|
|
test('AnkiIntegration reports a card with queued YouTube media ready once the media lands', async () => {
|
|
const events: string[] = [];
|
|
const integration = createIntegration({
|
|
fields: { audio: 'ExpressionAudio', image: 'Picture' },
|
|
media: { imageFormat: 'jpg' },
|
|
behavior: { notificationType: 'osd' },
|
|
});
|
|
installFakeAnki(integration, {
|
|
noteFieldNames: ['ExpressionAudio', 'Picture'],
|
|
mediaGenerator: {
|
|
generateAudio: async () => Buffer.from('audio'),
|
|
generateScreenshot: async () => Buffer.from('image'),
|
|
},
|
|
});
|
|
integration.setCardsReadyCallback((noteIds) => events.push(`ready ${noteIds.join(',')}`));
|
|
const internals = internalsOf(integration);
|
|
internals.queuePendingYoutubeMediaUpdate(youtubeJob({ noteId: 101 }));
|
|
// Note 7 was merged into 101, so it waits on 101's media too.
|
|
internals.rememberMergedNoteIds(7, 101);
|
|
|
|
internals.reportCardsReady([101, 7, 5]);
|
|
assert.deepEqual(events, ['ready 5']);
|
|
|
|
await integration.handleYoutubeMediaCacheReady('https://youtu.be/abc123', '/tmp/media.mkv');
|
|
assert.deepEqual(events, ['ready 5', 'ready 101']);
|
|
});
|
|
|
|
test('AnkiIntegration marks partial update notifications as failures in OSD mode', async () => {
|
|
const osdMessages: string[] = [];
|
|
const integration = createIntegration(
|
|
{ behavior: { notificationType: 'osd' } },
|
|
{ osd: (text) => osdMessages.push(text) },
|
|
);
|
|
|
|
await internalsOf(integration).showNotification(42, 'taberu', 'image failed');
|
|
|
|
assert.deepEqual(osdMessages, ['x Updated card: taberu (image failed)']);
|
|
});
|
|
|
|
test('AnkiIntegration routes workflow status notifications through configured surfaces', async () => {
|
|
const osdMessages: string[] = [];
|
|
const desktopMessages: string[] = [];
|
|
const overlayMessages: string[] = [];
|
|
const integration = createIntegration(
|
|
{ behavior: { notificationType: 'both' } },
|
|
{
|
|
osd: (text) => osdMessages.push(text),
|
|
desktop: (title, options) => desktopMessages.push(`${title}:${options.body ?? ''}`),
|
|
overlay: (payload) =>
|
|
overlayMessages.push(`${payload.title}:${payload.body ?? ''}:${payload.variant ?? ''}`),
|
|
},
|
|
);
|
|
|
|
assert.equal(await integration.createSentenceCard('食べる', 0, 1), false);
|
|
|
|
assert.deepEqual(osdMessages, []);
|
|
assert.deepEqual(overlayMessages, ['SubMiner:No video loaded:info']);
|
|
assert.deepEqual(desktopMessages, ['SubMiner:No video loaded']);
|
|
});
|
|
|
|
// --- YouTube media cache ----------------------------------------------------
|
|
|
|
/**
|
|
* Installs a fake AnkiConnect client whose notes carry empty values for `noteFieldNames`, plus a
|
|
* media generator that succeeds unless overridden. Records every note update and stored file.
|
|
*/
|
|
function installFakeAnki(
|
|
integration: AnkiIntegration,
|
|
options: {
|
|
noteFieldNames: string[];
|
|
mediaGenerator?: IntegrationInternals['mediaGenerator'];
|
|
},
|
|
) {
|
|
const updatedNotes: Array<{ noteId: number; fields: Record<string, string> }> = [];
|
|
const storedMedia: string[] = [];
|
|
const internals = internalsOf(integration);
|
|
internals.client = {
|
|
notesInfo: async (noteIds) =>
|
|
noteIds.map((noteId) => ({
|
|
noteId,
|
|
fields: Object.fromEntries(options.noteFieldNames.map((name) => [name, { value: '' }])),
|
|
})),
|
|
updateNoteFields: async (noteId, fields) => {
|
|
updatedNotes.push({ noteId, fields });
|
|
},
|
|
storeMediaFile: async (filename) => {
|
|
storedMedia.push(filename);
|
|
},
|
|
};
|
|
internals.mediaGenerator = {
|
|
generateAudio: async () => Buffer.from('audio'),
|
|
generateScreenshot: async () => Buffer.from('image'),
|
|
...options.mediaGenerator,
|
|
};
|
|
return { updatedNotes, storedMedia };
|
|
}
|
|
|
|
function youtubeJob(overrides: Partial<PendingYoutubeJob> & { noteId: number }): PendingYoutubeJob {
|
|
return {
|
|
sourceUrl: 'https://www.youtube.com/watch?v=abc123',
|
|
startTime: 10,
|
|
endTime: 12,
|
|
label: String(overrides.noteId),
|
|
audioFieldName: 'SentenceAudio',
|
|
imageFieldName: 'Picture',
|
|
generateAudio: true,
|
|
generateImage: true,
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
test('AnkiIntegration applies ready YouTube cache media to every queued note id', async () => {
|
|
const osdMessages: string[] = [];
|
|
const mediaInputs: string[] = [];
|
|
const integration = createIntegration(
|
|
{
|
|
fields: { audio: 'ExpressionAudio', image: 'Picture' },
|
|
media: { imageFormat: 'jpg' },
|
|
behavior: { notificationType: 'osd' },
|
|
},
|
|
{ osd: (text) => osdMessages.push(text) },
|
|
);
|
|
const { updatedNotes, storedMedia } = installFakeAnki(integration, {
|
|
noteFieldNames: ['ExpressionAudio', 'Picture'],
|
|
mediaGenerator: {
|
|
generateAudio: async (mediaPath, _start, _end, _padding, audioStreamIndex, _norm, scale) => {
|
|
mediaInputs.push(
|
|
`audio:${describeMediaInputForTest(mediaPath)}:${audioStreamIndex ?? 'auto'}:${scale}`,
|
|
);
|
|
return Buffer.from('audio');
|
|
},
|
|
generateScreenshot: async (mediaPath) => {
|
|
mediaInputs.push(`image:${describeMediaInputForTest(mediaPath)}`);
|
|
return Buffer.from('image');
|
|
},
|
|
},
|
|
});
|
|
const internals = internalsOf(integration);
|
|
internals.queuePendingYoutubeMediaUpdate(
|
|
youtubeJob({ noteId: 101, label: 'first', audioStreamIndex: 22, volumeScale: 0.25 }),
|
|
);
|
|
internals.queuePendingYoutubeMediaUpdate(
|
|
youtubeJob({
|
|
noteId: 202,
|
|
sourceUrl: 'https://youtu.be/abc123',
|
|
startTime: 20,
|
|
endTime: 22,
|
|
label: 'second',
|
|
audioStreamIndex: 23,
|
|
volumeScale: 0.8,
|
|
}),
|
|
);
|
|
|
|
await integration.handleYoutubeMediaCacheReady('https://youtu.be/abc123', '/tmp/media.mkv');
|
|
|
|
assert.deepEqual(mediaInputs, [
|
|
'audio:/tmp/media.mkv:youtube-cache:auto:0.25',
|
|
'image:/tmp/media.mkv:youtube-cache',
|
|
'audio:/tmp/media.mkv:youtube-cache:auto:0.8',
|
|
'image:/tmp/media.mkv:youtube-cache',
|
|
]);
|
|
assert.deepEqual(
|
|
updatedNotes.map((update) => update.noteId),
|
|
[101, 202],
|
|
);
|
|
for (const update of updatedNotes) {
|
|
assert.match(update.fields.SentenceAudio ?? '', /^\[sound:audio_/);
|
|
assert.match(update.fields.Picture ?? '', /^<img src="image_/);
|
|
}
|
|
assert.equal(storedMedia.length, 4);
|
|
assert.ok(
|
|
osdMessages.some((message) =>
|
|
message.includes('YouTube media cache ready. Adding media to 2 queued cards.'),
|
|
),
|
|
);
|
|
});
|
|
|
|
test('AnkiIntegration reports partial queued YouTube media updates separately from failures', async () => {
|
|
const osdMessages: string[] = [];
|
|
const notifications: Array<{ noteId: number; label: string | number; suffix?: string }> = [];
|
|
const integration = createIntegration(
|
|
{
|
|
fields: { image: 'Picture' },
|
|
media: { imageFormat: 'jpg' },
|
|
behavior: { notificationType: 'osd' },
|
|
},
|
|
{ osd: (text) => osdMessages.push(text) },
|
|
);
|
|
const { updatedNotes } = installFakeAnki(integration, {
|
|
noteFieldNames: ['SentenceAudio', 'Picture'],
|
|
mediaGenerator: {
|
|
generateAudio: async () => {
|
|
throw new Error('audio stream not found');
|
|
},
|
|
},
|
|
});
|
|
const internals = internalsOf(integration);
|
|
internals.showNotification = async (noteId, label, suffix) => {
|
|
notifications.push({ noteId, label, suffix });
|
|
};
|
|
internals.queuePendingYoutubeMediaUpdate(
|
|
youtubeJob({
|
|
noteId: 303,
|
|
sourceUrl: 'https://www.youtube.com/watch?v=partial',
|
|
label: 'partial',
|
|
}),
|
|
);
|
|
|
|
await integration.handleYoutubeMediaCacheReady('https://youtu.be/partial', '/tmp/media.mkv');
|
|
|
|
assert.equal(updatedNotes.length, 1);
|
|
assert.match(updatedNotes[0]?.fields.Picture ?? '', /^<img src="image_/);
|
|
assert.equal(updatedNotes[0]?.fields.SentenceAudio, undefined);
|
|
assert.deepEqual(notifications, [{ noteId: 303, label: 'partial', suffix: 'audio failed' }]);
|
|
assert.ok(
|
|
osdMessages.some((message) =>
|
|
message.includes('Queued YouTube media finished with 0 updated, 1 partial, and 0 failed.'),
|
|
),
|
|
);
|
|
});
|
|
|
|
test('AnkiIntegration queues YouTube media updates against recovered source URLs', async () => {
|
|
const audioVolumeScales: Array<number | undefined> = [];
|
|
let mpvVolume = 30;
|
|
const integration = createIntegration(
|
|
{ fields: { image: 'Picture' }, media: { imageFormat: 'jpg' } },
|
|
{
|
|
mpvClient: {
|
|
currentVideoPath:
|
|
'https://rr1---sn.example.googlevideo.com/videoplayback?expire=1777777777',
|
|
currentSubStart: 10,
|
|
currentSubEnd: 12,
|
|
currentTimePos: 11,
|
|
requestProperty: async (name: string) => {
|
|
assert.equal(name, 'volume');
|
|
return mpvVolume;
|
|
},
|
|
},
|
|
osd: () => undefined,
|
|
getCachedMediaPath: async () => null,
|
|
shouldRequireRemoteMediaCache: () => true,
|
|
getYoutubeMediaSourceUrl: () => 'https://www.youtube.com/watch?v=abc123',
|
|
},
|
|
);
|
|
const { updatedNotes, storedMedia } = installFakeAnki(integration, {
|
|
noteFieldNames: ['SentenceAudio', 'Picture'],
|
|
mediaGenerator: {
|
|
generateAudio: async (_path, _start, _end, _padding, _stream, _norm, volumeScale) => {
|
|
audioVolumeScales.push(volumeScale);
|
|
return Buffer.from('audio');
|
|
},
|
|
},
|
|
});
|
|
const internals = internalsOf(integration);
|
|
internals.showNotification = async () => undefined;
|
|
|
|
const queued = await internals.queuePendingYoutubeMediaUpdateForNote({
|
|
noteId: 404,
|
|
noteInfo: {
|
|
noteId: 404,
|
|
fields: { SentenceAudio: { value: '' }, Picture: { value: '' } },
|
|
},
|
|
label: 'resolved source',
|
|
});
|
|
// The volume is captured when the job is queued, not when the cache becomes ready.
|
|
mpvVolume = 90;
|
|
await integration.handleYoutubeMediaCacheReady('https://youtu.be/abc123', '/tmp/media.mkv');
|
|
|
|
assert.equal(queued, true);
|
|
assert.equal(updatedNotes.length, 1);
|
|
assert.equal(updatedNotes[0]?.noteId, 404);
|
|
assert.match(updatedNotes[0]?.fields.SentenceAudio ?? '', /^\[sound:audio_/);
|
|
assert.equal(updatedNotes[0]?.fields.ExpressionAudio, undefined);
|
|
assert.match(updatedNotes[0]?.fields.Picture ?? '', /^<img src="image_/);
|
|
assert.equal(storedMedia.length, 2);
|
|
assert.deepEqual(audioVolumeScales, [0.3 ** 3]);
|
|
});
|
|
|
|
test('AnkiIntegration passes audio normalization config for ready cached YouTube audio', async () => {
|
|
const audioCalls: Array<{
|
|
path: string;
|
|
audioStreamIndex?: number;
|
|
normalizeAudio?: boolean;
|
|
volumeScale?: number;
|
|
}> = [];
|
|
const requestedProperties: string[] = [];
|
|
const integration = createIntegration(
|
|
{ media: { audioPadding: 0, normalizeAudio: false } },
|
|
{
|
|
mpvClient: {
|
|
currentVideoPath: 'https://www.youtube.com/watch?v=abc123',
|
|
currentAudioStreamIndex: 0,
|
|
currentSubStart: 10,
|
|
currentSubEnd: 12,
|
|
currentTimePos: 11,
|
|
requestProperty: async (name: string) => {
|
|
requestedProperties.push(name);
|
|
return 55;
|
|
},
|
|
},
|
|
osd: () => undefined,
|
|
getCachedMediaPath: async () => '/tmp/subminer-youtube-media-cache/media.mkv',
|
|
shouldRequireRemoteMediaCache: () => true,
|
|
},
|
|
);
|
|
internalsOf(integration).mediaGenerator = {
|
|
generateAudio: async (
|
|
mediaPath,
|
|
_start,
|
|
_end,
|
|
_padding,
|
|
audioStreamIndex,
|
|
normalizeAudio,
|
|
volumeScale,
|
|
) => {
|
|
audioCalls.push({
|
|
path: typeof mediaPath === 'string' ? mediaPath : mediaPath.path,
|
|
audioStreamIndex,
|
|
normalizeAudio,
|
|
volumeScale,
|
|
});
|
|
return Buffer.from('audio');
|
|
},
|
|
};
|
|
|
|
await internalsOf(integration).generateAudio();
|
|
|
|
assert.deepEqual(requestedProperties, ['volume']);
|
|
assert.deepEqual(audioCalls, [
|
|
{
|
|
path: '/tmp/subminer-youtube-media-cache/media.mkv',
|
|
audioStreamIndex: undefined,
|
|
normalizeAudio: false,
|
|
volumeScale: 0.55 ** 3,
|
|
},
|
|
]);
|
|
});
|
|
|
|
const NO_QUEUED_CACHE_READY_CASES: Array<{
|
|
name: string;
|
|
options?: { notifyNoQueued: boolean };
|
|
expectedBodies: string[];
|
|
}> = [
|
|
{
|
|
name: 'announces ready YouTube cache when no queued notes exist',
|
|
expectedBodies: ['YouTube media cache ready.'],
|
|
},
|
|
{
|
|
name: 'can let caller own no-queued YouTube cache ready notification',
|
|
options: { notifyNoQueued: false },
|
|
expectedBodies: [],
|
|
},
|
|
];
|
|
|
|
for (const c of NO_QUEUED_CACHE_READY_CASES) {
|
|
test(`AnkiIntegration ${c.name}`, async () => {
|
|
const overlayNotifications: TestOverlayNotificationPayload[] = [];
|
|
const integration = createIntegration(
|
|
{ behavior: { notificationType: 'overlay' } },
|
|
{ overlay: (payload) => overlayNotifications.push(payload) },
|
|
);
|
|
|
|
await integration.handleYoutubeMediaCacheReady(
|
|
'https://youtu.be/abc123',
|
|
'/tmp/media.mkv',
|
|
c.options,
|
|
);
|
|
|
|
assert.deepEqual(
|
|
overlayNotifications.map((notification) => [notification.title, notification.body]),
|
|
c.expectedBodies.map((body) => ['SubMiner', body]),
|
|
);
|
|
});
|
|
}
|
|
|
|
// --- Overlay / desktop notification surfaces --------------------------------
|
|
|
|
/**
|
|
* Integration in 'both' notification mode with a fake media generator whose temp-icon write is
|
|
* `writeNotificationIconToFile`. Returns the surfaces' captured output.
|
|
*/
|
|
function createNotificationImageFixture(
|
|
writeNotificationIconToFile: (iconBuffer: Buffer, noteId: number) => string,
|
|
) {
|
|
const desktopNotifications: Array<{ title: string; body?: string; icon?: string }> = [];
|
|
const overlayNotifications: TestOverlayNotificationPayload[] = [];
|
|
const generatedFrom: Array<{ videoPath: string; timestamp: number }> = [];
|
|
const cleanupPaths: string[] = [];
|
|
const integration = createIntegration(
|
|
{ behavior: { notificationType: 'both' } },
|
|
{
|
|
mpvClient: { currentVideoPath: '/tmp/show.mkv', currentTimePos: 123.45 },
|
|
desktop: (title, options) => {
|
|
desktopNotifications.push({ title, body: options.body, icon: options.icon });
|
|
},
|
|
overlay: (payload) => overlayNotifications.push(payload),
|
|
},
|
|
);
|
|
internalsOf(integration).mediaGenerator = {
|
|
generateNotificationIcon: async (videoPath, timestamp) => {
|
|
generatedFrom.push({ videoPath, timestamp });
|
|
return Buffer.from('png');
|
|
},
|
|
writeNotificationIconToFile,
|
|
scheduleNotificationIconCleanup: (filePath) => {
|
|
cleanupPaths.push(filePath);
|
|
},
|
|
};
|
|
return { integration, desktopNotifications, overlayNotifications, generatedFrom, cleanupPaths };
|
|
}
|
|
|
|
const PNG_DATA_URL = `data:image/png;base64,${Buffer.from('png').toString('base64')}`;
|
|
|
|
test('AnkiIntegration embeds generated notification image on overlay mined-card notifications', async () => {
|
|
const notificationIconPath = path.join(os.tmpdir(), 'subminer-notification-icon.png');
|
|
const fixture = createNotificationImageFixture((iconBuffer, noteId) => {
|
|
assert.equal(iconBuffer.toString(), 'png');
|
|
assert.equal(noteId, 42);
|
|
return notificationIconPath;
|
|
});
|
|
|
|
await internalsOf(fixture.integration).showNotification(42, '食べる');
|
|
|
|
assert.deepEqual(fixture.generatedFrom, [{ videoPath: '/tmp/show.mkv', timestamp: 123.45 }]);
|
|
assert.equal(fixture.overlayNotifications.length, 1);
|
|
assert.equal(fixture.overlayNotifications[0]?.title, 'Anki Card Updated');
|
|
assert.equal(fixture.overlayNotifications[0]?.body, 'Updated card: 食べる');
|
|
assert.equal(fixture.overlayNotifications[0]?.image, PNG_DATA_URL);
|
|
assert.deepEqual(fixture.overlayNotifications[0]?.actions, [
|
|
{ id: 'open-anki-card', label: 'Open in Anki', noteId: 42 },
|
|
]);
|
|
assert.deepEqual(fixture.desktopNotifications, [
|
|
{ title: 'Anki Card Updated', body: 'Updated card: 食べる', icon: notificationIconPath },
|
|
]);
|
|
assert.deepEqual(fixture.cleanupPaths, [notificationIconPath]);
|
|
});
|
|
|
|
test('AnkiIntegration keeps overlay notification image when temp icon write fails', async () => {
|
|
const fixture = createNotificationImageFixture(() => {
|
|
throw new Error('disk full');
|
|
});
|
|
|
|
await internalsOf(fixture.integration).showNotification(42, '食べる');
|
|
|
|
assert.equal(fixture.overlayNotifications[0]?.image, PNG_DATA_URL);
|
|
assert.deepEqual(fixture.desktopNotifications, [
|
|
{ title: 'Anki Card Updated', body: 'Updated card: 食べる', icon: undefined },
|
|
]);
|
|
assert.deepEqual(fixture.cleanupPaths, []);
|
|
});
|
|
|
|
test('AnkiIntegration keeps overlay card-update progress visible until the terminal notification', async () => {
|
|
const overlayNotifications: TestOverlayNotificationPayload[] = [];
|
|
const integration = createIntegration(
|
|
{ behavior: { notificationType: 'overlay' } },
|
|
{ overlay: (payload) => overlayNotifications.push(payload) },
|
|
);
|
|
const internals = internalsOf(integration);
|
|
|
|
internals.beginUpdateProgress('Updating card');
|
|
await internals.showNotification(42, '食べる');
|
|
|
|
assert.deepEqual(
|
|
overlayNotifications.map(({ id, variant, persistent }) => ({ id, variant, persistent })),
|
|
[
|
|
{ id: 'anki-update-progress', variant: 'progress', persistent: true },
|
|
{ id: 'anki-update-progress', variant: 'success', persistent: false },
|
|
],
|
|
);
|
|
});
|
|
|
|
test('AnkiIntegration dismisses persistent overlay update progress when no terminal notification replaces it', () => {
|
|
const overlayNotifications: TestOverlayNotificationPayload[] = [];
|
|
const dismissedIds: string[] = [];
|
|
const integration = createIntegration(
|
|
{ behavior: { notificationType: 'overlay' } },
|
|
{
|
|
overlay: (payload) => overlayNotifications.push(payload),
|
|
dismissOverlay: (id) => dismissedIds.push(id),
|
|
},
|
|
);
|
|
const internals = internalsOf(integration);
|
|
|
|
internals.beginUpdateProgress('Updating card');
|
|
internals.endUpdateProgress();
|
|
|
|
assert.equal(overlayNotifications[0]?.persistent, true);
|
|
assert.deepEqual(dismissedIds, ['anki-update-progress']);
|
|
});
|
|
|
|
test('AnkiIntegration dismisses overlay update progress after notifications switch to OSD', () => {
|
|
const behavior: NonNullable<AnkiConnectConfig['behavior']> = { notificationType: 'overlay' };
|
|
const dismissedIds: string[] = [];
|
|
const integration = createIntegration(
|
|
{ behavior },
|
|
{ overlay: () => {}, dismissOverlay: (id) => dismissedIds.push(id) },
|
|
);
|
|
const internals = internalsOf(integration);
|
|
|
|
internals.beginUpdateProgress('Updating card');
|
|
behavior.notificationType = 'osd';
|
|
internals.endUpdateProgress();
|
|
|
|
assert.deepEqual(dismissedIds, ['anki-update-progress']);
|
|
});
|
|
|
|
// --- Misc-info metadata -----------------------------------------------------
|
|
|
|
const MISC_INFO_CASES = [
|
|
{
|
|
name: 'avoids leaking Jellyfin api_key query params',
|
|
pattern: '[SubMiner] %f (%t)',
|
|
mpv: {
|
|
currentVideoPath:
|
|
'stream?static=true&api_key=secret-token&MediaSourceId=a762ab23d26d4347e3cacdb83aaae405&AudioStreamIndex=3',
|
|
currentMediaTitle: '[Jellyfin/direct] Bocchi the Rock! - S01E02',
|
|
},
|
|
fallbackFilename: 'audio_123.mp3',
|
|
expected: '[SubMiner] [Jellyfin/direct] Bocchi the Rock! - S01E02 (00:07:06)',
|
|
leaked: 'api_key=',
|
|
},
|
|
{
|
|
name: 'treats ApiKey stream paths like legacy api_key ones',
|
|
pattern: '[SubMiner] %f (%t)',
|
|
mpv: {
|
|
currentVideoPath: 'stream?static=true&ApiKey=secret-token&MediaSourceId=ms-1',
|
|
currentMediaTitle: '[Jellyfin/direct] Bocchi the Rock! - S01E02',
|
|
},
|
|
fallbackFilename: 'audio_123.mp3',
|
|
expected: '[SubMiner] [Jellyfin/direct] Bocchi the Rock! - S01E02 (00:07:06)',
|
|
leaked: 'ApiKey=',
|
|
},
|
|
{
|
|
name: 'rejects a credential-bearing media title before metadata arrives',
|
|
pattern: '[SubMiner] %f | %F (%t)',
|
|
mpv: {
|
|
currentVideoPath: 'https://jellyfin.example/Videos/item/stream?api_key=test-secret',
|
|
currentMediaTitle: 'stream?static=true&api_key=test-secret',
|
|
},
|
|
fallbackFilename: 'stream?api_key=test-secret',
|
|
expected: '[SubMiner] Unknown media | Unknown media (00:07:06)',
|
|
leaked: 'test-secret',
|
|
},
|
|
];
|
|
|
|
for (const c of MISC_INFO_CASES) {
|
|
test(`AnkiIntegration.formatMiscInfoPattern ${c.name}`, () => {
|
|
const integration = createIntegration(
|
|
{ metadata: { pattern: c.pattern } },
|
|
{
|
|
mpvClient: {
|
|
currentSubText: '',
|
|
currentTimePos: 426,
|
|
currentSubStart: 426,
|
|
currentSubEnd: 428,
|
|
send: () => true,
|
|
...c.mpv,
|
|
},
|
|
},
|
|
);
|
|
|
|
const result = internalsOf(integration).formatMiscInfoPattern(c.fallbackFilename, 426);
|
|
|
|
assert.equal(result, c.expected);
|
|
assert.equal(result.includes(c.leaked), false);
|
|
});
|
|
}
|