Compare commits

...
Author SHA1 Message Date
sudacode d1ce70dbd0 fix(anki): close trailing Senren markup 2026-09-01 00:51:36 -07:00
sudacode 8eba6d11f9 fix(anki): preserve one-sided Senren furigana 2026-08-31 23:48:11 -07:00
sudacode 6a6af369ec fix(anki): sync field grouping fixtures with main 2026-08-31 23:43:20 -07:00
sudacode a20269e9f5 fix(jellyfin): stop inferring subtitle delays (#227) 2026-08-31 23:31:31 -07:00
sudacode 9e1c8f91b2 feat(anki): support Senren scene-switching field grouping
- Add Senren configuration, duplicate merging, and scene-aligned media fields
- Document Senren setup and enforce Kiku/Senren exclusivity
2026-08-31 23:29:55 -07:00
50 changed files with 648 additions and 798 deletions
+4
View File
@@ -0,0 +1,4 @@
type: fixed
area: jellyfin
- Jellyfin subtitle files now load with zero mpv delay instead of inferring and saving an offset from Japanese and English cue timelines.
+5
View File
@@ -0,0 +1,5 @@
type: added
area: anki
- Senren note type support for duplicate-card field grouping: enable `ankiConnect.isSenren` to merge duplicate mined cards using Senren's scene-switching markup, with grouped sentence, furigana, audio, picture, and miscInfo entries.
- Senren field grouping supports the same auto/manual/disabled modes as Kiku, including the manual merge modal, and is mutually exclusive with Kiku (only one can be enabled at a time).
+6 -1
View File
@@ -523,7 +523,7 @@
// ==========================================
// AnkiConnect Integration
// Automatic Anki updates and media generation options.
// Hot-reload: ankiConnect.ai.enabled, media.normalizeAudio/mirrorMpvVolume, knownWords, nPlusOne, fields.word/audio/image/sentence/miscInfo, behavior.autoUpdateNewCards, isLapis.sentenceCardModel, isKiku.fieldGrouping, and lapisKiku.wordCardKind update live while SubMiner is running.
// Hot-reload: ankiConnect.ai.enabled, media.normalizeAudio/mirrorMpvVolume, knownWords, nPlusOne, fields.word/audio/image/sentence/miscInfo, behavior.autoUpdateNewCards, isLapis.sentenceCardModel, isKiku.fieldGrouping, isSenren.fieldGrouping, and lapisKiku.wordCardKind update live while SubMiner is running.
// Shared AI provider transport settings are read from top-level ai and typically require restart.
// Most other AnkiConnect settings still require restart.
// ==========================================
@@ -606,6 +606,11 @@
"fieldGrouping": "disabled", // Kiku duplicate-card field grouping mode. Values: auto | manual | disabled
"deleteDuplicateInAuto": true // When Kiku field grouping is "auto", delete the duplicate source card after grouping completes. Values: true | false
}, // Is kiku setting.
"isSenren": {
"enabled": false, // Enable Senren-specific duplicate handling (scene-switching field grouping, including miscInfo grouping). Mutually exclusive with isKiku.enabled. Values: true | false
"fieldGrouping": "auto", // Senren duplicate-card field grouping mode (scene switching). Values: auto | manual | disabled
"deleteDuplicateInAuto": true // When Senren field grouping is "auto", delete the duplicate source card after grouping completes. Values: true | false
}, // Is senren setting.
"lapisKiku": {
"wordCardKind": "word-and-sentence" // Card-type flag SubMiner marks on Kiku/Lapis word cards. Only one flag is set at a time; the others are cleared. Requires isKiku.enabled or isLapis.enabled. Values: word-and-sentence | click | sentence | audio | none
} // Lapis kiku setting.
+17 -2
View File
@@ -308,9 +308,9 @@ Word cards get a card-type flag when SubMiner fills their sentence, whether that
`click` marks `IsClickCard`, `sentence` marks `IsSentenceCard`, `audio` marks `IsAudioCard`, and `none` leaves the flags untouched for templates that manage them elsewhere. Whichever flag is chosen, the other card-type flags are cleared so the note never claims two card types. The setting is only read when `isKiku` or `isLapis` is enabled, and cards mined with Mine Sentence or Mine Audio keep their own flag.
## Field Grouping (Kiku)
## Field Grouping (Kiku/Senren)
When you mine the same word multiple times, SubMiner can merge the cards instead of creating duplicates. This is designed for note types like [Kiku](https://github.com/youyoumu/kiku) that support grouped sentence/audio/image fields.
When you mine the same word multiple times, SubMiner can merge the cards instead of creating duplicates. This is designed for note types that support grouped fields: [Kiku](https://github.com/youyoumu/kiku) and [Senren](https://github.com/BrenoAqua/Senren) (which calls the feature scene switching).
```jsonc
"ankiConnect": {
@@ -322,6 +322,18 @@ When you mine the same word multiple times, SubMiner can merge the cards instead
}
```
For Senren note types, enable `isSenren` instead. Kiku and Senren write incompatible markup into the same fields, so only one can be enabled at a time; if both are enabled, Kiku wins and a config warning is emitted.
```jsonc
"ankiConnect": {
"isSenren": {
"enabled": true,
"fieldGrouping": "auto", // "auto" (default), "manual", or "disabled"
"deleteDuplicateInAuto": true // delete new card after auto-merge
}
}
```
### Modes
**Disabled** (`"disabled"`): No duplicate detection. Each card is independent.
@@ -337,9 +349,12 @@ When you mine the same word multiple times, SubMiner can merge the cards instead
| Sentence | Both cards' sentences kept as grouped entries |
| Audio | Both cards' `[sound:...]` entries kept |
| Image | Both cards' images kept |
| MiscInfo | Both cards' source info kept as grouped entries |
Identical values from both cards are kept as separate grouped entries; the merge does not deduplicate.
The merge markup depends on the note type. Kiku entries are wrapped in `<span data-group-id="...">` spans ordered newest first. Senren entries follow the [scene switching](https://github.com/BrenoAqua/Senren/blob/main/docs/scene_switching.md) format: sentence, sentenceFurigana, and miscInfo entries are appended as `<span class="group">` spans, audio and pictures are appended positionally, and the number of sentenceAudio entries drives Senren's scene count. Ungrouped legacy content is wrapped into a group span on first merge, and existing numbered `groupN` spans in miscInfo are preserved.
### Keyboard Shortcuts in the Modal
| Key | Action |
+4 -2
View File
@@ -148,9 +148,9 @@ The configuration file includes several main sections:
- [**Shared AI Provider**](#shared-ai-provider) - Canonical OpenAI-compatible provider config shared by Anki and YouTube subtitle fixing
- [**AnkiConnect**](#ankiconnect) - Automatic Anki card creation with media
- [**Kiku/Lapis Integration**](#kiku-lapis-integration) - Sentence cards and duplicate handling for Kiku/Lapis note types
- [**Kiku/Lapis Integration**](#kiku-lapis-integration) - Sentence cards and duplicate handling for Kiku/Lapis/Senren note types
- [**N+1 Word Highlighting**](#n-1-word-highlighting) - Known-word cache and single-target highlighting
- [**Field Grouping Modes**](#field-grouping-modes) - Kiku/Lapis duplicate card merging
- [**Field Grouping Modes**](#field-grouping-modes) - Kiku/Senren duplicate card merging
**External Integrations**
@@ -1051,6 +1051,7 @@ This example is intentionally compact. The option table below documents availabl
| `metadata.pattern` | string | Format pattern for metadata: `%f`=filename, `%F`=filename+ext, `%t`=time, `%T`=time with milliseconds, `<br>`=newline |
| `isLapis` | object | Lapis/shared sentence-card config: `{ enabled, sentenceCardModel }`. Sentence/audio field names are fixed to `Sentence` and `SentenceAudio`. |
| `isKiku` | object | Kiku-only config: `{ enabled, fieldGrouping, deleteDuplicateInAuto }` (shared sentence/audio/model settings are inherited from `isLapis`) |
| `isSenren` | object | Senren-only config: `{ enabled, fieldGrouping, deleteDuplicateInAuto }`. Merges duplicates using Senren's scene-switching markup. Mutually exclusive with `isKiku.enabled`. |
`ankiConnect.ai` only controls feature-local enablement plus optional `model` / `systemPrompt` overrides.
API key resolution, base URL, and timeout live under the shared top-level [`ai`](#shared-ai-provider) config.
@@ -1080,6 +1081,7 @@ SubMiner is intentionally built for [Kiku](https://kiku.youyoumu.my.id/) and [La
- Enable `isKiku` to turn on duplicate merge behavior for mined Word/Expression hits.
- When both are enabled, Kiku behavior is applied for grouping while sentence-card model settings are still read from `isLapis`.
- `isKiku.fieldGrouping` supports `disabled`, `auto`, and `manual` merge modes; see [Field Grouping Modes](#field-grouping-modes).
- For [Senren](https://github.com/BrenoAqua/Senren) note types, enable `isSenren` instead of `isKiku`. Duplicate merges then use Senren's scene-switching markup (including grouped `miscInfo` entries), and `isSenren.fieldGrouping` supports the same three modes (default: `auto`). Kiku and Senren are mutually exclusive; if both are enabled, Kiku wins and Senren is turned off with a config warning.
- `lapisKiku.wordCardKind` picks the card-type flag set on word cards; see [Word Card Type](#word-card-type). It is read only while `isLapis` or `isKiku` is enabled.
### Word Card Type
+1 -1
View File
@@ -54,7 +54,7 @@ From then on, pause / resume / seek / stop and audio or subtitle track changes y
- **Resume works.** If Jellyfin has a saved position for the item, SubMiner seeks there on load.
- **Direct play first.** When the source allows it and the container is in your direct-play allowlist, SubMiner streams the original file; otherwise it requests a transcoded stream from Jellyfin.
- **Japanese subtitles are auto-selected,** preferring Jellyfin's default and embedded tracks over external sidecar files when several match.
- **Subtitle timing is corrected when possible.** SubMiner removes Jellyfin's server-selected subtitle stream from the mpv load URL, suppresses the mpv plugin's one-shot subtitle auto-selection and overlay auto-start for managed Jellyfin loads, stages downloaded subtitle tracks without letting mpv auto-switch between tracks, then selects the Japanese track once after applying any saved or inferred timing delay. When Jellyfin provides both Japanese and English subtitle files, SubMiner compares their cue timelines and applies a global delay if one track is clearly offset. Manual delay shifts you make with SubMiner's adjacent-cue controls are saved per item and subtitle track, then restored the next time you select that track.
- **Downloaded subtitles keep their original timing.** SubMiner removes Jellyfin's server-selected subtitle stream from the mpv load URL, suppresses the mpv plugin's one-shot subtitle auto-selection and overlay auto-start for managed Jellyfin loads, stages the subtitle files exposed by Jellyfin without letting mpv auto-switch between tracks, resets mpv's subtitle delay to zero, then selects the Japanese track. SubMiner does not compare Japanese and English cue timelines or save an inferred delay.
## Settings
+6 -6
View File
@@ -72,17 +72,17 @@ After adding a word via Yomitan, press the audio card shortcut (`Ctrl/Cmd+Shift+
Audio card marking uses the same `ankiConnect.isLapis.sentenceCardModel` note type as sentence cards. See [Anki Integration - Sentence Cards](/anki-integration#sentence-cards-lapis) for setup.
:::
### Field Grouping (Kiku)
### Field Grouping (Kiku/Senren)
If you mine the same word from different sentences, SubMiner can merge the cards instead of creating duplicates. This feature is designed for use with [Kiku](https://github.com/youyoumu/kiku) and similar note types that support grouped fields.
If you mine the same word from different sentences, SubMiner can merge the cards instead of creating duplicates. This feature is designed for use with [Kiku](https://github.com/youyoumu/kiku) and [Senren](https://github.com/BrenoAqua/Senren) note types that support grouped fields (Senren calls it scene switching).
1. You add a word via Yomitan.
2. SubMiner detects the new card and checks if a card with the same expression already exists.
3. If a duplicate is found (this requires `ankiConnect.isKiku.fieldGrouping` to be set to `"auto"` or `"manual"`; it defaults to `"disabled"`):
- **Auto mode** (`ankiConnect.isKiku.fieldGrouping: "auto"`): Merges automatically. Both sentences, audio clips, and images are combined into the existing card. The duplicate is optionally deleted.
- **Manual mode** (`ankiConnect.isKiku.fieldGrouping: "manual"`): A modal appears showing both cards side by side. You choose which card to keep and preview the merged result before confirming.
3. If a duplicate is found (this requires Kiku or Senren to be enabled with a field grouping mode of `"auto"` or `"manual"`):
- **Auto mode**: Merges automatically. Both sentences, audio clips, images, and source info are combined into the existing card. The duplicate is optionally deleted.
- **Manual mode**: A modal appears showing both cards side by side. You choose which card to keep and preview the merged result before confirming.
See [Anki Integration - Field Grouping](/anki-integration#field-grouping-kiku) for configuration options, merge behavior, and modal keyboard shortcuts.
See [Anki Integration - Field Grouping](/anki-integration#field-grouping-kiku-senren) for configuration options, merge behavior, and modal keyboard shortcuts.
## Overlay Model
+6 -1
View File
@@ -523,7 +523,7 @@
// ==========================================
// AnkiConnect Integration
// Automatic Anki updates and media generation options.
// Hot-reload: ankiConnect.ai.enabled, media.normalizeAudio/mirrorMpvVolume, knownWords, nPlusOne, fields.word/audio/image/sentence/miscInfo, behavior.autoUpdateNewCards, isLapis.sentenceCardModel, isKiku.fieldGrouping, and lapisKiku.wordCardKind update live while SubMiner is running.
// Hot-reload: ankiConnect.ai.enabled, media.normalizeAudio/mirrorMpvVolume, knownWords, nPlusOne, fields.word/audio/image/sentence/miscInfo, behavior.autoUpdateNewCards, isLapis.sentenceCardModel, isKiku.fieldGrouping, isSenren.fieldGrouping, and lapisKiku.wordCardKind update live while SubMiner is running.
// Shared AI provider transport settings are read from top-level ai and typically require restart.
// Most other AnkiConnect settings still require restart.
// ==========================================
@@ -606,6 +606,11 @@
"fieldGrouping": "disabled", // Kiku duplicate-card field grouping mode. Values: auto | manual | disabled
"deleteDuplicateInAuto": true // When Kiku field grouping is "auto", delete the duplicate source card after grouping completes. Values: true | false
}, // Is kiku setting.
"isSenren": {
"enabled": false, // Enable Senren-specific duplicate handling (scene-switching field grouping, including miscInfo grouping). Mutually exclusive with isKiku.enabled. Values: true | false
"fieldGrouping": "auto", // Senren duplicate-card field grouping mode (scene switching). Values: auto | manual | disabled
"deleteDuplicateInAuto": true // When Senren field grouping is "auto", delete the duplicate source card after grouping completes. Values: true | false
}, // Is senren setting.
"lapisKiku": {
"wordCardKind": "word-and-sentence" // Card-type flag SubMiner marks on Kiku/Lapis word cards. Only one flag is set at a time; the others are cleared. Requires isKiku.enabled or isLapis.enabled. Values: word-and-sentence | click | sentence | audio | none
} // Lapis kiku setting.
+1
View File
@@ -155,6 +155,7 @@ function createFieldGroupingMergeCollaborator(options?: {
getEffectiveSentenceCardConfig: () => ({
sentenceField: 'Sentence',
audioField: 'SentenceAudio',
fieldGroupingProvider: 'kiku' as const,
}),
getCurrentSubtitleText: () => options?.currentSubtitleText,
resolveFieldName,
+40 -3
View File
@@ -835,6 +835,19 @@ export class AnkiIntegration {
};
}
private getSenrenConfig(): {
enabled: boolean;
fieldGrouping?: 'auto' | 'manual' | 'disabled';
deleteDuplicateInAuto?: boolean;
} {
const senren = this.config.isSenren;
return {
enabled: senren?.enabled === true,
fieldGrouping: senren?.fieldGrouping,
deleteDuplicateInAuto: senren?.deleteDuplicateInAuto,
};
}
private getEffectiveSentenceCardConfig(): {
model?: string;
sentenceField: string;
@@ -843,10 +856,27 @@ export class AnkiIntegration {
kikuEnabled: boolean;
kikuFieldGrouping: 'auto' | 'manual' | 'disabled';
kikuDeleteDuplicateInAuto: boolean;
senrenEnabled: boolean;
fieldGroupingProvider: 'kiku' | 'senren' | null;
fieldGroupingMode: 'auto' | 'manual' | 'disabled';
fieldGroupingDeleteDuplicateInAuto: boolean;
wordCardKind: WordCardKind;
} {
const lapis = this.getLapisConfig();
const kiku = this.getKikuConfig();
const senren = this.getSenrenConfig();
const kikuFieldGrouping = (kiku.fieldGrouping || 'disabled') as 'auto' | 'manual' | 'disabled';
const senrenFieldGrouping = (senren.fieldGrouping || 'auto') as 'auto' | 'manual' | 'disabled';
// Kiku and Senren are mutually exclusive; config resolution enforces it, and
// Kiku wins here too in case a runtime patch re-enables both.
const fieldGroupingProvider = kiku.enabled ? 'kiku' : senren.enabled ? 'senren' : null;
const fieldGroupingMode =
fieldGroupingProvider === 'kiku'
? kikuFieldGrouping
: fieldGroupingProvider === 'senren'
? senrenFieldGrouping
: 'disabled';
return {
model: lapis.sentenceCardModel,
@@ -854,8 +884,15 @@ export class AnkiIntegration {
audioField: 'SentenceAudio',
lapisEnabled: lapis.enabled,
kikuEnabled: kiku.enabled,
kikuFieldGrouping: (kiku.fieldGrouping || 'disabled') as 'auto' | 'manual' | 'disabled',
kikuFieldGrouping,
kikuDeleteDuplicateInAuto: kiku.deleteDuplicateInAuto !== false,
senrenEnabled: senren.enabled,
fieldGroupingProvider,
fieldGroupingMode,
fieldGroupingDeleteDuplicateInAuto:
fieldGroupingProvider === 'senren'
? senren.deleteDuplicateInAuto !== false
: kiku.deleteDuplicateInAuto !== false,
wordCardKind: resolveWordCardKindSetting(this.config.lapisKiku?.wordCardKind),
};
}
@@ -874,7 +911,7 @@ export class AnkiIntegration {
private async processNewCard(
noteId: number,
options?: { skipKikuFieldGrouping?: boolean },
options?: { skipFieldGrouping?: boolean },
): Promise<void> {
await this.noteUpdateWorkflow.execute(noteId, options);
}
@@ -1504,7 +1541,7 @@ export class AnkiIntegration {
trackedDuplicateNoteIdsBeforeCreate: Set<number>,
): boolean {
const sentenceCardConfig = this.getEffectiveSentenceCardConfig();
if (!sentenceCardConfig.kikuEnabled || sentenceCardConfig.kikuFieldGrouping === 'disabled') {
if (sentenceCardConfig.fieldGroupingMode === 'disabled') {
return false;
}
@@ -124,8 +124,7 @@ function createManualUpdateService(overrides: Partial<CardCreationDeps> = {}): {
audioField: 'SentenceAudio',
lapisEnabled: false,
kikuEnabled: false,
kikuFieldGrouping: 'disabled',
kikuDeleteDuplicateInAuto: false,
fieldGroupingMode: 'disabled',
}),
getFallbackDurationSeconds: () => 10,
appendKnownWordsFromNoteInfo: () => undefined,
@@ -208,8 +207,7 @@ test('manual clipboard word-card update uses configured fields with Lapis and Ki
audioField: 'SentenceAudio',
lapisEnabled: true,
kikuEnabled: true,
kikuFieldGrouping: 'disabled',
kikuDeleteDuplicateInAuto: false,
fieldGroupingMode: 'disabled',
}),
});
@@ -275,8 +273,7 @@ test('audio-card action keeps Lapis and Kiku sentence fields', async () => {
audioField: 'SentenceAudio',
lapisEnabled: true,
kikuEnabled: true,
kikuFieldGrouping: 'disabled',
kikuDeleteDuplicateInAuto: false,
fieldGroupingMode: 'disabled',
}),
});
@@ -338,8 +335,7 @@ test('manual clipboard subtitle update marks Kiku word cards as word-and-sentenc
audioField: 'SentenceAudio',
lapisEnabled: false,
kikuEnabled: true,
kikuFieldGrouping: 'disabled',
kikuDeleteDuplicateInAuto: false,
fieldGroupingMode: 'disabled',
}),
setCardTypeFields,
});
@@ -117,8 +117,7 @@ test('sentence card writes generated audio only to sentence audio field', async
audioField: 'SentenceAudio',
lapisEnabled: true,
kikuEnabled: false,
kikuFieldGrouping: 'disabled',
kikuDeleteDuplicateInAuto: false,
fieldGroupingMode: 'disabled',
}),
getFallbackDurationSeconds: () => 10,
appendKnownWordsFromNoteInfo: () => undefined,
+8 -16
View File
@@ -69,8 +69,7 @@ test('CardCreationService counts locally created sentence cards', async () => {
audioField: 'SentenceAudio',
lapisEnabled: false,
kikuEnabled: false,
kikuFieldGrouping: 'disabled',
kikuDeleteDuplicateInAuto: false,
fieldGroupingMode: 'disabled',
}),
getFallbackDurationSeconds: () => 10,
appendKnownWordsFromNoteInfo: () => undefined,
@@ -168,8 +167,7 @@ test('CardCreationService keeps updating after trackLastAddedNoteId throws', asy
audioField: 'SentenceAudio',
lapisEnabled: false,
kikuEnabled: false,
kikuFieldGrouping: 'disabled',
kikuDeleteDuplicateInAuto: false,
fieldGroupingMode: 'disabled',
}),
getFallbackDurationSeconds: () => 10,
appendKnownWordsFromNoteInfo: () => undefined,
@@ -267,8 +265,7 @@ test('CardCreationService keeps updating after recordCardsMinedCallback throws',
audioField: 'SentenceAudio',
lapisEnabled: false,
kikuEnabled: false,
kikuFieldGrouping: 'disabled',
kikuDeleteDuplicateInAuto: false,
fieldGroupingMode: 'disabled',
}),
getFallbackDurationSeconds: () => 10,
appendKnownWordsFromNoteInfo: () => undefined,
@@ -387,8 +384,7 @@ test('CardCreationService uses stream-open-filename for remote media generation'
audioField: 'SentenceAudio',
lapisEnabled: false,
kikuEnabled: false,
kikuFieldGrouping: 'disabled',
kikuDeleteDuplicateInAuto: false,
fieldGroupingMode: 'disabled',
}),
getFallbackDurationSeconds: () => 10,
appendKnownWordsFromNoteInfo: () => undefined,
@@ -490,8 +486,7 @@ test('CardCreationService does not use mpv stream indexes for ready cached YouTu
audioField: 'SentenceAudio',
lapisEnabled: false,
kikuEnabled: false,
kikuFieldGrouping: 'disabled',
kikuDeleteDuplicateInAuto: false,
fieldGroupingMode: 'disabled',
}),
getFallbackDurationSeconds: () => 10,
appendKnownWordsFromNoteInfo: () => undefined,
@@ -629,8 +624,7 @@ test('CardCreationService queues YouTube media when required cache is not ready'
audioField: 'SentenceAudio',
lapisEnabled: false,
kikuEnabled: false,
kikuFieldGrouping: 'disabled',
kikuDeleteDuplicateInAuto: false,
fieldGroupingMode: 'disabled',
}),
getFallbackDurationSeconds: () => 10,
appendKnownWordsFromNoteInfo: () => undefined,
@@ -728,8 +722,7 @@ test('CardCreationService tracks pre-add duplicate note ids for kiku sentence ca
audioField: 'SentenceAudio',
lapisEnabled: false,
kikuEnabled: true,
kikuFieldGrouping: 'manual',
kikuDeleteDuplicateInAuto: false,
fieldGroupingMode: 'manual',
}),
getFallbackDurationSeconds: () => 10,
appendKnownWordsFromNoteInfo: () => undefined,
@@ -817,8 +810,7 @@ test('CardCreationService does not track duplicate ids when pre-add lookup retur
audioField: 'SentenceAudio',
lapisEnabled: false,
kikuEnabled: true,
kikuFieldGrouping: 'manual',
kikuDeleteDuplicateInAuto: false,
fieldGroupingMode: 'manual',
}),
getFallbackDurationSeconds: () => 10,
appendKnownWordsFromNoteInfo: () => undefined,
+2 -4
View File
@@ -132,8 +132,7 @@ interface CardCreationDeps {
audioField: string;
lapisEnabled: boolean;
kikuEnabled: boolean;
kikuFieldGrouping: 'auto' | 'manual' | 'disabled';
kikuDeleteDuplicateInAuto: boolean;
fieldGroupingMode: 'auto' | 'manual' | 'disabled';
wordCardKind?: WordCardKind;
};
getFallbackDurationSeconds: () => number;
@@ -638,8 +637,7 @@ export class CardCreationService {
).trim();
let duplicateNoteIds: number[] = [];
if (
sentenceCardConfig.kikuEnabled &&
sentenceCardConfig.kikuFieldGrouping !== 'disabled' &&
sentenceCardConfig.fieldGroupingMode !== 'disabled' &&
pendingExpressionText &&
this.deps.findDuplicateNoteIds
) {
@@ -26,6 +26,7 @@ function createCollaborator(
miscInfoValue?: string;
};
warnings?: Array<{ fieldName: string; reason: string; detail?: string }>;
fieldGroupingProvider?: 'kiku' | 'senren' | null;
} = {},
) {
const warnings = options.warnings ?? [];
@@ -46,6 +47,7 @@ function createCollaborator(
getEffectiveSentenceCardConfig: () => ({
sentenceField: 'Sentence',
audioField: 'SentenceAudio',
fieldGroupingProvider: options.fieldGroupingProvider ?? 'kiku',
}),
getCurrentSubtitleText: () => options.currentSubtitleText,
resolveFieldName,
@@ -251,7 +253,166 @@ test('computeFieldGroupingMergedFields uses generated media only when includeGen
assert.equal(withMedia.MiscInfo, '<span data-group-id="11">generated misc</span>');
});
test('computeFieldGroupingMergedFields clears SentenceFurigana when either note lacks it', async () => {
test('computeFieldGroupingMergedFields merges Senren notes into scene-switching markup', async () => {
const { collaborator } = createCollaborator({ fieldGroupingProvider: 'senren' });
const merged = await collaborator.computeFieldGroupingMergedFields(
300,
200,
makeNote(300, {
word: '語',
sentence: '<span class="group">前<span class="highlight">語</span>後</span>',
sentenceAudio: '[sound:original.opus]',
picture: '<img src="original.webp">',
miscInfo: '<span class="group">Show EP1 (0:01:00)</span>',
}),
makeNote(200, {
word: '語',
sentence: '<span class="group">次<span class="highlight">語</span>文</span>',
sentenceAudio: '[sound:new.opus]',
picture: '<img src="new.webp">',
miscInfo: 'Show EP2 (0:02:00)',
}),
false,
);
assert.equal(
merged.sentence,
'<span class="group">前<span class="highlight">語</span>後</span>' +
'<span class="group">次<span class="highlight">語</span>文</span>',
);
assert.equal(merged.sentenceAudio, '[sound:original.opus][sound:new.opus]');
assert.equal(merged.picture, '<img src="original.webp"><img src="new.webp">');
assert.equal(
merged.miscInfo,
'<span class="group">Show EP1 (0:01:00)</span><span class="group">Show EP2 (0:02:00)</span>',
);
});
test('Senren merge wraps ungrouped legacy content and preserves numbered groups', async () => {
const { collaborator } = createCollaborator({ fieldGroupingProvider: 'senren' });
const merged = await collaborator.computeFieldGroupingMergedFields(
300,
200,
makeNote(300, {
sentence: 'plain legacy sentence',
sentenceAudio: '[sound:a.opus][sound:b.opus]',
miscInfo: '<span class="group2">pinned</span> stray text',
}),
makeNote(200, {
sentence: '<span class="group">new sentence</span>',
sentenceAudio: '[sound:c.opus]',
miscInfo: '<span class="group">new misc</span>',
}),
false,
);
assert.equal(
merged.sentence,
'<span class="group">plain legacy sentence</span><span class="group">new sentence</span>',
);
assert.equal(merged.sentenceAudio, '[sound:a.opus][sound:b.opus][sound:c.opus]');
assert.equal(
merged.miscInfo,
'<span class="group2">pinned</span><span class="group">stray text</span>' +
'<span class="group">new misc</span>',
);
});
test('Senren merge rebases numbered groups from an appended source note', async () => {
const { collaborator } = createCollaborator({ fieldGroupingProvider: 'senren' });
const merged = await collaborator.computeFieldGroupingMergedFields(
300,
200,
makeNote(300, {
sentenceAudio: '[sound:keep-a.opus][sound:keep-b.opus]',
miscInfo: '<span class="group">keep one</span><span class="group">keep two</span>',
}),
makeNote(200, {
sentenceAudio: '[sound:source-a.opus][sound:source-b.opus]',
miscInfo: '<span class="group2">source two</span>',
}),
false,
);
assert.equal(
merged.sentenceAudio,
'[sound:keep-a.opus][sound:keep-b.opus][sound:source-a.opus][sound:source-b.opus]',
);
assert.equal(
merged.miscInfo,
'<span class="group">keep one</span><span class="group">keep two</span>' +
'<span class="group4">source two</span>',
);
});
test('Senren merge keeps ungrouped text in place around an existing group span', async () => {
const { collaborator } = createCollaborator({ fieldGroupingProvider: 'senren' });
const merged = await collaborator.computeFieldGroupingMergedFields(
300,
200,
makeNote(300, {
miscInfo: 'leading<span class="group">middle</span>trailing',
sentenceAudio: '[sound:a.opus][sound:b.opus][sound:c.opus]',
}),
makeNote(200, {
miscInfo: '<span class="group">appended</span>',
sentenceAudio: '[sound:d.opus]',
}),
false,
);
// Order must follow the source field, and the two ungrouped runs must stay separate.
assert.equal(
merged.miscInfo,
'<span class="group">leading</span><span class="group">middle</span>' +
'<span class="group">trailing</span><span class="group">appended</span>',
);
assert.equal(merged.sentenceAudio, '[sound:a.opus][sound:b.opus][sound:c.opus][sound:d.opus]');
});
test('Senren merge closes unclosed group spans so later scenes stay siblings', async () => {
const { collaborator } = createCollaborator({ fieldGroupingProvider: 'senren' });
const merged = await collaborator.computeFieldGroupingMergedFields(
300,
200,
makeNote(300, { miscInfo: '<span class="group">a<span class="highlight">b' }),
makeNote(200, { miscInfo: '<span class="group">next</span>' }),
false,
);
assert.equal(
merged.miscInfo,
'<span class="group">a<span class="highlight">b</span></span><span class="group">next</span>',
);
const openTags = merged.miscInfo!.match(/<span\b/g)?.length ?? 0;
const closeTags = merged.miscInfo!.match(/<\/span>/g)?.length ?? 0;
assert.equal(openTags, closeTags);
});
test('Senren merge closes unclosed trailing markup before appending later scenes', async () => {
const { collaborator } = createCollaborator({ fieldGroupingProvider: 'senren' });
const merged = await collaborator.computeFieldGroupingMergedFields(
300,
200,
makeNote(300, { miscInfo: 'leading<span class="highlight">tail' }),
makeNote(200, { miscInfo: '<span class="group">next</span>' }),
false,
);
assert.equal(
merged.miscInfo,
'<span class="group">leading<span class="highlight">tail</span></span>' +
'<span class="group">next</span>',
);
});
test('Kiku merge clears SentenceFurigana when either note lacks it', async () => {
const { collaborator } = createCollaborator();
const merged = await collaborator.computeFieldGroupingMergedFields(
@@ -268,3 +429,21 @@ test('computeFieldGroupingMergedFields clears SentenceFurigana when either note
assert.equal(merged.SentenceFurigana, '');
});
test('Senren merge keeps duplicate SentenceFurigana when the kept field is empty', async () => {
const { collaborator } = createCollaborator({ fieldGroupingProvider: 'senren' });
const merged = await collaborator.computeFieldGroupingMergedFields(
300,
200,
makeNote(300, {
SentenceFurigana: '',
}),
makeNote(200, {
SentenceFurigana: 'duplicate furigana',
}),
false,
);
assert.equal(merged.SentenceFurigana, '<span class="group">duplicate furigana</span>');
});
+151 -1
View File
@@ -19,6 +19,7 @@ interface FieldGroupingMergeDeps {
getEffectiveSentenceCardConfig: () => {
sentenceField: string;
audioField: string;
fieldGroupingProvider: 'kiku' | 'senren' | null;
};
getCurrentSubtitleText: () => string | undefined;
resolveFieldName: (availableFieldNames: string[], preferredName: string) => string | null;
@@ -78,6 +79,13 @@ export class FieldGroupingMergeCollaborator {
const configuredWordField = getConfiguredWordFieldName(config);
const groupableFields = this.getGroupableFieldNames();
const keepFieldNames = Object.keys(keepNoteInfo.fields);
const sentenceCardConfig = this.deps.getEffectiveSentenceCardConfig();
const senrenSourceSceneOffset =
sentenceCardConfig.fieldGroupingProvider === 'senren'
? this.countSenrenAudioScenes(
this.getResolvedFieldValue(keepNoteInfo, sentenceCardConfig.audioField),
)
: 0;
const sourceFields: Record<string, string> = {};
const resolvedKeepFieldByPreferred = new Map<string, string>();
for (const preferredFieldName of groupableFields) {
@@ -154,14 +162,18 @@ export class FieldGroupingMergeCollaborator {
if (!existingValue.trim() && !newValue.trim()) continue;
if (keepFieldNormalized === 'sentencefurigana') {
const hasBothValues = existingValue.trim().length > 0 && newValue.trim().length > 0;
const usesSenrenGrouping =
this.deps.getEffectiveSentenceCardConfig().fieldGroupingProvider === 'senren';
mergedFields[keepFieldName] =
existingValue.trim() && newValue.trim()
hasBothValues || usesSenrenGrouping
? this.applyFieldGrouping(
existingValue,
newValue,
keepNoteId,
deleteNoteId,
keepFieldName,
senrenSourceSceneOffset,
)
: '';
continue;
@@ -174,6 +186,7 @@ export class FieldGroupingMergeCollaborator {
keepNoteId,
deleteNoteId,
keepFieldName,
senrenSourceSceneOffset,
);
} else if (existingValue.trim() && newValue.trim()) {
mergedFields[keepFieldName] = this.applyFieldGrouping(
@@ -182,6 +195,7 @@ export class FieldGroupingMergeCollaborator {
keepNoteId,
deleteNoteId,
keepFieldName,
senrenSourceSceneOffset,
);
} else {
if (!newValue.trim()) continue;
@@ -342,13 +356,149 @@ export class FieldGroupingMergeCollaborator {
return [...entries].sort((a, b) => b.groupId - a.groupId);
}
private isSentenceAudioField(fieldName: string): boolean {
const normalized = fieldName.toLowerCase();
const audioField = (
this.deps.getEffectiveSentenceCardConfig().audioField || 'sentenceaudio'
).toLowerCase();
return normalized === 'sentenceaudio' || normalized === audioField;
}
private isSenrenGroupOpenTag(openTag: string): boolean {
const classMatch =
openTag.match(/class\s*=\s*"([^"]*)"/i) || openTag.match(/class\s*=\s*'([^']*)'/i);
if (!classMatch) return false;
// Senren's templates match class tokens case-sensitively (/^group\d*$/).
return classMatch[1]!.split(/\s+/).some((token) => /^group\d*$/.test(token));
}
private countSenrenAudioScenes(value: string): number {
const soundEntries = value.match(/\[sound:[^\]]+\]/g)?.length ?? 0;
if (soundEntries > 0) return soundEntries;
return this.parseSenrenSceneEntries(value).length;
}
private rebaseSenrenNumberedGroup(entry: string, sceneOffset: number): string {
if (sceneOffset <= 0) return entry;
return entry.replace(
/^(\s*<span\b[^>]*?\bclass\s*=\s*)(["'])([^"']*)\2/i,
(_match: string, prefix: string, quote: string, rawClasses: string) => {
const classes = rawClasses
.split(/(\s+)/)
.map((classToken) => {
const groupMatch = classToken.match(/^group(\d+)$/);
if (!groupMatch) return classToken;
const targetScene = Number(groupMatch[1]);
if (!Number.isSafeInteger(targetScene) || targetScene <= 0) return classToken;
return `group${targetScene + sceneOffset}`;
})
.join('');
return `${prefix}${quote}${classes}${quote}`;
},
);
}
/**
* Splits a Senren field into ordered scene entries. Top-level
* `<span class="group">`/`"groupN"` spans are kept verbatim (nested markup like
* `<span class="highlight">` included); ungrouped runs are wrapped in a group
* span at their original position, because Senren discards anything outside a
* group span once scene switching activates.
*/
private parseSenrenSceneEntries(value: string): string[] {
const tokenRegex = /<span\b[^>]*>|<\/span>/gi;
const entries: string[] = [];
const pushUngrouped = (raw: string): void => {
const text = raw.replace(/<br\s*\/?>/gi, ' ').trim();
if (text) entries.push(`<span class="group">${text}</span>`);
};
let cursor = 0;
let depth = 0;
let entryStart = -1;
let match;
while ((match = tokenRegex.exec(value)) !== null) {
const token = match[0]!;
if (token[1] !== '/') {
if (depth === 0 && this.isSenrenGroupOpenTag(token)) {
pushUngrouped(value.slice(cursor, match.index));
entryStart = match.index;
cursor = match.index;
}
depth += 1;
} else {
depth = Math.max(0, depth - 1);
if (depth === 0 && entryStart !== -1) {
const end = match.index + token.length;
entries.push(value.slice(entryStart, end));
entryStart = -1;
cursor = end;
}
}
}
if (entryStart !== -1) {
// Unclosed group span: close every span still open (the group and any nested
// markup) so the following scenes are siblings rather than nested inside it.
entries.push(`${value.slice(entryStart)}${'</span>'.repeat(depth)}`);
} else {
pushUngrouped(`${value.slice(cursor)}${'</span>'.repeat(depth)}`);
}
return entries;
}
/**
* Merges two notes' field values in Senren's scene-switching format. Scenes are
* appended in order (existing first, never resorted) so indices stay aligned
* across sentence/picture/miscInfo with the sentenceAudio entries, which alone
* drive Senren's scene count.
*/
private applySenrenFieldGrouping(
existingValue: string,
newValue: string,
fieldName: string,
sourceSceneOffset: number,
): string {
if (this.isPictureField(fieldName)) {
const tags = [...this.extractImageTags(existingValue), ...this.extractImageTags(newValue)];
if (tags.length === 0) return existingValue || newValue;
return tags.join('');
}
if (this.isSentenceAudioField(fieldName)) {
const existing = existingValue.trim();
const added = newValue.trim();
if (!existing || !added) return existing || added;
if (!/\[sound:[^\]]+\]/.test(added)) {
this.deps.warnFieldParseOnce(fieldName, 'missing-sound-tag');
}
return existing + added;
}
const sourceEntries = this.parseSenrenSceneEntries(newValue).map((entry) =>
this.rebaseSenrenNumberedGroup(entry, sourceSceneOffset),
);
const merged = [...this.parseSenrenSceneEntries(existingValue), ...sourceEntries];
if (merged.length === 0) return existingValue || newValue;
return merged.join('');
}
private applyFieldGrouping(
existingValue: string,
newValue: string,
keepGroupId: number,
sourceGroupId: number,
fieldName: string,
senrenSourceSceneOffset: number,
): string {
if (this.deps.getEffectiveSentenceCardConfig().fieldGroupingProvider === 'senren') {
return this.applySenrenFieldGrouping(
existingValue,
newValue,
fieldName,
senrenSourceSceneOffset,
);
}
if (this.shouldUseStrictSpanGrouping(fieldName)) {
if (this.isPictureField(fieldName)) {
const keepEntries = this.parsePictureEntries(existingValue, keepGroupId);
@@ -71,7 +71,7 @@ function createWorkflowHarness() {
getEffectiveSentenceCardConfig: () => ({
sentenceField: 'Sentence',
audioField: 'SentenceAudio',
kikuDeleteDuplicateInAuto: true,
fieldGroupingDeleteDuplicateInAuto: true,
}),
getCurrentSubtitleText: () => 'subtitle-text',
getFieldGroupingCallback: (): FieldGroupingCallback | null => {
@@ -24,7 +24,7 @@ export interface FieldGroupingWorkflowDeps {
getEffectiveSentenceCardConfig: () => {
sentenceField: string;
audioField: string;
kikuDeleteDuplicateInAuto: boolean;
fieldGroupingDeleteDuplicateInAuto: boolean;
};
getCurrentSubtitleText: () => string | undefined;
getFieldGroupingCallback:
@@ -75,7 +75,7 @@ export class FieldGroupingWorkflow {
originalNoteId,
newNoteId,
this.getExpression(newNoteInfo),
sentenceCardConfig.kikuDeleteDuplicateInAuto,
sentenceCardConfig.fieldGroupingDeleteDuplicateInAuto,
);
} catch (error) {
this.deps.logError('Field grouping auto merge failed:', (error as Error).message);
+14 -18
View File
@@ -21,14 +21,14 @@ function createHarness(
manualHandled?: boolean;
expression?: string | null;
currentSentenceImageField?: string | undefined;
onProcessNewCard?: (noteId: number, options?: { skipKikuFieldGrouping?: boolean }) => void;
onProcessNewCard?: (noteId: number, options?: { skipFieldGrouping?: boolean }) => void;
} = {},
) {
const calls: string[] = [];
const findNotesQueries: Array<{ query: string; maxRetries?: number }> = [];
const noteInfoRequests: number[][] = [];
const duplicateRequests: Array<{ expression: string; excludeNoteId: number }> = [];
const processCalls: Array<{ noteId: number; options?: { skipKikuFieldGrouping?: boolean } }> = [];
const processCalls: Array<{ noteId: number; options?: { skipFieldGrouping?: boolean } }> = [];
const autoCalls: Array<{ originalNoteId: number; newNoteId: number; expression: string }> = [];
const manualCalls: Array<{ originalNoteId: number; newNoteId: number; expression: string }> = [];
@@ -46,9 +46,8 @@ function createHarness(
sentenceField: 'Sentence',
audioField: 'SentenceAudio',
lapisEnabled: false,
kikuEnabled: options.kikuEnabled ?? true,
kikuFieldGrouping: options.kikuFieldGrouping ?? 'auto',
kikuDeleteDuplicateInAuto: true,
fieldGroupingProvider: (options.kikuEnabled ?? true) ? ('kiku' as const) : null,
fieldGroupingMode: options.kikuFieldGrouping ?? 'auto',
}),
isUpdateInProgress: () => false,
getDeck: options.deck ? () => options.deck : undefined,
@@ -134,7 +133,7 @@ test('triggerFieldGroupingForLastAddedCard stops when kiku mode is disabled', as
await harness.service.triggerFieldGroupingForLastAddedCard();
assert.deepEqual(harness.calls, ['osd:Kiku mode is not enabled']);
assert.deepEqual(harness.calls, ['osd:Field grouping requires Kiku or Senren mode']);
assert.equal(harness.findNotesQueries.length, 0);
});
@@ -143,7 +142,7 @@ test('triggerFieldGroupingForLastAddedCard stops when field grouping is disabled
await harness.service.triggerFieldGroupingForLastAddedCard();
assert.deepEqual(harness.calls, ['osd:Kiku field grouping is disabled']);
assert.deepEqual(harness.calls, ['osd:Field grouping is disabled']);
assert.equal(harness.findNotesQueries.length, 0);
});
@@ -155,9 +154,8 @@ test('triggerFieldGroupingForLastAddedCard stops when an update is already in pr
sentenceField: 'Sentence',
audioField: 'SentenceAudio',
lapisEnabled: false,
kikuEnabled: true,
kikuFieldGrouping: 'auto',
kikuDeleteDuplicateInAuto: true,
fieldGroupingProvider: 'kiku' as const,
fieldGroupingMode: 'auto' as const,
}),
isUpdateInProgress: () => true,
withUpdateProgress: async () => {
@@ -266,7 +264,7 @@ test('triggerFieldGroupingForLastAddedCard prefers tracked duplicate note ids be
});
test('triggerFieldGroupingForLastAddedCard refreshes the card when configured fields are missing', async () => {
const processCalls: Array<{ noteId: number; options?: { skipKikuFieldGrouping?: boolean } }> = [];
const processCalls: Array<{ noteId: number; options?: { skipFieldGrouping?: boolean } }> = [];
const harness = createHarness({
noteIds: [11],
notesInfo: [
@@ -298,7 +296,7 @@ test('triggerFieldGroupingForLastAddedCard refreshes the card when configured fi
await harness.service.triggerFieldGroupingForLastAddedCard();
assert.deepEqual(processCalls, [{ noteId: 11, options: { skipKikuFieldGrouping: true } }]);
assert.deepEqual(processCalls, [{ noteId: 11, options: { skipFieldGrouping: true } }]);
assert.deepEqual(harness.manualCalls, []);
});
@@ -352,9 +350,8 @@ test('buildFieldGroupingPreview returns merged compact and full previews', async
sentenceField: 'Sentence',
audioField: 'SentenceAudio',
lapisEnabled: false,
kikuEnabled: true,
kikuFieldGrouping: 'auto',
kikuDeleteDuplicateInAuto: true,
fieldGroupingProvider: 'kiku' as const,
fieldGroupingMode: 'auto' as const,
}),
isUpdateInProgress: () => false,
withUpdateProgress: async (_message, action) => action(),
@@ -417,9 +414,8 @@ test('buildFieldGroupingPreview reports missing notes cleanly', async () => {
sentenceField: 'Sentence',
audioField: 'SentenceAudio',
lapisEnabled: false,
kikuEnabled: true,
kikuFieldGrouping: 'auto',
kikuDeleteDuplicateInAuto: true,
fieldGroupingProvider: 'kiku' as const,
fieldGroupingMode: 'auto' as const,
}),
isUpdateInProgress: () => false,
withUpdateProgress: async (_message, action) => action(),
+9 -10
View File
@@ -20,9 +20,8 @@ interface FieldGroupingDeps {
sentenceField: string;
audioField: string;
lapisEnabled: boolean;
kikuEnabled: boolean;
kikuFieldGrouping: 'auto' | 'manual' | 'disabled';
kikuDeleteDuplicateInAuto: boolean;
fieldGroupingProvider: 'kiku' | 'senren' | null;
fieldGroupingMode: 'auto' | 'manual' | 'disabled';
};
isUpdateInProgress: () => boolean;
getDeck?: () => string | undefined;
@@ -46,7 +45,7 @@ interface FieldGroupingDeps {
noteInfo: FieldGroupingNoteInfo,
configuredFieldNames: (string | undefined)[],
) => boolean;
processNewCard: (noteId: number, options?: { skipKikuFieldGrouping?: boolean }) => Promise<void>;
processNewCard: (noteId: number, options?: { skipFieldGrouping?: boolean }) => Promise<void>;
getSentenceCardImageFieldName: () => string | undefined;
resolveFieldName: (availableFieldNames: string[], preferredName: string) => string | null;
computeFieldGroupingMergedFields: (
@@ -76,12 +75,12 @@ export class FieldGroupingService {
async triggerFieldGroupingForLastAddedCard(): Promise<void> {
const sentenceCardConfig = this.deps.getEffectiveSentenceCardConfig();
if (!sentenceCardConfig.kikuEnabled) {
this.deps.showOsdNotification('Kiku mode is not enabled');
if (sentenceCardConfig.fieldGroupingProvider === null) {
this.deps.showOsdNotification('Field grouping requires Kiku or Senren mode');
return;
}
if (sentenceCardConfig.kikuFieldGrouping === 'disabled') {
this.deps.showOsdNotification('Kiku field grouping is disabled');
if (sentenceCardConfig.fieldGroupingMode === 'disabled') {
this.deps.showOsdNotification('Field grouping is disabled');
return;
}
@@ -134,7 +133,7 @@ export class FieldGroupingService {
])
) {
await this.deps.processNewCard(noteId, {
skipKikuFieldGrouping: true,
skipFieldGrouping: true,
});
}
@@ -147,7 +146,7 @@ export class FieldGroupingService {
const noteInfo = refreshedInfo[0]!;
if (sentenceCardConfig.kikuFieldGrouping === 'auto') {
if (sentenceCardConfig.fieldGroupingMode === 'auto') {
await this.deps.handleFieldGroupingAuto(
duplicateNoteId,
noteId,
@@ -58,7 +58,7 @@ function createWorkflowHarness() {
sentenceField: 'Sentence',
lapisEnabled: false,
kikuEnabled: false,
kikuFieldGrouping: 'disabled' as const,
fieldGroupingMode: 'disabled' as const,
}),
appendKnownWordsFromNoteInfo: (_noteInfo: NoteUpdateWorkflowNoteInfo) => undefined,
extractFields: (fields: Record<string, { value: string }>) => {
@@ -136,7 +136,7 @@ test('NoteUpdateWorkflow uses configured fields for word-card enrichment with La
sentenceField: 'Sentence',
lapisEnabled: true,
kikuEnabled: true,
kikuFieldGrouping: 'disabled',
fieldGroupingMode: 'disabled',
});
harness.deps.client.notesInfo = async () =>
[
@@ -193,7 +193,7 @@ test('NoteUpdateWorkflow marks enriched Kiku word cards as word-and-sentence car
sentenceField: 'Sentence',
lapisEnabled: false,
kikuEnabled: true,
kikuFieldGrouping: 'manual',
fieldGroupingMode: 'manual',
});
harness.deps.client.notesInfo = async () =>
[
@@ -226,7 +226,7 @@ test('NoteUpdateWorkflow marks the configured word card kind instead of word-and
sentenceField: 'Sentence',
lapisEnabled: false,
kikuEnabled: true,
kikuFieldGrouping: 'manual',
fieldGroupingMode: 'manual',
wordCardKind: 'click',
});
harness.deps.client.notesInfo = async () =>
@@ -262,7 +262,7 @@ test('NoteUpdateWorkflow leaves card type flags alone when the word card kind is
sentenceField: 'Sentence',
lapisEnabled: false,
kikuEnabled: true,
kikuFieldGrouping: 'manual',
fieldGroupingMode: 'manual',
wordCardKind: 'none',
});
harness.deps.client.notesInfo = async () =>
@@ -317,7 +317,7 @@ test('NoteUpdateWorkflow preserves explicit sentence card type during sentence e
sentenceField: 'Sentence',
lapisEnabled: true,
kikuEnabled: false,
kikuFieldGrouping: 'disabled',
fieldGroupingMode: 'disabled',
});
harness.deps.client.notesInfo = async () =>
[
@@ -360,7 +360,7 @@ test('NoteUpdateWorkflow updates note before auto field grouping merge', async (
sentenceField: 'Sentence',
lapisEnabled: false,
kikuEnabled: true,
kikuFieldGrouping: 'auto',
fieldGroupingMode: 'auto',
});
harness.deps.findDuplicateNote = async () => 99;
harness.deps.client.notesInfo = async () => {
+5 -7
View File
@@ -40,7 +40,7 @@ export interface NoteUpdateWorkflowDeps {
sentenceField: string;
lapisEnabled: boolean;
kikuEnabled: boolean;
kikuFieldGrouping: 'auto' | 'manual' | 'disabled';
fieldGroupingMode: 'auto' | 'manual' | 'disabled';
wordCardKind?: WordCardKind;
};
appendKnownWordsFromNoteInfo: (noteInfo: NoteUpdateWorkflowNoteInfo) => void;
@@ -160,7 +160,7 @@ export class NoteUpdateWorkflow {
return null;
}
async execute(noteId: number, options?: { skipKikuFieldGrouping?: boolean }): Promise<void> {
async execute(noteId: number, options?: { skipFieldGrouping?: boolean }): Promise<void> {
this.deps.beginUpdateProgress('Updating card');
try {
const notesInfoResult = await this.deps.client.notesInfo([noteId]);
@@ -187,9 +187,7 @@ export class NoteUpdateWorkflow {
const sentenceCardConfig = this.deps.getEffectiveSentenceCardConfig();
const shouldRunFieldGrouping =
!options?.skipKikuFieldGrouping &&
sentenceCardConfig.kikuEnabled &&
sentenceCardConfig.kikuFieldGrouping !== 'disabled';
!options?.skipFieldGrouping && sentenceCardConfig.fieldGroupingMode !== 'disabled';
let duplicateNoteId: number | null = null;
if (shouldRunFieldGrouping && hasExpressionText) {
duplicateNoteId = await this.deps.findDuplicateNote(expressionText, noteId, noteInfo);
@@ -350,7 +348,7 @@ export class NoteUpdateWorkflow {
noteInfoForGrouping = refreshedInfo[0]!;
}
if (sentenceCardConfig.kikuFieldGrouping === 'auto') {
if (sentenceCardConfig.fieldGroupingMode === 'auto') {
await this.deps.handleFieldGroupingAuto(
duplicateNoteId,
noteId,
@@ -359,7 +357,7 @@ export class NoteUpdateWorkflow {
);
return;
}
if (sentenceCardConfig.kikuFieldGrouping === 'manual') {
if (sentenceCardConfig.fieldGroupingMode === 'manual') {
await this.deps.handleFieldGroupingManual(
duplicateNoteId,
noteId,
+8
View File
@@ -116,6 +116,10 @@ export function normalizeAnkiIntegrationConfig(config: AnkiConnectConfig): AnkiC
...DEFAULT_ANKI_CONNECT_CONFIG.isKiku,
...(config.isKiku ?? {}),
},
isSenren: {
...DEFAULT_ANKI_CONNECT_CONFIG.isSenren,
...(config.isSenren ?? {}),
},
lapisKiku: {
...DEFAULT_ANKI_CONNECT_CONFIG.lapisKiku,
...(config.lapisKiku ?? {}),
@@ -209,6 +213,10 @@ export class AnkiIntegrationRuntime {
patch.isKiku !== undefined
? { ...this.config.isKiku, ...patch.isKiku }
: this.config.isKiku,
isSenren:
patch.isSenren !== undefined
? { ...this.config.isSenren, ...patch.isSenren }
: this.config.isSenren,
lapisKiku:
patch.lapisKiku !== undefined
? { ...this.config.lapisKiku, ...patch.lapisKiku }
+42
View File
@@ -2188,6 +2188,7 @@ test('runtime options registry is centralized', () => {
'subtitle.annotation.frequency',
'anki.nPlusOneMatchMode',
'anki.kikuFieldGrouping',
'anki.senrenFieldGrouping',
]);
});
@@ -2775,6 +2776,47 @@ test('accepts a Kiku/Lapis word card kind and warns on an unknown one', () => {
);
});
test('forces Senren off when Kiku is also enabled and validates Senren fieldGrouping', () => {
const dir = makeTempDir();
fs.writeFileSync(
path.join(dir, 'config.jsonc'),
`{
"ankiConnect": {
"isKiku": { "enabled": true },
"isSenren": { "enabled": true }
}
}`,
'utf-8',
);
const service = new ConfigService(dir);
assert.equal(service.getConfig().ankiConnect.isKiku.enabled, true);
assert.equal(service.getConfig().ankiConnect.isSenren.enabled, false);
assert.ok(
service.getWarnings().some((warning) => warning.path === 'ankiConnect.isSenren.enabled'),
);
const senrenOnlyDir = makeTempDir();
fs.writeFileSync(
path.join(senrenOnlyDir, 'config.jsonc'),
`{
"ankiConnect": {
"isSenren": { "enabled": true, "fieldGrouping": "sometimes" }
}
}`,
'utf-8',
);
const senrenOnlyService = new ConfigService(senrenOnlyDir);
assert.equal(senrenOnlyService.getConfig().ankiConnect.isSenren.enabled, true);
assert.equal(senrenOnlyService.getConfig().ankiConnect.isSenren.fieldGrouping, 'auto');
assert.ok(
senrenOnlyService
.getWarnings()
.some((warning) => warning.path === 'ankiConnect.isSenren.fieldGrouping'),
);
});
test('accepts valid ankiConnect knownWords deck object', () => {
const dir = makeTempDir();
fs.writeFileSync(
@@ -91,6 +91,11 @@ export const INTEGRATIONS_DEFAULT_CONFIG: Pick<
fieldGrouping: 'disabled',
deleteDuplicateInAuto: true,
},
isSenren: {
enabled: false,
fieldGrouping: 'auto',
deleteDuplicateInAuto: true,
},
lapisKiku: {
wordCardKind: 'word-and-sentence',
},
@@ -363,6 +363,28 @@ export function buildIntegrationConfigOptionRegistry(
description:
'When Kiku field grouping is "auto", delete the duplicate source card after grouping completes.',
},
{
path: 'ankiConnect.isSenren.fieldGrouping',
kind: 'enum',
enumValues: ['auto', 'manual', 'disabled'],
defaultValue: defaultConfig.ankiConnect.isSenren.fieldGrouping,
description: 'Senren duplicate-card field grouping mode (scene switching).',
runtime: runtimeOptionById.get('anki.senrenFieldGrouping'),
},
{
path: 'ankiConnect.isSenren.enabled',
kind: 'boolean',
defaultValue: defaultConfig.ankiConnect.isSenren.enabled,
description:
'Enable Senren-specific duplicate handling (scene-switching field grouping, including miscInfo grouping). Mutually exclusive with isKiku.enabled.',
},
{
path: 'ankiConnect.isSenren.deleteDuplicateInAuto',
kind: 'boolean',
defaultValue: defaultConfig.ankiConnect.isSenren.deleteDuplicateInAuto,
description:
'When Senren field grouping is "auto", delete the duplicate source card after grouping completes.',
},
{
path: 'ankiConnect.isLapis.enabled',
kind: 'boolean',
+17
View File
@@ -124,5 +124,22 @@ export function buildRuntimeOptionRegistry(
},
}),
},
{
id: 'anki.senrenFieldGrouping',
path: 'ankiConnect.isSenren.fieldGrouping',
label: 'Senren Field Grouping',
scope: 'ankiConnect',
valueType: 'enum',
allowedValues: ['auto', 'manual', 'disabled'],
defaultValue: 'auto',
requiresRestart: false,
formatValueForOsd: (value) => String(value),
toAnkiPatch: (value) => ({
isSenren: {
fieldGrouping:
value === 'auto' || value === 'manual' || value === 'disabled' ? value : 'auto',
},
}),
},
];
}
+1 -1
View File
@@ -135,7 +135,7 @@ const INTEGRATION_TEMPLATE_SECTIONS: ConfigTemplateSection[] = [
title: 'AnkiConnect Integration',
description: ['Automatic Anki updates and media generation options.'],
notes: [
'Hot-reload: ankiConnect.ai.enabled, media.normalizeAudio/mirrorMpvVolume, knownWords, nPlusOne, fields.word/audio/image/sentence/miscInfo, behavior.autoUpdateNewCards, isLapis.sentenceCardModel, isKiku.fieldGrouping, and lapisKiku.wordCardKind update live while SubMiner is running.',
'Hot-reload: ankiConnect.ai.enabled, media.normalizeAudio/mirrorMpvVolume, knownWords, nPlusOne, fields.word/audio/image/sentence/miscInfo, behavior.autoUpdateNewCards, isLapis.sentenceCardModel, isKiku.fieldGrouping, isSenren.fieldGrouping, and lapisKiku.wordCardKind update live while SubMiner is running.',
'Shared AI provider transport settings are read from top-level ai and typically require restart.',
'Most other AnkiConnect settings still require restart.',
],
+2
View File
@@ -1,6 +1,7 @@
import type { ResolveContext } from './context';
import { initializeAnkiConnectResolution } from './anki-connect/initialize';
import { applyAnkiKikuResolution } from './anki-connect/kiku';
import { applyAnkiSenrenResolution } from './anki-connect/senren';
import { applyAnkiLapisKikuResolution } from './anki-connect/lapis-kiku';
import { applyAnkiKnownWordsResolution } from './anki-connect/known-words';
import { applyAnkiLegacyResolution } from './anki-connect/legacy';
@@ -23,5 +24,6 @@ export function applyAnkiConnectResolution(context: ResolveContext): void {
applyAnkiLegacyResolution(context, ankiConnect, behavior, fields, media, metadata);
applyAnkiKnownWordsResolution(context, ankiConnect, behavior);
applyAnkiKikuResolution(context);
applyAnkiSenrenResolution(context);
applyAnkiLapisKikuResolution(context, ankiConnect);
}
@@ -77,6 +77,12 @@ export function initializeAnkiConnectResolution(
? (ankiConnect.isKiku as (typeof context.resolved)['ankiConnect']['isKiku'])
: {}),
},
isSenren: {
...context.resolved.ankiConnect.isSenren,
...(isObject(ankiConnect.isSenren)
? (ankiConnect.isSenren as (typeof context.resolved)['ankiConnect']['isSenren'])
: {}),
},
lapisKiku: {
...context.resolved.ankiConnect.lapisKiku,
},
+34
View File
@@ -0,0 +1,34 @@
import { DEFAULT_CONFIG } from '../../definitions';
import type { ResolveContext } from '../context';
export function applyAnkiSenrenResolution(context: ResolveContext): void {
if (
context.resolved.ankiConnect.isSenren.fieldGrouping !== 'auto' &&
context.resolved.ankiConnect.isSenren.fieldGrouping !== 'manual' &&
context.resolved.ankiConnect.isSenren.fieldGrouping !== 'disabled'
) {
context.warn(
'ankiConnect.isSenren.fieldGrouping',
context.resolved.ankiConnect.isSenren.fieldGrouping,
DEFAULT_CONFIG.ankiConnect.isSenren.fieldGrouping,
'Expected auto, manual, or disabled.',
);
context.resolved.ankiConnect.isSenren.fieldGrouping =
DEFAULT_CONFIG.ankiConnect.isSenren.fieldGrouping;
}
// Kiku and Senren field grouping write incompatible markup into the same note
// fields, so only one may be active; Kiku wins to preserve pre-existing setups.
if (
context.resolved.ankiConnect.isSenren.enabled === true &&
context.resolved.ankiConnect.isKiku.enabled === true
) {
context.warn(
'ankiConnect.isSenren.enabled',
true,
false,
'Kiku and Senren are mutually exclusive; disable isKiku.enabled to use Senren field grouping.',
);
context.resolved.ankiConnect.isSenren.enabled = false;
}
}
+6 -3
View File
@@ -298,10 +298,12 @@ test('settings registry puts feature toggles first, then other toggles alphabeti
];
assert.equal(miningSections[0], 'AnkiConnect');
const kikuLapis = fields.filter((candidate) => candidate.section === 'Kiku/Lapis Features');
const kikuLapis = fields.filter(
(candidate) => candidate.section === 'Kiku/Lapis/Senren Features',
);
assert.deepEqual(
kikuLapis.slice(0, 2).map((candidate) => candidate.configPath),
['ankiConnect.isLapis.enabled', 'ankiConnect.isKiku.enabled'],
kikuLapis.slice(0, 3).map((candidate) => candidate.configPath),
['ankiConnect.isLapis.enabled', 'ankiConnect.isKiku.enabled', 'ankiConnect.isSenren.enabled'],
);
});
@@ -366,6 +368,7 @@ test('settings registry marks safe live config paths as hot-reloadable', () => {
'ankiConnect.fields.miscInfo',
'ankiConnect.isLapis.sentenceCardModel',
'ankiConnect.isKiku.fieldGrouping',
'ankiConnect.isSenren.fieldGrouping',
]) {
assert.equal(field(path).restartBehavior, 'hot-reload', path);
}
+9 -3
View File
@@ -131,7 +131,7 @@ const SECTION_ORDER = new Map<string, number>(
'AnkiConnect',
'Note Fields',
'Media Capture',
'Kiku/Lapis Features',
'Kiku/Lapis/Senren Features',
'Anki AI',
'AnkiConnect Proxy',
'Jimaku',
@@ -163,6 +163,7 @@ const PATH_ORDER = new Map<string, number>(
'ankiConnect.proxy.enabled',
'ankiConnect.isLapis.enabled',
'ankiConnect.isKiku.enabled',
'ankiConnect.isSenren.enabled',
'subtitleStyle.knownWordColor',
'ankiConnect.knownWords.matureThresholdDays',
'subtitleStyle.knownWordMaturityColors.new',
@@ -221,6 +222,7 @@ const LABEL_OVERRIDES: Record<string, string> = {
'ankiConnect.nPlusOne.enabled': 'Enabled',
'ankiConnect.isLapis.enabled': 'Enable Lapis Features',
'ankiConnect.isKiku.enabled': 'Enable Kiku Features',
'ankiConnect.isSenren.enabled': 'Enable Senren Features',
'ankiConnect.lapisKiku.wordCardKind': 'Word Card Type',
'stats.toggleKey': 'Toggle Stats Overlay',
'shortcuts.openCharacterDictionaryManager': 'Open Character Dictionary Manager',
@@ -251,7 +253,9 @@ const DESCRIPTION_OVERRIDES: Record<string, string> = {
'ankiConnect.pollingRate':
'Polling interval in milliseconds. Ignored while the local AnkiConnect proxy is enabled because push-based enrichment is used instead.',
'ankiConnect.isKiku.enabled':
'Enable Kiku-specific mining behavior. Kiku supersedes Lapis: Lapis features still work, and Kiku adds duplicate handling and field grouping.',
'Enable Kiku-specific mining behavior. Kiku supersedes Lapis: Lapis features still work, and Kiku adds duplicate handling and field grouping. Mutually exclusive with Senren.',
'ankiConnect.isSenren.enabled':
'Enable Senren-specific duplicate handling: field grouping merges duplicates into Senren scene-switching markup (including miscInfo grouping). Mutually exclusive with Kiku; only one can be enabled at a time.',
'ankiConnect.isLapis.enabled':
'Enable Lapis-specific mining behavior and sentence-card model targeting. When Kiku is enabled, Lapis features still work and Kiku-specific features are added on top.',
'ankiConnect.isLapis.sentenceCardModel':
@@ -407,9 +411,10 @@ function categoryAndSection(path: string): { category: ConfigSettingsCategory; s
if (
path.startsWith('ankiConnect.isKiku.') ||
path.startsWith('ankiConnect.isLapis.') ||
path.startsWith('ankiConnect.isSenren.') ||
path.startsWith('ankiConnect.lapisKiku.')
) {
return { category: 'mining-anki', section: 'Kiku/Lapis Features' };
return { category: 'mining-anki', section: 'Kiku/Lapis/Senren Features' };
}
if (path.startsWith('ankiConnect.ai.')) {
return { category: 'mining-anki', section: 'Anki AI' };
@@ -709,6 +714,7 @@ function restartBehaviorForPath(path: string): ConfigSettingsRestartBehavior {
path === 'ankiConnect.fields.miscInfo' ||
path === 'ankiConnect.isLapis.sentenceCardModel' ||
path === 'ankiConnect.isKiku.fieldGrouping' ||
path === 'ankiConnect.isSenren.fieldGrouping' ||
path === 'ankiConnect.lapisKiku.wordCardKind' ||
path === 'mpv.aniskipEnabled' ||
path === 'mpv.aniskipButtonKey' ||
+1
View File
@@ -85,6 +85,7 @@ const HOT_RELOAD_EXACT_OR_PREFIX_PATHS = [
'ankiConnect.fields.miscInfo',
'ankiConnect.isLapis.sentenceCardModel',
'ankiConnect.isKiku.fieldGrouping',
'ankiConnect.isSenren.fieldGrouping',
'ankiConnect.lapisKiku.wordCardKind',
] as const;
-6
View File
@@ -131,12 +131,6 @@ export {
resolvePlaybackPlan as resolveJellyfinPlaybackPlanRuntime,
ticksToSeconds as jellyfinTicksToSecondsRuntime,
} from './jellyfin';
export { loadJellyfinSubtitleDelay, saveJellyfinSubtitleDelay } from './jellyfin-subtitle-delay';
export {
estimateSubtitleTimingOffset,
type SubtitleTimingOffsetOptions,
type SubtitleTimingOffsetResult,
} from './subtitle-timing-offset';
export { buildJellyfinTimelinePayload, JellyfinRemoteSessionService } from './jellyfin-remote';
export {
broadcastRuntimeOptionsChangedRuntime,
@@ -1,54 +0,0 @@
import assert from 'node:assert/strict';
import * as fs from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
import test from 'node:test';
import { loadJellyfinSubtitleDelay, saveJellyfinSubtitleDelay } from './jellyfin-subtitle-delay';
function statePath(name: string): string {
return path.join(fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-jellyfin-delay-')), name);
}
test('jellyfin subtitle delay store saves and loads delay by item and stream', () => {
const filePath = statePath('delays.json');
assert.equal(
saveJellyfinSubtitleDelay({
filePath,
itemId: 'episode-1',
streamIndex: 3,
delaySeconds: 1.25,
}),
true,
);
assert.equal(loadJellyfinSubtitleDelay({ filePath, itemId: 'episode-1', streamIndex: 3 }), 1.25);
assert.equal(loadJellyfinSubtitleDelay({ filePath, itemId: 'episode-1', streamIndex: 4 }), null);
});
test('jellyfin subtitle delay store preserves other stream delays when updating one stream', () => {
const filePath = statePath('delays.json');
saveJellyfinSubtitleDelay({ filePath, itemId: 'episode-1', streamIndex: 3, delaySeconds: 1.25 });
saveJellyfinSubtitleDelay({ filePath, itemId: 'episode-1', streamIndex: 4, delaySeconds: -0.5 });
saveJellyfinSubtitleDelay({ filePath, itemId: 'episode-1', streamIndex: 3, delaySeconds: 2 });
assert.equal(loadJellyfinSubtitleDelay({ filePath, itemId: 'episode-1', streamIndex: 3 }), 2);
assert.equal(loadJellyfinSubtitleDelay({ filePath, itemId: 'episode-1', streamIndex: 4 }), -0.5);
});
test('jellyfin subtitle delay store ignores invalid files and values', () => {
const filePath = statePath('delays.json');
fs.writeFileSync(filePath, '{');
assert.equal(loadJellyfinSubtitleDelay({ filePath, itemId: 'episode-1', streamIndex: 3 }), null);
assert.equal(
saveJellyfinSubtitleDelay({
filePath,
itemId: 'episode-1',
streamIndex: 3,
delaySeconds: Number.NaN,
}),
false,
);
});
@@ -1,66 +0,0 @@
import * as fs from 'fs';
import * as path from 'path';
type JellyfinSubtitleDelayStore = {
version?: unknown;
delays?: unknown;
};
type JellyfinSubtitleDelayParams = {
filePath: string;
itemId: string;
streamIndex: number;
};
type SaveJellyfinSubtitleDelayParams = JellyfinSubtitleDelayParams & {
delaySeconds: number;
};
function storeKey(itemId: string, streamIndex: number): string {
return JSON.stringify([itemId, streamIndex]);
}
function readDelayMap(filePath: string): Record<string, number> {
try {
if (!fs.existsSync(filePath)) return {};
const parsed = JSON.parse(fs.readFileSync(filePath, 'utf-8')) as JellyfinSubtitleDelayStore;
if (
!parsed ||
typeof parsed !== 'object' ||
!parsed.delays ||
typeof parsed.delays !== 'object'
) {
return {};
}
const delays: Record<string, number> = {};
for (const [key, value] of Object.entries(parsed.delays as Record<string, unknown>)) {
if (typeof value === 'number' && Number.isFinite(value)) {
delays[key] = value;
}
}
return delays;
} catch {
return {};
}
}
export function loadJellyfinSubtitleDelay(params: JellyfinSubtitleDelayParams): number | null {
const delay = readDelayMap(params.filePath)[storeKey(params.itemId, params.streamIndex)];
return typeof delay === 'number' && Number.isFinite(delay) ? delay : null;
}
export function saveJellyfinSubtitleDelay(params: SaveJellyfinSubtitleDelayParams): boolean {
if (!Number.isFinite(params.delaySeconds)) return false;
try {
const delays = readDelayMap(params.filePath);
delays[storeKey(params.itemId, params.streamIndex)] = params.delaySeconds;
const dir = path.dirname(params.filePath);
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
fs.writeFileSync(params.filePath, JSON.stringify({ version: 1, delays }, null, 2));
return true;
} catch {
return false;
}
}
@@ -1,73 +0,0 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { estimateSubtitleTimingOffset } from './subtitle-timing-offset';
function cue(startTime: number) {
return { startTime, endTime: startTime + 1, text: `cue ${startTime}` };
}
test('estimate subtitle timing offset detects a late Jellyfin subtitle timeline', () => {
const primary = [
34.935, 36.937, 41.441, 45.279, 48.115, 52.286, 54.955, 59.793, 63.63, 67.634, 76.643, 80.814,
87.988, 90.991, 94.094, 97.097,
].map(cue);
const reference = [
3.46, 9.48, 13.61, 21.4, 28.16, 32.06, 35.93, 45.1, 56.57, 59.68, 62.44, 65.56,
].map(cue);
const result = estimateSubtitleTimingOffset(primary, reference);
assert.ok(result);
assert.ok(result.offsetSeconds > -32);
assert.ok(result.offsetSeconds < -31);
assert.ok(result.matchCount >= 8);
assert.ok(result.meanErrorSeconds <= 0.75);
});
test('estimate subtitle timing offset favors the early episode timeline', () => {
const primary = [
34.935, 36.937, 41.441, 45.279, 48.115, 52.286, 54.955, 59.793, 63.63, 67.634, 76.643, 80.814,
87.988, 90.991, 94.094, 97.097, 207.974, 212.579, 222.422, 228.095, 232.432, 238.271, 244.778,
246.78, 249.282, 251.284, 253.62, 256.289, 259.626, 262.129, 264.965, 267.634, 270.303, 274.407,
277.077, 280.08, 284.084, 288.421, 291.925, 295.262, 298.431, 301.101, 306.773, 308.942,
312.946, 316.283, 321.621, 326.626, 331.131, 336.069, 340.407, 343.41, 351.418, 355.422,
357.924, 362.429, 365.432, 370.604, 373.273, 377.944, 381.114, 384.618, 387.621, 390.957,
396.73, 399.232, 401.568, 403.57, 405.572, 407.574, 409.743, 412.746, 418.752, 425.258, 427.26,
435.602, 440.44, 442.942, 445.445, 449.783,
].map(cue);
const reference = [
3.46, 9.48, 13.61, 21.4, 28.16, 32.06, 35.93, 45.1, 56.57, 59.68, 62.44, 65.56, 165.77, 172.81,
176.1, 177.27, 186.33, 191.33, 195.78, 201.83, 212.9, 214.09, 216.73, 220.2, 222.91, 225.65,
232.8, 237.92, 242.23, 243.28, 247.53, 252.04, 255.9, 258.86, 262.09, 264.43, 276.07, 278.01,
280.98, 285.67, 289.89, 294.57, 300, 303.56, 308.58, 316.37, 318.38, 319.86, 325.38, 328.82,
333.68, 335.26, 336.82, 340.11, 342.11, 344.36, 346.39, 347.53, 350.92, 370.18, 372.88, 376.43,
388.2, 390.57, 403.96, 406.36, 409.72, 413.78, 425.55, 432.76, 435.03, 438.06, 443.73, 448.31,
450.57, 457.62, 463.41, 465.85, 473.79, 480.59,
].map(cue);
const result = estimateSubtitleTimingOffset(primary, reference);
assert.ok(result);
assert.ok(result.offsetSeconds > -32);
assert.ok(result.offsetSeconds < -31);
});
test('estimate subtitle timing offset ignores subtitle timelines that are already aligned', () => {
const starts = [1, 5, 9, 14, 20, 25, 31, 38];
const result = estimateSubtitleTimingOffset(
starts.map(cue),
starts.map((start) => cue(start + 0.04)),
);
assert.equal(result, null);
});
test('estimate subtitle timing offset rejects weak timeline matches', () => {
const primary = [10, 20, 30, 40, 50, 60, 70, 80].map(cue);
const reference = [1, 2, 3, 4, 5, 6, 7, 8].map(cue);
const result = estimateSubtitleTimingOffset(primary, reference);
assert.equal(result, null);
});
-153
View File
@@ -1,153 +0,0 @@
import type { SubtitleCue } from './subtitle-cue-parser';
export type SubtitleTimingOffsetResult = {
offsetSeconds: number;
matchCount: number;
meanErrorSeconds: number;
maxErrorSeconds: number;
};
export type SubtitleTimingOffsetOptions = {
maxCueCount?: number;
maxOffsetSeconds?: number;
matchThresholdSeconds?: number;
maxMeanErrorSeconds?: number;
minMatchCount?: number;
minMatchRatio?: number;
minUsefulOffsetSeconds?: number;
};
type OffsetScore = SubtitleTimingOffsetResult;
const DEFAULT_MAX_CUE_COUNT = 60;
const DEFAULT_MAX_OFFSET_SECONDS = 180;
const DEFAULT_MATCH_THRESHOLD_SECONDS = 1;
const DEFAULT_MAX_MEAN_ERROR_SECONDS = 0.75;
const DEFAULT_MIN_MATCH_COUNT = 8;
const DEFAULT_MIN_MATCH_RATIO = 0.25;
const DEFAULT_MIN_USEFUL_OFFSET_SECONDS = 0.25;
function normalizeCueStarts(cues: SubtitleCue[], maxCueCount: number): number[] {
const starts = cues
.map((cue) => cue.startTime)
.filter((start) => Number.isFinite(start) && start >= 0)
.sort((a, b) => a - b);
const deduped: number[] = [];
for (const start of starts) {
const previous = deduped[deduped.length - 1];
if (previous === undefined || Math.abs(start - previous) > 0.05) {
deduped.push(start);
}
if (deduped.length >= maxCueCount) {
break;
}
}
return deduped;
}
function roundToMillis(value: number): number {
return Math.round(value * 1000) / 1000;
}
function scoreOffset(
primaryStarts: number[],
referenceStarts: number[],
offsetSeconds: number,
matchThresholdSeconds: number,
): OffsetScore {
let primaryIndex = 0;
let referenceIndex = 0;
let matchCount = 0;
let totalErrorSeconds = 0;
let maxErrorSeconds = 0;
while (primaryIndex < primaryStarts.length && referenceIndex < referenceStarts.length) {
const shiftedPrimary = primaryStarts[primaryIndex]! + offsetSeconds;
const reference = referenceStarts[referenceIndex]!;
const errorSeconds = Math.abs(shiftedPrimary - reference);
if (errorSeconds <= matchThresholdSeconds) {
matchCount += 1;
totalErrorSeconds += errorSeconds;
maxErrorSeconds = Math.max(maxErrorSeconds, errorSeconds);
primaryIndex += 1;
referenceIndex += 1;
continue;
}
if (shiftedPrimary < reference) {
primaryIndex += 1;
} else {
referenceIndex += 1;
}
}
return {
offsetSeconds,
matchCount,
meanErrorSeconds: matchCount > 0 ? totalErrorSeconds / matchCount : Number.POSITIVE_INFINITY,
maxErrorSeconds,
};
}
function isBetterScore(next: OffsetScore, current: OffsetScore | null): boolean {
if (current === null) return true;
if (next.matchCount !== current.matchCount) return next.matchCount > current.matchCount;
if (next.meanErrorSeconds !== current.meanErrorSeconds) {
return next.meanErrorSeconds < current.meanErrorSeconds;
}
return Math.abs(next.offsetSeconds) < Math.abs(current.offsetSeconds);
}
export function estimateSubtitleTimingOffset(
primaryCues: SubtitleCue[],
referenceCues: SubtitleCue[],
options: SubtitleTimingOffsetOptions = {},
): SubtitleTimingOffsetResult | null {
const maxCueCount = options.maxCueCount ?? DEFAULT_MAX_CUE_COUNT;
const maxOffsetSeconds = options.maxOffsetSeconds ?? DEFAULT_MAX_OFFSET_SECONDS;
const matchThresholdSeconds = options.matchThresholdSeconds ?? DEFAULT_MATCH_THRESHOLD_SECONDS;
const maxMeanErrorSeconds = options.maxMeanErrorSeconds ?? DEFAULT_MAX_MEAN_ERROR_SECONDS;
const minMatchCount = options.minMatchCount ?? DEFAULT_MIN_MATCH_COUNT;
const minMatchRatio = options.minMatchRatio ?? DEFAULT_MIN_MATCH_RATIO;
const minUsefulOffsetSeconds =
options.minUsefulOffsetSeconds ?? DEFAULT_MIN_USEFUL_OFFSET_SECONDS;
const primaryStarts = normalizeCueStarts(primaryCues, maxCueCount);
const referenceStarts = normalizeCueStarts(referenceCues, maxCueCount);
const comparableCueCount = Math.min(primaryStarts.length, referenceStarts.length);
if (comparableCueCount < minMatchCount) {
return null;
}
const candidates = new Set<number>();
for (const primaryStart of primaryStarts) {
for (const referenceStart of referenceStarts) {
const offsetSeconds = roundToMillis(referenceStart - primaryStart);
if (Math.abs(offsetSeconds) <= maxOffsetSeconds) {
candidates.add(offsetSeconds);
}
}
}
let best: OffsetScore | null = null;
for (const offsetSeconds of candidates) {
if (Math.abs(offsetSeconds) < minUsefulOffsetSeconds) {
continue;
}
const score = scoreOffset(primaryStarts, referenceStarts, offsetSeconds, matchThresholdSeconds);
if (score.matchCount < minMatchCount) {
continue;
}
if (score.matchCount / comparableCueCount < minMatchRatio) {
continue;
}
if (score.meanErrorSeconds > maxMeanErrorSeconds) {
continue;
}
if (isBetterScore(score, best)) {
best = score;
}
}
return best;
}
-23
View File
@@ -302,7 +302,6 @@ import {
listJellyfinItemsRuntime,
listJellyfinLibrariesRuntime,
listJellyfinSubtitleTracksRuntime,
loadJellyfinSubtitleDelay,
loadSubtitlePosition as loadSubtitlePositionCore,
loadYomitanExtension as loadYomitanExtensionCore,
markLastCardAsAudioCard as markLastCardAsAudioCardCore,
@@ -315,7 +314,6 @@ import {
resolveSanitizedSubtitleSeekCommand,
resolveJellyfinPlaybackPlanRuntime,
runStartupBootstrapRuntime,
saveJellyfinSubtitleDelay,
saveSubtitlePosition as saveSubtitlePositionCore,
clearYomitanParserCachesForWindow,
getYomitanCurrentAnkiDeckName as getYomitanCurrentAnkiDeckNameCore,
@@ -677,7 +675,6 @@ function spawnManagedMpvProcess(args: string[]): ReturnType<typeof spawn> {
}
let activeJellyfinRemotePlayback: ActiveJellyfinRemotePlaybackState | null = null;
let activeJellyfinSubtitleDelayKey: { itemId: string; streamIndex: number } | null = null;
let jellyfinRemoteLastProgressAtMs = 0;
let jellyfinMpvAutoLaunchInFlight: Promise<boolean> | null = null;
let backgroundWarmupsStarted = false;
@@ -2482,7 +2479,6 @@ const fieldGroupingOverlayRuntime = createFieldGroupingOverlayRuntime<OverlayHos
const createFieldGroupingCallback = fieldGroupingOverlayRuntime.createFieldGroupingCallback;
const SUBTITLE_POSITIONS_DIR = path.join(CONFIG_DIR, 'subtitle-positions');
const JELLYFIN_SUBTITLE_DELAYS_PATH = path.join(CONFIG_DIR, 'jellyfin-subtitle-delays.json');
const mediaRuntime = createMediaRuntimeService(
createBuildMediaRuntimeMainDepsHandler({
@@ -3106,23 +3102,6 @@ const {
wait: (ms) => new Promise<void>((resolve) => setTimeout(resolve, ms)),
cacheSubtitleTrack: (track) => jellyfinSubtitleCacheIo.cacheSubtitleTrack(track),
cleanupCachedSubtitles: (dirs) => jellyfinSubtitleCacheIo.cleanupCachedSubtitles(dirs),
getSavedSubtitleDelay: (itemId, streamIndex) =>
loadJellyfinSubtitleDelay({
filePath: JELLYFIN_SUBTITLE_DELAYS_PATH,
itemId,
streamIndex,
}),
setActiveSubtitleDelayKey: (key) => {
activeJellyfinSubtitleDelayKey = key;
},
loadSubtitleSourceText,
saveSubtitleDelay: (itemId, streamIndex, delaySeconds) =>
saveJellyfinSubtitleDelay({
filePath: JELLYFIN_SUBTITLE_DELAYS_PATH,
itemId,
streamIndex,
delaySeconds,
}),
initSubtitlePrefetch: (sourcePath) =>
subtitlePrefetchRuntime.refreshSubtitleSidebarFromSource(sourcePath),
logDebug: (message, error) => {
@@ -3188,7 +3167,6 @@ const {
getActivePlayback: () => activeJellyfinRemotePlayback,
clearActivePlayback: () => {
activeJellyfinRemotePlayback = null;
activeJellyfinSubtitleDelayKey = null;
},
getSession: () => appState.jellyfinRemoteSession,
getNow: () => Date.now(),
@@ -4545,7 +4523,6 @@ const {
appState.activeParsedSubtitleSource = null;
appState.activeParsedSubtitleMediaPath = null;
}
activeJellyfinSubtitleDelayKey = null;
overlayManager.broadcastToOverlayWindows('subtitle:set', resetSubtitlePayload);
subtitleWsService.broadcast(resetSubtitlePayload, frequencyOptions);
annotationSubtitleWsService.broadcast(resetSubtitlePayload, frequencyOptions);
@@ -134,6 +134,9 @@ function buildAnkiRuntimeConfigPatch(
if (diff.hotReloadFields.includes('ankiConnect.isKiku.fieldGrouping')) {
patch.isKiku = { fieldGrouping: config.ankiConnect.isKiku.fieldGrouping };
}
if (diff.hotReloadFields.includes('ankiConnect.isSenren.fieldGrouping')) {
patch.isSenren = { fieldGrouping: config.ankiConnect.isSenren.fieldGrouping };
}
if (diff.hotReloadFields.includes('ankiConnect.lapisKiku.wordCardKind')) {
patch.lapisKiku = { wordCardKind: config.ankiConnect.lapisKiku.wordCardKind };
}
@@ -19,19 +19,6 @@ test('preload jellyfin external subtitles main deps builder maps callbacks', asy
return { path: '/tmp/sub.srt', cleanupDir: '/tmp/subs' };
},
cleanupCachedSubtitles: () => calls.push('cleanup'),
getSavedSubtitleDelay: (_itemId, streamIndex) => {
calls.push(`load-delay:${streamIndex}`);
return 1.25;
},
setActiveSubtitleDelayKey: (key) => calls.push(`active-delay:${key?.streamIndex ?? 'none'}`),
loadSubtitleSourceText: async (source) => {
calls.push(`load-source:${source}`);
return 'subtitle';
},
saveSubtitleDelay: (_itemId, streamIndex, delaySeconds) => {
calls.push(`save-delay:${streamIndex}:${delaySeconds}`);
return true;
},
logDebug: (message) => calls.push(`debug:${message}`),
})();
@@ -41,21 +28,6 @@ test('preload jellyfin external subtitles main deps builder maps callbacks', asy
await deps.wait(1);
await deps.cacheSubtitleTrack({ index: 1, deliveryUrl: 'https://example.test/sub.srt' });
deps.cleanupCachedSubtitles(['/tmp/subs']);
assert.equal(deps.getSavedSubtitleDelay?.('item', 3), 1.25);
deps.setActiveSubtitleDelayKey?.({ itemId: 'item', streamIndex: 3 });
assert.equal(await deps.loadSubtitleSourceText?.('/tmp/sub.srt'), 'subtitle');
assert.equal(deps.saveSubtitleDelay?.('item', 3, -31.5), true);
deps.logDebug('oops', null);
assert.deepEqual(calls, [
'list',
'send',
'wait',
'cache',
'cleanup',
'load-delay:3',
'active-delay:3',
'load-source:/tmp/sub.srt',
'save-delay:3:-31.5',
'debug:oops',
]);
assert.deepEqual(calls, ['list', 'send', 'wait', 'cache', 'cleanup', 'debug:oops']);
});
@@ -15,19 +15,6 @@ export function createBuildPreloadJellyfinExternalSubtitlesMainDepsHandler(
wait: (ms: number) => deps.wait(ms),
cacheSubtitleTrack: (track) => deps.cacheSubtitleTrack(track),
cleanupCachedSubtitles: (dirs) => deps.cleanupCachedSubtitles(dirs),
getSavedSubtitleDelay: deps.getSavedSubtitleDelay
? (itemId, streamIndex) => deps.getSavedSubtitleDelay!(itemId, streamIndex)
: undefined,
setActiveSubtitleDelayKey: deps.setActiveSubtitleDelayKey
? (key) => deps.setActiveSubtitleDelayKey!(key)
: undefined,
loadSubtitleSourceText: deps.loadSubtitleSourceText
? (source) => deps.loadSubtitleSourceText!(source)
: undefined,
saveSubtitleDelay: deps.saveSubtitleDelay
? (itemId, streamIndex, delaySeconds) =>
deps.saveSubtitleDelay!(itemId, streamIndex, delaySeconds)
: undefined,
initSubtitlePrefetch: deps.initSubtitlePrefetch
? (sourcePath) => deps.initSubtitlePrefetch!(sourcePath)
: undefined,
@@ -32,14 +32,6 @@ function makeDeps(overrides: {
cleanupCachedSubtitles?: Parameters<
typeof createPreloadJellyfinExternalSubtitlesHandler
>[0]['cleanupCachedSubtitles'];
getSavedSubtitleDelay?: Parameters<
typeof createPreloadJellyfinExternalSubtitlesHandler
>[0]['getSavedSubtitleDelay'];
setActiveSubtitleDelayKey?: Parameters<
typeof createPreloadJellyfinExternalSubtitlesHandler
>[0]['setActiveSubtitleDelayKey'];
loadSubtitleSourceText?: (source: string) => Promise<string>;
saveSubtitleDelay?: (itemId: string, streamIndex: number, delaySeconds: number) => void;
initSubtitlePrefetch?: Parameters<
typeof createPreloadJellyfinExternalSubtitlesHandler
>[0]['initSubtitlePrefetch'];
@@ -57,10 +49,6 @@ function makeDeps(overrides: {
cleanupDir: '/tmp/subminer-jellyfin-subtitles',
})),
cleanupCachedSubtitles: overrides.cleanupCachedSubtitles ?? (() => {}),
getSavedSubtitleDelay: overrides.getSavedSubtitleDelay,
setActiveSubtitleDelayKey: overrides.setActiveSubtitleDelayKey,
loadSubtitleSourceText: overrides.loadSubtitleSourceText,
saveSubtitleDelay: overrides.saveSubtitleDelay,
initSubtitlePrefetch: overrides.initSubtitlePrefetch,
logDebug: overrides.logDebug ?? (() => {}),
};
@@ -377,20 +365,17 @@ test('preload jellyfin subtitles waits for delayed external japanese track inste
test('preload jellyfin subtitles clears managed delay when no external tracks are available', async () => {
const commands: Array<Array<string | number>> = [];
const activeDelayKeys: Array<unknown> = [];
const preload = createPreloadJellyfinExternalSubtitlesHandler(
makeDeps({
listJellyfinSubtitleTracks: async () => [
{ index: 0, language: 'jpn', title: 'Embedded Japanese' },
],
sendMpvCommand: (command) => commands.push(command),
setActiveSubtitleDelayKey: (key) => activeDelayKeys.push(key),
}),
);
await preload({ session, clientInfo, itemId: 'item-1' });
assert.deepEqual(activeDelayKeys, [null]);
assert.deepEqual(commands, [['set_property', 'sub-delay', 0]]);
});
@@ -461,42 +446,7 @@ test('preload jellyfin subtitles prefers Jellyfin default and embedded japanese
]);
});
test('preload jellyfin subtitles applies saved delay for selected japanese stream', async () => {
const commands: Array<Array<string | number>> = [];
const activeKeys: Array<{ itemId: string; streamIndex: number } | null> = [];
const preload = createPreloadJellyfinExternalSubtitlesHandler(
makeDeps({
listJellyfinSubtitleTracks: async () => [
{ index: 3, language: 'jpn', title: 'Japanese', deliveryUrl: 'https://sub/jpn.srt' },
],
getMpvClient: () => ({
requestProperty: async () => [
{
type: 'sub',
id: 11,
lang: 'jpn',
title: 'Japanese',
external: true,
'external-filename': '/tmp/subminer-jellyfin-subtitles/3.srt',
},
],
}),
sendMpvCommand: (command) => commands.push(command),
getSavedSubtitleDelay: (_itemId, streamIndex) => (streamIndex === 3 ? 1.25 : null),
setActiveSubtitleDelayKey: (key) => activeKeys.push(key),
}),
);
await preload({ session, clientInfo, itemId: 'item-9' });
assert.deepEqual(setPropertyCommandsExceptTrackAutoSelection(commands), [
['set_property', 'sub-delay', 1.25],
['set_property', 'sid', 11],
]);
assert.deepEqual(activeKeys, [{ itemId: 'item-9', streamIndex: 3 }]);
});
test('preload jellyfin subtitles applies saved delay before selecting japanese stream', async () => {
test('preload jellyfin subtitles resets delay before selecting japanese stream', async () => {
const commands: Array<Array<string | number>> = [];
const preload = createPreloadJellyfinExternalSubtitlesHandler(
makeDeps({
@@ -516,14 +466,13 @@ test('preload jellyfin subtitles applies saved delay before selecting japanese s
],
}),
sendMpvCommand: (command) => commands.push(command),
getSavedSubtitleDelay: () => 1.25,
}),
);
await preload({ session, clientInfo, itemId: 'item-9' });
const delayIndex = commands.findIndex(
(command) => command[0] === 'set_property' && command[1] === 'sub-delay' && command[2] === 1.25,
(command) => command[0] === 'set_property' && command[1] === 'sub-delay' && command[2] === 0,
);
const selectedSidIndex = commands.findIndex(
(command) => command[0] === 'set_property' && command[1] === 'sid' && command[2] === 11,
@@ -533,143 +482,6 @@ test('preload jellyfin subtitles applies saved delay before selecting japanese s
assert.ok(delayIndex < selectedSidIndex);
});
test('preload jellyfin subtitles auto-aligns late japanese track from english reference', async () => {
const commands: Array<Array<string | number>> = [];
const savedDelays: Array<{ itemId: string; streamIndex: number; delaySeconds: number }> = [];
const primarySrt = `1
00:00:34,935 --> 00:00:36,937
Japanese 1
2
00:00:36,937 --> 00:00:41,441
Japanese 2
3
00:00:41,441 --> 00:00:45,279
Japanese 3
4
00:00:45,279 --> 00:00:48,115
Japanese 4
5
00:00:48,115 --> 00:00:52,286
Japanese 5
6
00:00:52,286 --> 00:00:54,955
Japanese 6
7
00:00:54,955 --> 00:00:59,793
Japanese 7
8
00:00:59,793 --> 00:01:03,630
Japanese 8
9
00:01:03,630 --> 00:01:07,634
Japanese 9
10
00:01:07,634 --> 00:01:13,040
Japanese 10
11
00:01:16,643 --> 00:01:20,814
Japanese 11
12
00:01:20,814 --> 00:01:23,116
Japanese 12
13
00:01:27,988 --> 00:01:30,991
Japanese 13
14
00:01:30,991 --> 00:01:34,094
Japanese 14
15
00:01:34,094 --> 00:01:37,097
Japanese 15
16
00:01:37,097 --> 00:01:39,100
Japanese 16
`;
const referenceAss = `[Events]
Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text
Dialogue: 0,0:00:03.46,0:00:08.73,Default,,0,0,0,,English 1
Dialogue: 0,0:00:09.48,0:00:13.61,Default,,0,0,0,,English 2
Dialogue: 0,0:00:13.61,0:00:19.64,Default,,0,0,0,,English 3
Dialogue: 0,0:00:21.40,0:00:27.32,Default,,0,0,0,,English 4
Dialogue: 0,0:00:28.16,0:00:31.75,Default,,0,0,0,,English 5
Dialogue: 0,0:00:32.06,0:00:34.52,Default,,0,0,0,,English 6
Dialogue: 0,0:00:35.93,0:00:40.57,Default,,0,0,0,,English 7
Dialogue: 0,0:00:45.10,0:00:51.01,Default,,0,0,0,,English 8
Dialogue: 0,0:00:56.57,0:00:59.12,Default,,0,0,0,,English 9
Dialogue: 0,0:00:59.68,0:01:02.44,Default,,0,0,0,,English 10
Dialogue: 0,0:01:02.44,0:01:05.56,Default,,0,0,0,,English 11
Dialogue: 0,0:01:05.56,0:01:06.87,Default,,0,0,0,,English 12
`;
const preload = createPreloadJellyfinExternalSubtitlesHandler(
makeDeps({
listJellyfinSubtitleTracks: async () => [
{ index: 0, language: 'jpn', title: 'Japanese', deliveryUrl: 'https://sub/jpn.srt' },
{ index: 4, language: 'eng', title: 'English', deliveryUrl: 'https://sub/eng.ass' },
],
getMpvClient: () => ({
requestProperty: async () => [
{
type: 'sub',
id: 10,
lang: 'jpn',
title: 'Japanese',
external: true,
'external-filename': '/tmp/subminer-jellyfin-subtitles/0.srt',
},
{
type: 'sub',
id: 12,
lang: 'eng',
title: 'English',
external: true,
'external-filename': '/tmp/subminer-jellyfin-subtitles/4.ass',
},
],
}),
sendMpvCommand: (command) => commands.push(command),
cacheSubtitleTrack: async (track) => ({
path: `/tmp/subminer-jellyfin-subtitles/${track.index}.${track.index === 4 ? 'ass' : 'srt'}`,
cleanupDir: '/tmp/subminer-jellyfin-subtitles',
}),
getSavedSubtitleDelay: () => null,
loadSubtitleSourceText: async (source) =>
source.endsWith('.ass') ? referenceAss : primarySrt,
saveSubtitleDelay: (itemId, streamIndex, delaySeconds) => {
savedDelays.push({ itemId, streamIndex, delaySeconds });
},
}),
);
await preload({ session, clientInfo, itemId: 'item-9' });
const delayCommand = commands.find(
(command) => command[0] === 'set_property' && command[1] === 'sub-delay',
);
assert.ok(delayCommand);
const delaySeconds = delayCommand[2];
if (typeof delaySeconds !== 'number') {
assert.fail('Expected numeric subtitle delay.');
}
assert.ok(delaySeconds > -32);
assert.ok(delaySeconds < -31);
assert.deepEqual(savedDelays, [{ itemId: 'item-9', streamIndex: 0, delaySeconds }]);
});
test('preload jellyfin subtitles accepts numeric string mpv track ids', async () => {
const commands: Array<Array<string | number>> = [];
const preload = createPreloadJellyfinExternalSubtitlesHandler(
+1 -89
View File
@@ -1,6 +1,3 @@
import { parseSubtitleCues } from '../../core/services/subtitle-cue-parser';
import { estimateSubtitleTimingOffset } from '../../core/services/subtitle-timing-offset';
type JellyfinSession = {
serverUrl: string;
accessToken: string;
@@ -35,11 +32,6 @@ type CachedExternalSubtitleTrack = CachedSubtitleTrack & {
source: JellyfinSubtitleTrack;
};
type JellyfinSubtitleDelayKey = {
itemId: string;
streamIndex: number;
};
type MpvSubtitleTrack = {
id: number;
lang: string;
@@ -257,54 +249,6 @@ async function waitForPreferredSubtitleTracks(
return subtitleTracks;
}
async function estimateSubtitleDelayFromReference(
deps: {
loadSubtitleSourceText?: (source: string) => Promise<string>;
logDebug: (message: string, error: unknown) => void;
},
primaryTrack: CachedExternalSubtitleTrack | null,
referenceTrack: CachedExternalSubtitleTrack | null,
): Promise<number | null> {
if (!deps.loadSubtitleSourceText || !primaryTrack || !referenceTrack) {
return null;
}
try {
const [primaryContent, referenceContent] = await Promise.all([
deps.loadSubtitleSourceText(primaryTrack.path),
deps.loadSubtitleSourceText(referenceTrack.path),
]);
const primaryCues = parseSubtitleCues(primaryContent, primaryTrack.path);
const referenceCues = parseSubtitleCues(referenceContent, referenceTrack.path);
return estimateSubtitleTimingOffset(primaryCues, referenceCues)?.offsetSeconds ?? null;
} catch (error) {
deps.logDebug('Failed to auto-align Jellyfin subtitle timing', error);
return null;
}
}
function saveEstimatedSubtitleDelay(
deps: {
saveSubtitleDelay?: (
itemId: string,
streamIndex: number,
delaySeconds: number,
) => boolean | void;
logDebug: (message: string, error: unknown) => void;
},
key: JellyfinSubtitleDelayKey,
delaySeconds: number,
): void {
try {
const saved = deps.saveSubtitleDelay?.(key.itemId, key.streamIndex, delaySeconds);
if (saved === false) {
deps.logDebug('Failed to save Jellyfin auto subtitle delay', key);
}
} catch (error) {
deps.logDebug('Failed to save Jellyfin auto subtitle delay', error);
}
}
export function createPreloadJellyfinExternalSubtitlesHandler(deps: {
listJellyfinSubtitleTracks: (
session: JellyfinSession,
@@ -316,10 +260,6 @@ export function createPreloadJellyfinExternalSubtitlesHandler(deps: {
wait: (ms: number) => Promise<void>;
cacheSubtitleTrack: (track: JellyfinSubtitleTrack) => Promise<CachedSubtitleTrack>;
cleanupCachedSubtitles: (dirs: string[]) => void;
getSavedSubtitleDelay?: (itemId: string, streamIndex: number) => number | null;
setActiveSubtitleDelayKey?: (key: JellyfinSubtitleDelayKey | null) => void;
loadSubtitleSourceText?: (source: string) => Promise<string>;
saveSubtitleDelay?: (itemId: string, streamIndex: number, delaySeconds: number) => boolean | void;
initSubtitlePrefetch?: (sourcePath: string) => void | Promise<void>;
logDebug: (message: string, error: unknown) => void;
}): PreloadJellyfinExternalSubtitlesHandler {
@@ -357,6 +297,7 @@ export function createPreloadJellyfinExternalSubtitlesHandler(deps: {
itemId: string;
}): Promise<void> => {
try {
resetManagedSubtitleDelay();
try {
cleanupActiveCache();
} catch (error) {
@@ -369,8 +310,6 @@ export function createPreloadJellyfinExternalSubtitlesHandler(deps: {
);
const externalTracks = tracks.filter((track) => Boolean(track.deliveryUrl));
if (externalTracks.length === 0) {
deps.setActiveSubtitleDelayKey?.(null);
resetManagedSubtitleDelay();
return;
}
@@ -427,40 +366,13 @@ export function createPreloadJellyfinExternalSubtitlesHandler(deps: {
japanesePrimaryId,
);
if (selectedCachedTrack) {
const delayKey = { itemId: params.itemId, streamIndex: selectedCachedTrack.source.index };
deps.setActiveSubtitleDelayKey?.(delayKey);
const savedDelay = deps.getSavedSubtitleDelay?.(delayKey.itemId, delayKey.streamIndex);
if (typeof savedDelay === 'number' && Number.isFinite(savedDelay)) {
deps.sendMpvCommand(['set_property', 'sub-delay', savedDelay]);
} else {
const referenceCachedTrack = findCachedTrackForMpvTrackId(
resolvedSubtitleTracks,
cachedTracks,
englishSecondaryId,
);
const estimatedDelay = await estimateSubtitleDelayFromReference(
deps,
selectedCachedTrack,
referenceCachedTrack,
);
if (estimatedDelay !== null) {
deps.sendMpvCommand(['set_property', 'sub-delay', estimatedDelay]);
saveEstimatedSubtitleDelay(deps, delayKey, estimatedDelay);
} else {
resetManagedSubtitleDelay();
}
}
deps.sendMpvCommand(['set_property', 'sid', japanesePrimaryId]);
startSubtitlePrefetchForCachedTrack(selectedCachedTrack.path);
} else {
deps.setActiveSubtitleDelayKey?.(null);
resetManagedSubtitleDelay();
deps.sendMpvCommand(['set_property', 'sid', japanesePrimaryId]);
}
} else {
deps.sendMpvCommand(['set_property', 'sid', 'no']);
deps.setActiveSubtitleDelayKey?.(null);
resetManagedSubtitleDelay();
}
if (englishSecondaryId !== null) {
+2 -2
View File
@@ -205,8 +205,8 @@
<div id="kikuSelectionStep">
<div class="kiku-info-text">
A card with the same expression already exists. Select which card to keep. The other
card's content will be merged using Kiku field grouping. You can choose whether to
delete the duplicate.
card's content will be merged using field grouping. You can choose whether to delete
the duplicate.
</div>
<div class="kiku-cards-container">
<div id="kikuCard1" class="kiku-card active" tabindex="0">
+1
View File
@@ -59,6 +59,7 @@ const RUNTIME_OPTION_IDS: RuntimeOptionId[] = [
'subtitle.annotation.jlpt',
'subtitle.annotation.frequency',
'anki.kikuFieldGrouping',
'anki.senrenFieldGrouping',
'anki.nPlusOneMatchMode',
];
+5
View File
@@ -124,6 +124,11 @@ export interface AnkiConnectConfig {
fieldGrouping?: 'auto' | 'manual' | 'disabled';
deleteDuplicateInAuto?: boolean;
};
isSenren?: {
enabled?: boolean;
fieldGrouping?: 'auto' | 'manual' | 'disabled';
deleteDuplicateInAuto?: boolean;
};
lapisKiku?: {
wordCardKind?: WordCardKind;
};
+5
View File
@@ -285,6 +285,11 @@ export interface ResolvedConfig {
fieldGrouping: 'auto' | 'manual' | 'disabled';
deleteDuplicateInAuto: boolean;
};
isSenren: {
enabled: boolean;
fieldGrouping: 'auto' | 'manual' | 'disabled';
deleteDuplicateInAuto: boolean;
};
lapisKiku: {
wordCardKind: WordCardKind;
};
+1
View File
@@ -6,6 +6,7 @@ export type RuntimeOptionId =
| 'subtitle.annotation.jlpt'
| 'subtitle.annotation.frequency'
| 'anki.kikuFieldGrouping'
| 'anki.senrenFieldGrouping'
| 'anki.nPlusOneMatchMode';
export type RuntimeOptionScope = 'ankiConnect' | 'subtitle';