diff --git a/src/core/services/__tests__/stats-server.test.ts b/src/core/services/__tests__/stats-server.test.ts index a45cb33c..0451fca0 100644 --- a/src/core/services/__tests__/stats-server.test.ts +++ b/src/core/services/__tests__/stats-server.test.ts @@ -354,6 +354,7 @@ type CapturedAnkiRequest = { async function withFakeAnkiConnect( fn: (requests: CapturedAnkiRequest[], url: string) => Promise, + options?: { notesInfoFields?: Record | null }, ): Promise { const requests: CapturedAnkiRequest[] = []; const server = http.createServer((req, res) => { @@ -373,18 +374,21 @@ async function withFakeAnkiConnect( } else if (payload.action === 'notesInfo') { const noteIds = payload.params?.notes ?? []; body = { - result: noteIds.map((noteId) => ({ - noteId, - fields: { - Expression: { value: '猫' }, - ExpressionAudio: { value: '[sound:word.mp3]' }, - Sentence: { value: '' }, - SentenceAudio: { value: '' }, - Picture: { value: '' }, - MiscInfo: { value: '' }, - SelectionText: { value: '' }, - }, - })), + result: + options?.notesInfoFields === null + ? [] + : noteIds.map((noteId) => ({ + noteId, + fields: options?.notesInfoFields ?? { + Expression: { value: '猫' }, + ExpressionAudio: { value: '[sound:word.mp3]' }, + Sentence: { value: '' }, + SentenceAudio: { value: '' }, + Picture: { value: '' }, + MiscInfo: { value: '' }, + SelectionText: { value: '' }, + }, + })), error: null, }; } else if (payload.action === 'findCards') { @@ -1771,6 +1775,65 @@ describe('stats server API routes', () => { }); }); + it('POST /api/stats/mine-card writes audio cards to configured audio field when note info is missing', async () => { + await withTempDir(async (dir) => { + const sourcePath = path.join(dir, 'episode.mkv'); + fs.writeFileSync(sourcePath, 'fake media'); + + await withFakeAnkiConnect( + async (requests, url) => { + const app = createStatsApp(createMockTracker(), { + createMediaGenerator: () => ({ + generateAudio: async () => Buffer.from('audio'), + generateScreenshot: async () => null, + generateAnimatedImage: async () => null, + }), + ankiConnectConfig: { + url, + deck: 'Mining', + fields: { + audio: 'Voice', + image: 'Picture', + sentence: 'Sentence', + }, + media: { + generateAudio: true, + generateImage: false, + }, + isLapis: { + enabled: true, + sentenceCardModel: 'Lapis Morph', + }, + }, + }); + + const res = await app.request('/api/stats/mine-card?mode=audio', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + sourcePath, + startMs: 1_000, + endMs: 2_000, + sentence: '猫を見た', + word: '猫', + videoTitle: 'Episode 1', + }), + }); + + const body = await res.json(); + assert.equal(res.status, 200, JSON.stringify(body)); + + const updateRequest = requests.find((request) => request.action === 'updateNoteFields'); + assert.match( + updateRequest?.params?.note?.fields?.Voice ?? '', + /^\[sound:subminer_audio_\d+\.mp3\]$/, + ); + }, + { notesInfoFields: null }, + ); + }); + }); + it('POST /api/stats/mine-card records timing for slow sentence mining phases', async () => { await withTempDir(async (dir) => { const sourcePath = path.join(dir, 'episode.mkv'); @@ -1989,9 +2052,12 @@ describe('stats server API routes', () => { it('POST /api/stats/anki/notesInfo resolves stale note ids through the configured alias resolver', async () => { const originalFetch = globalThis.fetch; - const requests: unknown[] = []; - globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { - requests.push(init?.body ? JSON.parse(String(init.body)) : null); + const requests: Array<{ input: string; body: unknown }> = []; + globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + requests.push({ + input: String(input), + body: init?.body ? JSON.parse(String(init.body)) : null, + }); return new Response( JSON.stringify({ result: [ @@ -2012,6 +2078,7 @@ describe('stats server API routes', () => { try { const app = createStatsApp(createMockTracker(), { + ankiConnectConfig: { url: 'http://127.0.0.1:9876' }, resolveAnkiNoteId: (noteId) => (noteId === 111 ? 222 : noteId), }); const res = await app.request('/api/stats/anki/notesInfo', { @@ -2023,9 +2090,12 @@ describe('stats server API routes', () => { assert.equal(res.status, 200); assert.deepEqual(requests, [ { - action: 'notesInfo', - version: 6, - params: { notes: [222] }, + input: 'http://127.0.0.1:9876', + body: { + action: 'notesInfo', + version: 6, + params: { notes: [222] }, + }, }, ]); assert.deepEqual(await res.json(), [ diff --git a/src/core/services/stats-server.ts b/src/core/services/stats-server.ts index 6d654ba7..66ee08ef 100644 --- a/src/core/services/stats-server.ts +++ b/src/core/services/stats-server.ts @@ -184,7 +184,7 @@ function getStatsDirectMiningAudioFieldNames( : 'SentenceAudio'; const expressionAudioField = noteInfo ? resolveStatsNoteFieldName(noteInfo, configuredAudioField) - : null; + : configuredAudioField; if (mode === 'sentence') { return uniqueFieldNames(sentenceAudioField); @@ -1045,7 +1045,8 @@ export function createStatsApp( ), ); try { - const response = await fetch('http://127.0.0.1:8765', { + const ankiConfig = getAnkiConnectConfig(); + const response = await fetch(ankiConfig?.url ?? 'http://127.0.0.1:8765', { method: 'POST', headers: { 'Content-Type': 'application/json' }, signal: AbortSignal.timeout(ANKI_CONNECT_FETCH_TIMEOUT_MS), diff --git a/src/main.ts b/src/main.ts index 49457ba0..efe66187 100644 --- a/src/main.ts +++ b/src/main.ts @@ -4476,10 +4476,11 @@ const startLocalStatsServer = (): void => { appState.ankiIntegration?.resolveCurrentNoteId(noteId) ?? noteId, resolveSentenceSearchHeadwords, addYomitanNote: async (word: string) => { - const ankiUrl = getResolvedConfig().ankiConnect.url || 'http://127.0.0.1:8765'; + const ankiConnectConfig = getResolvedConfig().ankiConnect; + const ankiUrl = ankiConnectConfig.url || 'http://127.0.0.1:8765'; await syncYomitanDefaultAnkiServerCore(ankiUrl, yomitanDeps, yomitanLogger, { - forceOverride: true, - deck: getResolvedConfig().ankiConnect.deck, + forceOverride: shouldForceOverrideYomitanAnkiServer(ankiConnectConfig), + deck: ankiConnectConfig.deck, }); const result = await addYomitanNoteViaSearch(word, yomitanDeps, yomitanLogger); if (result.noteId && result.duplicateNoteIds.length > 0) { diff --git a/src/main/main-wiring.test.ts b/src/main/main-wiring.test.ts index 224b5f86..1c3160d8 100644 --- a/src/main/main-wiring.test.ts +++ b/src/main/main-wiring.test.ts @@ -237,6 +237,21 @@ test('warm tokenization release reuses current subtitle payload instead of synth assert.match(currentPayloadBlock, /payload\.text !== appState\.currentSubText/); }); +test('stats server Yomitan note creation honors configured Anki server override policy', () => { + const source = readMainSource(); + const startStatsServerBlock = source.match( + /statsServer = startStatsServer\(\{(?[\s\S]*?)\n \}\);/, + )?.groups?.body; + const addYomitanNoteBlock = startStatsServerBlock?.match( + /addYomitanNote:\s*async\s*\(word: string\)\s*=>\s*\{(?[\s\S]*?)\n \},/, + )?.groups?.body; + + assert.ok(addYomitanNoteBlock); + assert.match(addYomitanNoteBlock, /const ankiConnectConfig = getResolvedConfig\(\)\.ankiConnect;/); + assert.match(addYomitanNoteBlock, /shouldForceOverrideYomitanAnkiServer\(ankiConnectConfig\)/); + assert.doesNotMatch(addYomitanNoteBlock, /forceOverride:\s*true/); +}); + test('Linux visible overlay recreation clears stale input state before creating replacement window', () => { const source = readMainSource(); const actionBlock = source.match(