mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-04-26 16:19:26 -07:00
fix: overwrite manual subtitle audio fields
This commit is contained in:
@@ -0,0 +1,66 @@
|
|||||||
|
---
|
||||||
|
id: TASK-299
|
||||||
|
title: Force audio replacement during manual subtitle mining
|
||||||
|
status: Done
|
||||||
|
assignee:
|
||||||
|
- Codex
|
||||||
|
created_date: '2026-04-26 00:10'
|
||||||
|
updated_date: '2026-04-26 02:42'
|
||||||
|
labels:
|
||||||
|
- anki
|
||||||
|
- mining
|
||||||
|
dependencies: []
|
||||||
|
priority: medium
|
||||||
|
---
|
||||||
|
|
||||||
|
## Description
|
||||||
|
|
||||||
|
<!-- SECTION:DESCRIPTION:BEGIN -->
|
||||||
|
Manual subtitle mining via the Ctrl+C/Ctrl+V flow should replace expression and sentence audio fields even when the user has configured media overwrite fields to false. These fields can already contain proxy-inserted SubMiner audio on a new card, and manual update intent is to replace that generated content.
|
||||||
|
<!-- SECTION:DESCRIPTION:END -->
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
<!-- AC:BEGIN -->
|
||||||
|
- [x] #1 Manual subtitle mining replaces existing expression audio content regardless of configured audio overwrite settings.
|
||||||
|
- [x] #2 Manual subtitle mining replaces existing sentence audio content regardless of configured audio overwrite settings.
|
||||||
|
- [x] #3 Non-manual mining/update flows continue to respect configured audio overwrite settings.
|
||||||
|
- [x] #4 A regression test covers manual audio replacement when overwrite settings are disabled.
|
||||||
|
<!-- AC:END -->
|
||||||
|
|
||||||
|
## Implementation Plan
|
||||||
|
|
||||||
|
<!-- SECTION:PLAN:BEGIN -->
|
||||||
|
1. Locate the manual subtitle mining Ctrl+C/Ctrl+V flow and the Anki media field overwrite gate.
|
||||||
|
2. Add a failing regression test showing manual mining overwrites expression and sentence audio when configured audio overwrite is disabled.
|
||||||
|
3. Implement the smallest path-specific override so only manual subtitle mining forces audio replacement.
|
||||||
|
4. Run the focused mining test and update task acceptance criteria/final notes.
|
||||||
|
<!-- SECTION:PLAN:END -->
|
||||||
|
|
||||||
|
## Implementation Notes
|
||||||
|
|
||||||
|
<!-- SECTION:NOTES:BEGIN -->
|
||||||
|
Implemented focused manual clipboard update behavior in CardCreationService.updateLastAddedFromClipboard: generated manual audio is written to both resolved sentence audio and expression audio fields with forced overwrite. Other update flows still use existing overwrite config paths.
|
||||||
|
|
||||||
|
Verification: focused Anki tests passed; typecheck passed; changelog lint and diff check passed. Full bun run test:fast was attempted but is blocked by unrelated existing tokenizer annotation-stage failures tied to dirty task 298 worktree changes.
|
||||||
|
<!-- SECTION:NOTES:END -->
|
||||||
|
|
||||||
|
## Final Summary
|
||||||
|
|
||||||
|
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
|
||||||
|
Summary:
|
||||||
|
- Manual clipboard subtitle updates now resolve both sentence audio and expression audio fields and replace both with the newly generated audio regardless of ankiConnect.behavior.overwriteAudio.
|
||||||
|
- Added a regression test for the Ctrl+C/Ctrl+V manual update path with existing proxy-inserted audio and overwriteAudio disabled.
|
||||||
|
- Registered the regression test in test:fast, documented the overwrite exception in user docs, and added a changelog fragment.
|
||||||
|
|
||||||
|
Verification:
|
||||||
|
- bun test src/anki-integration/card-creation-manual-update.test.ts src/anki-integration/card-creation.test.ts
|
||||||
|
- bun run tsc --noEmit
|
||||||
|
- bun test src/main-entry-runtime.test.ts src/anki-integration.test.ts src/anki-integration/card-creation-manual-update.test.ts src/anki-integration/anki-connect-proxy.test.ts src/anki-integration/field-grouping-workflow.test.ts src/anki-integration/field-grouping.test.ts src/anki-integration/field-grouping-merge.test.ts src/release-workflow.test.ts src/prerelease-workflow.test.ts src/ci-workflow.test.ts scripts/build-changelog.test.ts scripts/electron-builder-after-pack.test.ts scripts/mkv-to-readme-video.test.ts scripts/run-coverage-lane.test.ts scripts/update-aur-package.test.ts
|
||||||
|
- bun run changelog:lint
|
||||||
|
- bun run docs:test
|
||||||
|
- bun run docs:build
|
||||||
|
- git diff --check
|
||||||
|
|
||||||
|
Blocked gate:
|
||||||
|
- bun run test:fast currently fails in unrelated src/core/services/tokenizer/annotation-stage.test.ts tests for kana-only non-independent noun helper merges; those files have pre-existing dirty changes outside this task.
|
||||||
|
<!-- SECTION:FINAL_SUMMARY:END -->
|
||||||
4
changes/299-manual-subtitle-audio-overwrite.md
Normal file
4
changes/299-manual-subtitle-audio-overwrite.md
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
type: fixed
|
||||||
|
area: anki
|
||||||
|
|
||||||
|
- Anki: Manual clipboard subtitle updates now replace both expression and sentence audio fields even when configured audio overwrite is disabled.
|
||||||
@@ -213,6 +213,8 @@ Animated AVIF requires an AV1 encoder (`libaom-av1`, `libsvtav1`, or `librav1e`)
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
`overwriteAudio` applies to automatic card updates and duplicate-card enrichment. Manual clipboard subtitle updates (`Ctrl/Cmd+C`, then `Ctrl/Cmd+V`) always replace generated audio in both the expression audio field and sentence audio field.
|
||||||
|
|
||||||
## AI Translation
|
## AI Translation
|
||||||
|
|
||||||
SubMiner can auto-translate the mined sentence and fill the translation field.
|
SubMiner can auto-translate the mined sentence and fill the translation field.
|
||||||
|
|||||||
@@ -858,7 +858,7 @@ This example is intentionally compact. The option table below documents availabl
|
|||||||
**Requirements:** [AnkiConnect](https://github.com/FooSoft/anki-connect) plugin must be installed and running in Anki. ffmpeg must be installed for media generation.
|
**Requirements:** [AnkiConnect](https://github.com/FooSoft/anki-connect) plugin must be installed and running in Anki. ffmpeg must be installed for media generation.
|
||||||
|
|
||||||
| Option | Values | Description |
|
| Option | Values | Description |
|
||||||
| ------------------------------------------------- | --------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------- |
|
| ------------------------------------------------- | --------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||||
| `enabled` | `true`, `false` | Enable AnkiConnect integration (default: `true`) |
|
| `enabled` | `true`, `false` | Enable AnkiConnect integration (default: `true`) |
|
||||||
| `url` | string (URL) | AnkiConnect API URL (default: `http://127.0.0.1:8765`) |
|
| `url` | string (URL) | AnkiConnect API URL (default: `http://127.0.0.1:8765`) |
|
||||||
| `pollingRate` | number (ms) | How often to check for new cards in polling mode (default: `3000`; ignored for direct proxy `addNote`/`addNotes` updates) |
|
| `pollingRate` | number (ms) | How often to check for new cards in polling mode (default: `3000`; ignored for direct proxy `addNote`/`addNotes` updates) |
|
||||||
@@ -893,7 +893,7 @@ This example is intentionally compact. The option table below documents availabl
|
|||||||
| `media.audioPadding` | number (seconds) | Padding around audio clip timing (default: `0.5`) |
|
| `media.audioPadding` | number (seconds) | Padding around audio clip timing (default: `0.5`) |
|
||||||
| `media.fallbackDuration` | number (seconds) | Default duration if timing unavailable (default: `3.0`) |
|
| `media.fallbackDuration` | number (seconds) | Default duration if timing unavailable (default: `3.0`) |
|
||||||
| `media.maxMediaDuration` | number (seconds) | Max duration for generated media from multi-line copy (default: `30`, `0` to disable) |
|
| `media.maxMediaDuration` | number (seconds) | Max duration for generated media from multi-line copy (default: `30`, `0` to disable) |
|
||||||
| `behavior.overwriteAudio` | `true`, `false` | Replace existing audio on updates; when `false`, new audio is appended/prepended per `behavior.mediaInsertMode` (default: `true`) |
|
| `behavior.overwriteAudio` | `true`, `false` | Replace existing audio on updates; when `false`, new audio is appended/prepended per `behavior.mediaInsertMode`; manual clipboard updates always replace generated audio (default: `true`) |
|
||||||
| `behavior.overwriteImage` | `true`, `false` | Replace existing images on updates; when `false`, new images are appended/prepended per `behavior.mediaInsertMode` (default: `true`) |
|
| `behavior.overwriteImage` | `true`, `false` | Replace existing images on updates; when `false`, new images are appended/prepended per `behavior.mediaInsertMode` (default: `true`) |
|
||||||
| `behavior.mediaInsertMode` | `"append"`, `"prepend"` | Where to insert new media when overwrite is off (default: `"append"`) |
|
| `behavior.mediaInsertMode` | `"append"`, `"prepend"` | Where to insert new media when overwrite is off (default: `"append"`) |
|
||||||
| `behavior.highlightWord` | `true`, `false` | Highlight the word in sentence context (default: `true`) |
|
| `behavior.highlightWord` | `true`, `false` | Highlight the word in sentence context (default: `true`) |
|
||||||
|
|||||||
@@ -100,6 +100,8 @@ If you prefer a hands-on approach (animecards-style), you can copy the current s
|
|||||||
- For multiple lines: press `Ctrl/Cmd+Shift+C`, then a digit `1`–`9` to select how many recent subtitle lines to combine. The combined text is copied to the clipboard.
|
- For multiple lines: press `Ctrl/Cmd+Shift+C`, then a digit `1`–`9` to select how many recent subtitle lines to combine. The combined text is copied to the clipboard.
|
||||||
3. Press `Ctrl/Cmd+V` to update the last-added card with the clipboard contents plus audio, image, and translation — the same fields auto-update would fill.
|
3. Press `Ctrl/Cmd+V` to update the last-added card with the clipboard contents plus audio, image, and translation — the same fields auto-update would fill.
|
||||||
|
|
||||||
|
Manual clipboard updates always replace generated audio in both the expression audio field and sentence audio field, even when `ankiConnect.behavior.overwriteAudio` is disabled. The manual flow assumes you are intentionally replacing the proxy-generated clip on the newest card.
|
||||||
|
|
||||||
This is useful when auto-update is disabled or when you want explicit control over which subtitle line gets attached to the card.
|
This is useful when auto-update is disabled or when you want explicit control over which subtitle line gets attached to the card.
|
||||||
|
|
||||||
| Shortcut | Action | Config key |
|
| Shortcut | Action | Config key |
|
||||||
|
|||||||
@@ -70,7 +70,7 @@
|
|||||||
"test:launcher": "bun run test:launcher:src",
|
"test:launcher": "bun run test:launcher:src",
|
||||||
"test:core": "bun run test:core:src",
|
"test:core": "bun run test:core:src",
|
||||||
"test:subtitle": "bun run test:subtitle:src",
|
"test:subtitle": "bun run test:subtitle:src",
|
||||||
"test:fast": "bun run test:config:src && bun run test:core:src && bun run test:docs:kb && bun test src/main-entry-runtime.test.ts src/anki-integration.test.ts src/anki-integration/anki-connect-proxy.test.ts src/anki-integration/field-grouping-workflow.test.ts src/anki-integration/field-grouping.test.ts src/anki-integration/field-grouping-merge.test.ts src/release-workflow.test.ts src/prerelease-workflow.test.ts src/ci-workflow.test.ts scripts/build-changelog.test.ts scripts/electron-builder-after-pack.test.ts scripts/mkv-to-readme-video.test.ts scripts/run-coverage-lane.test.ts scripts/update-aur-package.test.ts && bun test src/core/services/immersion-tracker/__tests__/query.test.ts src/core/services/immersion-tracker/__tests__/query-split-modules.test.ts && bun run tsc && bun test dist/main/runtime/registry.test.js",
|
"test:fast": "bun run test:config:src && bun run test:core:src && bun run test:docs:kb && bun test src/main-entry-runtime.test.ts src/anki-integration.test.ts src/anki-integration/card-creation-manual-update.test.ts src/anki-integration/anki-connect-proxy.test.ts src/anki-integration/field-grouping-workflow.test.ts src/anki-integration/field-grouping.test.ts src/anki-integration/field-grouping-merge.test.ts src/release-workflow.test.ts src/prerelease-workflow.test.ts src/ci-workflow.test.ts scripts/build-changelog.test.ts scripts/electron-builder-after-pack.test.ts scripts/mkv-to-readme-video.test.ts scripts/run-coverage-lane.test.ts scripts/update-aur-package.test.ts && bun test src/core/services/immersion-tracker/__tests__/query.test.ts src/core/services/immersion-tracker/__tests__/query-split-modules.test.ts && bun run tsc && bun test dist/main/runtime/registry.test.js",
|
||||||
"generate:config-example": "bun run src/generate-config-example.ts",
|
"generate:config-example": "bun run src/generate-config-example.ts",
|
||||||
"verify:config-example": "bun run src/verify-config-example.ts",
|
"verify:config-example": "bun run src/verify-config-example.ts",
|
||||||
"start": "bun run build && electron . --start",
|
"start": "bun run build && electron . --start",
|
||||||
|
|||||||
143
src/anki-integration/card-creation-manual-update.test.ts
Normal file
143
src/anki-integration/card-creation-manual-update.test.ts
Normal file
@@ -0,0 +1,143 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
|
||||||
|
import { CardCreationService } from './card-creation';
|
||||||
|
import type { AnkiConnectConfig } from '../types/anki';
|
||||||
|
|
||||||
|
type CardCreationDeps = ConstructorParameters<typeof CardCreationService>[0];
|
||||||
|
|
||||||
|
function createManualUpdateService(overrides: Partial<CardCreationDeps> = {}): {
|
||||||
|
service: CardCreationService;
|
||||||
|
updatedFields: Record<string, string>[];
|
||||||
|
mergeCalls: Array<{ existing: string; newValue: string; overwrite: boolean }>;
|
||||||
|
storedMedia: string[];
|
||||||
|
} {
|
||||||
|
const updatedFields: Record<string, string>[] = [];
|
||||||
|
const mergeCalls: Array<{ existing: string; newValue: string; overwrite: boolean }> = [];
|
||||||
|
const storedMedia: string[] = [];
|
||||||
|
|
||||||
|
const deps: CardCreationDeps = {
|
||||||
|
getConfig: () =>
|
||||||
|
({
|
||||||
|
deck: 'Mining',
|
||||||
|
fields: {
|
||||||
|
word: 'Expression',
|
||||||
|
sentence: 'Sentence',
|
||||||
|
audio: 'ExpressionAudio',
|
||||||
|
},
|
||||||
|
media: {
|
||||||
|
generateAudio: true,
|
||||||
|
generateImage: false,
|
||||||
|
maxMediaDuration: 30,
|
||||||
|
},
|
||||||
|
behavior: {
|
||||||
|
overwriteAudio: false,
|
||||||
|
overwriteImage: false,
|
||||||
|
},
|
||||||
|
ai: false,
|
||||||
|
}) as AnkiConnectConfig,
|
||||||
|
getAiConfig: () => ({}),
|
||||||
|
getTimingTracker: () =>
|
||||||
|
({
|
||||||
|
findTiming: (text: string) => (text === '字幕' ? { startTime: 12, endTime: 14 } : null),
|
||||||
|
}) as never,
|
||||||
|
getMpvClient: () =>
|
||||||
|
({
|
||||||
|
currentVideoPath: '/video.mp4',
|
||||||
|
currentAudioStreamIndex: 0,
|
||||||
|
}) as never,
|
||||||
|
client: {
|
||||||
|
addNote: async () => 0,
|
||||||
|
addTags: async () => undefined,
|
||||||
|
notesInfo: async () => [
|
||||||
|
{
|
||||||
|
noteId: 42,
|
||||||
|
fields: {
|
||||||
|
Expression: { value: '単語' },
|
||||||
|
Sentence: { value: '' },
|
||||||
|
ExpressionAudio: { value: '[sound:auto-expression.mp3]' },
|
||||||
|
SentenceAudio: { value: '[sound:auto-sentence.mp3]' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
updateNoteFields: async (_noteId, fields) => {
|
||||||
|
updatedFields.push(fields);
|
||||||
|
},
|
||||||
|
storeMediaFile: async (filename) => {
|
||||||
|
storedMedia.push(filename);
|
||||||
|
},
|
||||||
|
findNotes: async () => [42],
|
||||||
|
retrieveMediaFile: async () => '',
|
||||||
|
},
|
||||||
|
mediaGenerator: {
|
||||||
|
generateAudio: async () => Buffer.from('audio'),
|
||||||
|
generateScreenshot: async () => null,
|
||||||
|
generateAnimatedImage: async () => null,
|
||||||
|
},
|
||||||
|
showOsdNotification: () => undefined,
|
||||||
|
showUpdateResult: () => undefined,
|
||||||
|
showStatusNotification: () => undefined,
|
||||||
|
showNotification: async () => undefined,
|
||||||
|
beginUpdateProgress: () => undefined,
|
||||||
|
endUpdateProgress: () => undefined,
|
||||||
|
withUpdateProgress: async (_message, action) => action(),
|
||||||
|
resolveConfiguredFieldName: (noteInfo, ...preferredNames) => {
|
||||||
|
for (const preferredName of preferredNames) {
|
||||||
|
if (preferredName && preferredName in noteInfo.fields) return preferredName;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
resolveNoteFieldName: (noteInfo, preferredName) =>
|
||||||
|
preferredName && preferredName in noteInfo.fields ? preferredName : null,
|
||||||
|
getAnimatedImageLeadInSeconds: async () => 0,
|
||||||
|
extractFields: (fields) =>
|
||||||
|
Object.fromEntries(
|
||||||
|
Object.entries(fields).map(([name, field]) => [name.toLowerCase(), field.value]),
|
||||||
|
),
|
||||||
|
processSentence: (sentence) => sentence,
|
||||||
|
setCardTypeFields: () => undefined,
|
||||||
|
mergeFieldValue: (existing, newValue, overwrite) => {
|
||||||
|
mergeCalls.push({ existing, newValue, overwrite });
|
||||||
|
return overwrite || !existing.trim() ? newValue : existing;
|
||||||
|
},
|
||||||
|
formatMiscInfoPattern: () => '',
|
||||||
|
getEffectiveSentenceCardConfig: () => ({
|
||||||
|
model: 'Sentence',
|
||||||
|
sentenceField: 'Sentence',
|
||||||
|
audioField: 'SentenceAudio',
|
||||||
|
lapisEnabled: false,
|
||||||
|
kikuEnabled: false,
|
||||||
|
kikuFieldGrouping: 'disabled',
|
||||||
|
kikuDeleteDuplicateInAuto: false,
|
||||||
|
}),
|
||||||
|
getFallbackDurationSeconds: () => 10,
|
||||||
|
appendKnownWordsFromNoteInfo: () => undefined,
|
||||||
|
isUpdateInProgress: () => false,
|
||||||
|
setUpdateInProgress: () => undefined,
|
||||||
|
trackLastAddedNoteId: () => undefined,
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
service: new CardCreationService(deps),
|
||||||
|
updatedFields,
|
||||||
|
mergeCalls,
|
||||||
|
storedMedia,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
test('manual clipboard subtitle update replaces expression and sentence audio even when overwriteAudio is disabled', async () => {
|
||||||
|
const { service, updatedFields, mergeCalls, storedMedia } = createManualUpdateService();
|
||||||
|
|
||||||
|
await service.updateLastAddedFromClipboard('字幕');
|
||||||
|
|
||||||
|
assert.equal(updatedFields.length, 1);
|
||||||
|
assert.equal(storedMedia.length, 1);
|
||||||
|
const audioValue = `[sound:${storedMedia[0]}]`;
|
||||||
|
assert.equal(updatedFields[0]?.ExpressionAudio, audioValue);
|
||||||
|
assert.equal(updatedFields[0]?.SentenceAudio, audioValue);
|
||||||
|
assert.deepEqual(
|
||||||
|
mergeCalls.map((call) => call.overwrite),
|
||||||
|
[true, true],
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -219,6 +219,10 @@ export class CardCreationService {
|
|||||||
this.deps.getConfig(),
|
this.deps.getConfig(),
|
||||||
);
|
);
|
||||||
const sentenceAudioField = this.getResolvedSentenceAudioFieldName(noteInfo);
|
const sentenceAudioField = this.getResolvedSentenceAudioFieldName(noteInfo);
|
||||||
|
const expressionAudioField = this.deps.resolveConfiguredFieldName(
|
||||||
|
noteInfo,
|
||||||
|
this.deps.getConfig().fields?.audio || 'ExpressionAudio',
|
||||||
|
);
|
||||||
const sentenceField = this.deps.getEffectiveSentenceCardConfig().sentenceField;
|
const sentenceField = this.deps.getEffectiveSentenceCardConfig().sentenceField;
|
||||||
|
|
||||||
const sentence = blocks.join(' ');
|
const sentence = blocks.join(' ');
|
||||||
@@ -248,13 +252,21 @@ export class CardCreationService {
|
|||||||
|
|
||||||
if (audioBuffer) {
|
if (audioBuffer) {
|
||||||
await this.deps.client.storeMediaFile(audioFilename, audioBuffer);
|
await this.deps.client.storeMediaFile(audioFilename, audioBuffer);
|
||||||
if (sentenceAudioField) {
|
if (sentenceAudioField || expressionAudioField) {
|
||||||
const existingAudio = noteInfo.fields[sentenceAudioField]?.value || '';
|
const audioValue = `[sound:${audioFilename}]`;
|
||||||
updatedFields[sentenceAudioField] = this.deps.mergeFieldValue(
|
const audioFields = new Set(
|
||||||
existingAudio,
|
[sentenceAudioField, expressionAudioField].filter(
|
||||||
`[sound:${audioFilename}]`,
|
(fieldName): fieldName is string => Boolean(fieldName),
|
||||||
this.deps.getConfig().behavior?.overwriteAudio !== false,
|
),
|
||||||
);
|
);
|
||||||
|
for (const audioField of audioFields) {
|
||||||
|
const existingAudio = noteInfo.fields[audioField]?.value || '';
|
||||||
|
updatedFields[audioField] = this.deps.mergeFieldValue(
|
||||||
|
existingAudio,
|
||||||
|
audioValue,
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
miscInfoFilename = audioFilename;
|
miscInfoFilename = audioFilename;
|
||||||
updatePerformed = true;
|
updatePerformed = true;
|
||||||
|
|||||||
Reference in New Issue
Block a user