mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-09-10 17:16:20 -07:00
feat(anki): add Senren scene-switching field grouping
- Support auto, manual, and disabled Senren duplicate-card merges - Group sentence, furigana, audio, picture, and miscInfo fields
This commit is contained in:
@@ -0,0 +1,4 @@
|
||||
type: fixed
|
||||
area: overlay
|
||||
|
||||
- Broadcast-style Japanese caption tracks (Crunchyroll JA subs) that split one sentence across two positioned events now publish it as a single line, so `preserveLineBreaks: false` flattens it, the sidebar lists it once, and mined sentences are whole. Rows from two different speakers, sound effects, and labeled turns still stay on separate lines.
|
||||
@@ -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.
|
||||
@@ -0,0 +1,4 @@
|
||||
type: fixed
|
||||
area: mining
|
||||
|
||||
- Multi-line copy and mining now select backward from the current subtitle in timeline order after seeking, instead of copying lines in playback encounter order. Jumping back to a short previous line also counts as a seek with external subtitle files, so that line becomes the current one.
|
||||
@@ -0,0 +1,4 @@
|
||||
type: fixed
|
||||
area: overlay
|
||||
|
||||
- Native mpv secondary subtitles stay hidden when switching secondary subtitle tracks during playback.
|
||||
@@ -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).
|
||||
@@ -0,0 +1,4 @@
|
||||
type: fixed
|
||||
area: subtitles
|
||||
|
||||
- Copying the current subtitle, Anki sentence mining from recent lines, and immersion stats no longer include the separate furigana lines that broadcast-caption ASS files place above a word; recorders now use the same furigana-free text the overlay displays.
|
||||
@@ -523,7 +523,7 @@
|
||||
// ==========================================
|
||||
// AnkiConnect Integration
|
||||
// Automatic Anki updates and media generation options.
|
||||
// Hot-reload: ankiConnect.ai.enabled, media.normalizeAudio/mirrorMpvVolume/reviewTiming, 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/reviewTiming, 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.
|
||||
// ==========================================
|
||||
@@ -607,6 +607,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.
|
||||
|
||||
@@ -315,9 +315,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": {
|
||||
@@ -329,6 +329,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.
|
||||
@@ -344,9 +356,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 use `group` spans when ordinal order is sufficient and numbered `groupN` spans when they need an absolute scene target. 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 source `groupN` spans are rebased after the kept note's existing audio scenes.
|
||||
|
||||
### Keyboard Shortcuts in the Modal
|
||||
|
||||
| Key | Action |
|
||||
|
||||
@@ -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**
|
||||
|
||||
@@ -1054,6 +1054,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.
|
||||
@@ -1083,6 +1084,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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -523,7 +523,7 @@
|
||||
// ==========================================
|
||||
// AnkiConnect Integration
|
||||
// Automatic Anki updates and media generation options.
|
||||
// Hot-reload: ankiConnect.ai.enabled, media.normalizeAudio/mirrorMpvVolume/reviewTiming, 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/reviewTiming, 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.
|
||||
// ==========================================
|
||||
@@ -607,6 +607,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.
|
||||
|
||||
@@ -35,7 +35,7 @@ These work when the overlay window has focus.
|
||||
| `Ctrl/Cmd+G` | Trigger field grouping (Kiku merge check) | `shortcuts.triggerFieldGrouping` |
|
||||
| `Ctrl/Cmd+Shift+A` | Mark last card as audio card | `shortcuts.markAudioCard` |
|
||||
|
||||
The multi-line shortcuts open a digit selector with a 3-second timeout (`shortcuts.multiCopyTimeoutMs`). Press `1`–`9` to select how many recent subtitle lines to combine. When the shortcut starts from mpv, SubMiner focuses the visible overlay for that selector instead of reserving the number keys in the mpv plugin.
|
||||
The multi-line shortcuts open a digit selector with a 3-second timeout (`shortcuts.multiCopyTimeoutMs`). Press `1`–`9` to select the total number of subtitle lines to combine, ending at the current line and moving backward through the subtitle timeline. The current line counts toward the selected total. When the shortcut starts from mpv, SubMiner focuses the visible overlay for that selector instead of reserving the number keys in the mpv plugin.
|
||||
|
||||
## Overlay Controls
|
||||
|
||||
|
||||
@@ -131,7 +131,19 @@ coming and prefetching would otherwise idle for the rest of the cue.
|
||||
authored source order when no usable position exists.
|
||||
- Half-size kana positioned directly above a same-timed kanji caption is treated as ASS
|
||||
furigana. The parser omits it from published cues but retains hidden matching metadata so
|
||||
mpv's raw live text can be reconciled without displaying or mining the reading.
|
||||
mpv's raw live text can be reconciled without displaying or mining the reading. The
|
||||
timing tracker (clipboard copy, recent-line mining) and immersion recorders run the same
|
||||
reconciliation on the `sub-start`/`sub-end` sample, so they record what the overlay shows.
|
||||
- Broadcast-caption rows that spell one utterance across several same-timed positioned events
|
||||
(same style, layer, and vertical band, stacked at most two text rows apart) are joined into
|
||||
one cue with a single line break, so `preserveLineBreaks` treats them like an authored `\N`,
|
||||
and the recorders above see the whole sentence. A row continues the one above it when that row
|
||||
is a bare speaker label, ends without terminal punctuation, or leaves a ≪…≫ / ⸨…⸩ span open; a
|
||||
lower row that opens its own label or span always starts a new cue, which keeps two speakers
|
||||
sharing the screen on separate lines. The pass runs only on scripts that read as broadcast
|
||||
captions (a meaningful share of events carry speaker labels or ≪…≫ / ⸨…⸩ spans) and only on
|
||||
rows containing Japanese, because fansub typesetting stacks positioned rows for signs, chat
|
||||
bubbles, and headlines where that punctuation convention does not hold.
|
||||
- Fragment-only ASS karaoke is reconstructed per style before publication. Explicit spaces
|
||||
survive concatenation. Latin fragment typesetting with no literal spaces also recovers word
|
||||
boundaries represented only by materially larger horizontal `\pos` or `\move` gaps within that
|
||||
|
||||
@@ -155,6 +155,7 @@ function createFieldGroupingMergeCollaborator(options?: {
|
||||
getEffectiveSentenceCardConfig: () => ({
|
||||
sentenceField: 'Sentence',
|
||||
audioField: 'SentenceAudio',
|
||||
fieldGroupingProvider: 'kiku' as const,
|
||||
}),
|
||||
getCurrentSubtitleText: () => options?.currentSubtitleText,
|
||||
resolveFieldName,
|
||||
|
||||
+40
-3
@@ -852,6 +852,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;
|
||||
@@ -860,10 +873,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,
|
||||
@@ -871,8 +901,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),
|
||||
};
|
||||
}
|
||||
@@ -891,7 +928,7 @@ export class AnkiIntegration {
|
||||
|
||||
private async processNewCard(
|
||||
noteId: number,
|
||||
options?: { skipKikuFieldGrouping?: boolean },
|
||||
options?: { skipFieldGrouping?: boolean },
|
||||
): Promise<void> {
|
||||
await this.noteUpdateWorkflow.execute(noteId, options);
|
||||
}
|
||||
@@ -1521,7 +1558,7 @@ export class AnkiIntegration {
|
||||
trackedDuplicateNoteIdsBeforeCreate: Set<number>,
|
||||
): boolean {
|
||||
const sentenceCardConfig = this.getEffectiveSentenceCardConfig();
|
||||
if (!sentenceCardConfig.kikuEnabled || sentenceCardConfig.kikuFieldGrouping === 'disabled') {
|
||||
if (sentenceCardConfig.fieldGroupingMode === 'disabled') {
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
@@ -125,8 +125,7 @@ function createManualUpdateService(overrides: Partial<CardCreationDeps> = {}): {
|
||||
audioField: 'SentenceAudio',
|
||||
lapisEnabled: false,
|
||||
kikuEnabled: false,
|
||||
kikuFieldGrouping: 'disabled',
|
||||
kikuDeleteDuplicateInAuto: false,
|
||||
fieldGroupingMode: 'disabled',
|
||||
}),
|
||||
getFallbackDurationSeconds: () => 10,
|
||||
appendKnownWordsFromNoteInfo: () => undefined,
|
||||
@@ -211,8 +210,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',
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -279,8 +277,7 @@ test('audio-card action keeps Lapis and Kiku sentence fields', async () => {
|
||||
audioField: 'SentenceAudio',
|
||||
lapisEnabled: true,
|
||||
kikuEnabled: true,
|
||||
kikuFieldGrouping: 'disabled',
|
||||
kikuDeleteDuplicateInAuto: false,
|
||||
fieldGroupingMode: 'disabled',
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -343,8 +340,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,
|
||||
});
|
||||
|
||||
@@ -120,8 +120,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,
|
||||
|
||||
@@ -70,8 +70,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,
|
||||
@@ -171,8 +170,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,
|
||||
@@ -272,8 +270,7 @@ test('CardCreationService keeps updating after recordCardsMinedCallback throws',
|
||||
audioField: 'SentenceAudio',
|
||||
lapisEnabled: false,
|
||||
kikuEnabled: false,
|
||||
kikuFieldGrouping: 'disabled',
|
||||
kikuDeleteDuplicateInAuto: false,
|
||||
fieldGroupingMode: 'disabled',
|
||||
}),
|
||||
getFallbackDurationSeconds: () => 10,
|
||||
appendKnownWordsFromNoteInfo: () => undefined,
|
||||
@@ -394,8 +391,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,
|
||||
@@ -499,8 +495,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,
|
||||
@@ -640,8 +635,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,
|
||||
@@ -741,8 +735,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,
|
||||
@@ -832,8 +825,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,
|
||||
|
||||
@@ -139,8 +139,7 @@ interface CardCreationDeps {
|
||||
audioField: string;
|
||||
lapisEnabled: boolean;
|
||||
kikuEnabled: boolean;
|
||||
kikuFieldGrouping: 'auto' | 'manual' | 'disabled';
|
||||
kikuDeleteDuplicateInAuto: boolean;
|
||||
fieldGroupingMode: 'auto' | 'manual' | 'disabled';
|
||||
wordCardKind?: WordCardKind;
|
||||
};
|
||||
getFallbackDurationSeconds: () => number;
|
||||
@@ -697,8 +696,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,8 @@ function createCollaborator(
|
||||
getEffectiveSentenceCardConfig: () => ({
|
||||
sentenceField: 'Sentence',
|
||||
audioField: 'SentenceAudio',
|
||||
fieldGroupingProvider:
|
||||
options.fieldGroupingProvider === undefined ? 'kiku' : options.fieldGroupingProvider,
|
||||
}),
|
||||
getCurrentSubtitleText: () => options.currentSubtitleText,
|
||||
resolveFieldName,
|
||||
@@ -251,7 +254,218 @@ 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="group2">次<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="group2">Show EP2 (0:02:00)</span>',
|
||||
);
|
||||
});
|
||||
|
||||
test('Senren merge warns for invalid source audio when kept audio is empty', async () => {
|
||||
const warnings: Array<{ fieldName: string; reason: string; detail?: string }> = [];
|
||||
const { collaborator } = createCollaborator({
|
||||
fieldGroupingProvider: 'senren',
|
||||
warnings,
|
||||
});
|
||||
|
||||
const merged = await collaborator.computeFieldGroupingMergedFields(
|
||||
300,
|
||||
200,
|
||||
makeNote(300, { SentenceAudio: '' }),
|
||||
makeNote(200, { SentenceAudio: 'invalid audio' }),
|
||||
false,
|
||||
);
|
||||
|
||||
assert.equal(merged.SentenceAudio, 'invalid audio');
|
||||
assert.deepEqual(warnings, [
|
||||
{
|
||||
fieldName: 'SentenceAudio',
|
||||
reason: 'missing-sound-tag',
|
||||
detail: undefined,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
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="group3">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="group3">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 rebases plain source groups after empty and sparse kept fields', async () => {
|
||||
const { collaborator } = createCollaborator({ fieldGroupingProvider: 'senren' });
|
||||
|
||||
const merged = await collaborator.computeFieldGroupingMergedFields(
|
||||
300,
|
||||
200,
|
||||
makeNote(300, {
|
||||
sentenceAudio: '[sound:keep-a.opus][sound:keep-b.opus]',
|
||||
sentence: '',
|
||||
miscInfo: '<span class="group">keep first</span>',
|
||||
}),
|
||||
makeNote(200, {
|
||||
sentenceAudio: '[sound:source-a.opus][sound:source-b.opus]',
|
||||
sentence: '<span class="group">source first</span>',
|
||||
miscInfo: '<span class="group">source first</span><span class="group2">source second</span>',
|
||||
}),
|
||||
false,
|
||||
);
|
||||
|
||||
assert.equal(merged.sentence, '<span class="group3">source first</span>');
|
||||
assert.equal(
|
||||
merged.miscInfo,
|
||||
'<span class="group">keep first</span><span class="group3">source first</span>' +
|
||||
'<span class="group4">source second</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="group4">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 +482,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>');
|
||||
});
|
||||
|
||||
@@ -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,152 @@ 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 rebaseSenrenGroup(entry: string, sceneOffset: number, sourceEntryIndex: 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) => {
|
||||
if (classToken === 'group') {
|
||||
return `group${sceneOffset + sourceEntryIndex + 1}`;
|
||||
}
|
||||
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 (added && !/\[sound:[^\]]+\]/.test(added)) {
|
||||
this.deps.warnFieldParseOnce(fieldName, 'missing-sound-tag');
|
||||
}
|
||||
if (!existing || !added) return existing || added;
|
||||
return existing + added;
|
||||
}
|
||||
|
||||
const sourceEntries = this.parseSenrenSceneEntries(newValue).map((entry, sourceEntryIndex) =>
|
||||
this.rebaseSenrenGroup(entry, sourceSceneOffset, sourceEntryIndex),
|
||||
);
|
||||
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);
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -59,7 +59,7 @@ function createWorkflowHarness() {
|
||||
sentenceField: 'Sentence',
|
||||
lapisEnabled: false,
|
||||
kikuEnabled: false,
|
||||
kikuFieldGrouping: 'disabled' as const,
|
||||
fieldGroupingMode: 'disabled' as const,
|
||||
}),
|
||||
appendKnownWordsFromNoteInfo: (_noteInfo: NoteUpdateWorkflowNoteInfo) => undefined,
|
||||
removeKnownWordNote: (_noteId: number) => undefined,
|
||||
@@ -138,7 +138,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 () =>
|
||||
[
|
||||
@@ -195,7 +195,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 () =>
|
||||
[
|
||||
@@ -228,7 +228,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 () =>
|
||||
@@ -264,7 +264,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 () =>
|
||||
@@ -319,7 +319,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 () =>
|
||||
[
|
||||
@@ -362,7 +362,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 () => {
|
||||
|
||||
@@ -46,7 +46,7 @@ export interface NoteUpdateWorkflowDeps {
|
||||
sentenceField: string;
|
||||
lapisEnabled: boolean;
|
||||
kikuEnabled: boolean;
|
||||
kikuFieldGrouping: 'auto' | 'manual' | 'disabled';
|
||||
fieldGroupingMode: 'auto' | 'manual' | 'disabled';
|
||||
wordCardKind?: WordCardKind;
|
||||
};
|
||||
appendKnownWordsFromNoteInfo: (noteInfo: NoteUpdateWorkflowNoteInfo) => void;
|
||||
@@ -170,7 +170,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]);
|
||||
@@ -196,9 +196,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);
|
||||
@@ -401,7 +399,7 @@ export class NoteUpdateWorkflow {
|
||||
noteInfoForGrouping = refreshedInfo[0]!;
|
||||
}
|
||||
|
||||
if (sentenceCardConfig.kikuFieldGrouping === 'auto') {
|
||||
if (sentenceCardConfig.fieldGroupingMode === 'auto') {
|
||||
await this.deps.handleFieldGroupingAuto(
|
||||
duplicateNoteId,
|
||||
noteId,
|
||||
@@ -410,7 +408,7 @@ export class NoteUpdateWorkflow {
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (sentenceCardConfig.kikuFieldGrouping === 'manual') {
|
||||
if (sentenceCardConfig.fieldGroupingMode === 'manual') {
|
||||
await this.deps.handleFieldGroupingManual(
|
||||
duplicateNoteId,
|
||||
noteId,
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -2189,6 +2189,7 @@ test('runtime options registry is centralized', () => {
|
||||
'subtitle.annotation.frequency',
|
||||
'anki.nPlusOneMatchMode',
|
||||
'anki.kikuFieldGrouping',
|
||||
'anki.senrenFieldGrouping',
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -2776,6 +2777,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(
|
||||
|
||||
@@ -92,6 +92,11 @@ export const INTEGRATIONS_DEFAULT_CONFIG: Pick<
|
||||
fieldGrouping: 'disabled',
|
||||
deleteDuplicateInAuto: true,
|
||||
},
|
||||
isSenren: {
|
||||
enabled: false,
|
||||
fieldGrouping: 'auto',
|
||||
deleteDuplicateInAuto: true,
|
||||
},
|
||||
lapisKiku: {
|
||||
wordCardKind: 'word-and-sentence',
|
||||
},
|
||||
|
||||
@@ -371,6 +371,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',
|
||||
|
||||
@@ -138,5 +138,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',
|
||||
},
|
||||
}),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
@@ -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/reviewTiming, 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/reviewTiming, 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.',
|
||||
],
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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'],
|
||||
);
|
||||
});
|
||||
|
||||
@@ -367,6 +369,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);
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
@@ -252,7 +254,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':
|
||||
@@ -408,9 +412,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' };
|
||||
@@ -711,6 +716,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' ||
|
||||
|
||||
@@ -86,6 +86,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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -244,6 +244,35 @@ test('handleMultiCopyDigit copies available history and reports truncation', ()
|
||||
assert.equal(osd.at(-1), 'Only 2 lines available, copied 2');
|
||||
});
|
||||
|
||||
test('handleMultiCopyDigit copies backward from the current subtitle after a backward seek', () => {
|
||||
const copied: string[] = [];
|
||||
const tracker = new SubtitleTimingTracker();
|
||||
|
||||
try {
|
||||
tracker.recordSubtitle('A', 1, 2);
|
||||
tracker.recordSubtitle('B', 3, 4);
|
||||
tracker.recordSubtitle('C', 5, 6);
|
||||
tracker.recordSubtitle('B', 3, 4);
|
||||
|
||||
const deps = {
|
||||
subtitleTimingTracker: tracker,
|
||||
writeClipboardText: (text: string) => copied.push(text),
|
||||
showMpvOsd: () => {},
|
||||
};
|
||||
|
||||
handleMultiCopyDigit(1, deps);
|
||||
handleMultiCopyDigit(2, deps);
|
||||
|
||||
assert.deepEqual(copied, ['B', 'A\n\nB']);
|
||||
assert.deepEqual(tracker.getRecentEntries(2), [
|
||||
{ displayText: 'A', startTime: 1, endTime: 2, secondaryText: undefined },
|
||||
{ displayText: 'B', startTime: 3, endTime: 4, secondaryText: undefined },
|
||||
]);
|
||||
} finally {
|
||||
tracker.destroy();
|
||||
}
|
||||
});
|
||||
|
||||
test('handleMineSentenceDigit reports async create failures', async () => {
|
||||
const osd: string[] = [];
|
||||
const logs: Array<{ message: string; err: unknown }> = [];
|
||||
@@ -344,6 +373,22 @@ test('handleMineSentenceDigit keeps per-entry timings when subtitle text repeats
|
||||
}
|
||||
});
|
||||
|
||||
test('subtitle timing history preserves adjacent repeated text with distinct timings', () => {
|
||||
const tracker = new SubtitleTimingTracker();
|
||||
|
||||
try {
|
||||
tracker.recordSubtitle('same', 1, 2);
|
||||
tracker.recordSubtitle('same', 3, 4);
|
||||
|
||||
assert.deepEqual(tracker.getRecentEntries(2), [
|
||||
{ displayText: 'same', startTime: 1, endTime: 2, secondaryText: undefined },
|
||||
{ displayText: 'same', startTime: 3, endTime: 4, secondaryText: undefined },
|
||||
]);
|
||||
} finally {
|
||||
tracker.destroy();
|
||||
}
|
||||
});
|
||||
|
||||
test('handleMineSentenceDigit joins per-entry secondary subtitles when available', async () => {
|
||||
const created: Array<{ sentence: string; secondarySub?: string }> = [];
|
||||
const tracker = new SubtitleTimingTracker();
|
||||
|
||||
@@ -83,6 +83,7 @@ function createDeps(overrides: Partial<MpvProtocolHandleMessageDeps> = {}): {
|
||||
state.secondarySubText = text;
|
||||
},
|
||||
resolvePendingRequest: () => false,
|
||||
shouldEnforceSecondarySubVisibilityHidden: () => true,
|
||||
setSecondarySubVisibility: () => {},
|
||||
syncCurrentAudioStreamIndex: () => {},
|
||||
setCurrentAudioTrackId: () => {},
|
||||
@@ -198,6 +199,21 @@ test('dispatchMpvProtocolMessage rejects decimal subtitle track IDs', async () =
|
||||
assert.deepEqual(state.events, [{ sid: null }, { sid: null }, { sid: null }, { sid: null }]);
|
||||
});
|
||||
|
||||
test('dispatchMpvProtocolMessage hides native secondary subtitles after a track change', async () => {
|
||||
const visibilityChanges: boolean[] = [];
|
||||
const { deps, state } = createDeps({
|
||||
setSecondarySubVisibility: (visible) => visibilityChanges.push(visible),
|
||||
});
|
||||
|
||||
await dispatchMpvProtocolMessage(
|
||||
{ event: 'property-change', name: 'secondary-sid', data: '4' },
|
||||
deps,
|
||||
);
|
||||
|
||||
assert.deepEqual(visibilityChanges, [false]);
|
||||
assert.deepEqual(state.events, [{ sid: 4 }]);
|
||||
});
|
||||
|
||||
test('dispatchMpvProtocolMessage enforces sub-visibility hidden when overlay suppression is enabled', async () => {
|
||||
const { deps, state } = createDeps({
|
||||
isVisibleOverlayVisible: () => true,
|
||||
@@ -239,6 +255,24 @@ test('dispatchMpvProtocolMessage skips sub-visibility suppression when overlay i
|
||||
assert.equal(state.commands.length, 0);
|
||||
});
|
||||
|
||||
test('dispatchMpvProtocolMessage corrects native secondary subtitle visibility', async () => {
|
||||
const visibilityChanges: boolean[] = [];
|
||||
const { deps } = createDeps({
|
||||
setSecondarySubVisibility: (visible) => visibilityChanges.push(visible),
|
||||
});
|
||||
|
||||
await dispatchMpvProtocolMessage(
|
||||
{ event: 'property-change', name: 'secondary-sub-visibility', data: 'yes' },
|
||||
deps,
|
||||
);
|
||||
await dispatchMpvProtocolMessage(
|
||||
{ event: 'property-change', name: 'secondary-sub-visibility', data: 'no' },
|
||||
deps,
|
||||
);
|
||||
|
||||
assert.deepEqual(visibilityChanges, [false]);
|
||||
});
|
||||
|
||||
test('dispatchMpvProtocolMessage sets secondary subtitle track based on track list response', async () => {
|
||||
const { deps, state } = createDeps();
|
||||
|
||||
|
||||
@@ -72,6 +72,7 @@ export interface MpvProtocolHandleMessageDeps {
|
||||
emitSubtitleMetricsChange: (payload: Partial<MpvSubtitleRenderMetrics>) => void;
|
||||
setCurrentSecondarySubText: (text: string) => void;
|
||||
resolvePendingRequest: (requestId: number, message: MpvMessage) => boolean;
|
||||
shouldEnforceSecondarySubVisibilityHidden: () => boolean;
|
||||
setSecondarySubVisibility: (visible: boolean) => void;
|
||||
syncCurrentAudioStreamIndex: () => void;
|
||||
setCurrentAudioTrackId: (value: number | null) => void;
|
||||
@@ -285,6 +286,9 @@ export async function dispatchMpvProtocolMessage(
|
||||
: null;
|
||||
deps.emitSubtitleTrackChange({ sid: sid !== null && Number.isInteger(sid) ? sid : null });
|
||||
} else if (msg.name === 'secondary-sid') {
|
||||
if (deps.shouldEnforceSecondarySubVisibilityHidden()) {
|
||||
deps.setSecondarySubVisibility(false);
|
||||
}
|
||||
const sid =
|
||||
typeof msg.data === 'number'
|
||||
? msg.data
|
||||
@@ -375,6 +379,11 @@ export async function dispatchMpvProtocolMessage(
|
||||
if (deps.isVisibleOverlayVisible() && asBoolean(msg.data, false)) {
|
||||
deps.sendCommand({ command: ['set_property', 'sub-visibility', false] });
|
||||
}
|
||||
} else if (msg.name === 'secondary-sub-visibility') {
|
||||
const visible = parseVisibilityProperty(msg.data);
|
||||
if (deps.shouldEnforceSecondarySubVisibilityHidden() && visible === true) {
|
||||
deps.setSecondarySubVisibility(false);
|
||||
}
|
||||
} else if (msg.name === 'sub-use-margins') {
|
||||
deps.emitSubtitleMetricsChange({
|
||||
subUseMargins: asBoolean(msg.data, deps.getSubtitleMetrics().subUseMargins),
|
||||
|
||||
@@ -652,7 +652,7 @@ test('MpvIpcClient captures and disables secondary subtitle visibility on reques
|
||||
]);
|
||||
});
|
||||
|
||||
test('MpvIpcClient restorePreviousSecondarySubVisibility restores and clears tracked value', async () => {
|
||||
test('MpvIpcClient restores secondary subtitle visibility and relinquishes suppression', async () => {
|
||||
const commands: unknown[] = [];
|
||||
const client = new MpvIpcClient('/tmp/mpv.sock', makeDeps());
|
||||
const previous: boolean[] = [];
|
||||
@@ -671,6 +671,12 @@ test('MpvIpcClient restorePreviousSecondarySubVisibility restores and clears tra
|
||||
});
|
||||
client.restorePreviousSecondarySubVisibility();
|
||||
|
||||
await invokeHandleMessage(client, {
|
||||
event: 'property-change',
|
||||
name: 'secondary-sub-visibility',
|
||||
data: 'yes',
|
||||
});
|
||||
|
||||
assert.equal(previous[0], true);
|
||||
assert.equal(previous.length, 1);
|
||||
assert.deepEqual(commands, [
|
||||
@@ -682,8 +688,53 @@ test('MpvIpcClient restorePreviousSecondarySubVisibility restores and clears tra
|
||||
},
|
||||
]);
|
||||
|
||||
await invokeHandleMessage(client, {
|
||||
event: 'property-change',
|
||||
name: 'secondary-sub-visibility',
|
||||
data: 'yes',
|
||||
});
|
||||
assert.equal(commands.length, 2);
|
||||
|
||||
client.restorePreviousSecondarySubVisibility();
|
||||
assert.equal(commands.length, 2);
|
||||
|
||||
const callbacks = (client as any).transport.callbacks;
|
||||
callbacks.onConnect();
|
||||
commands.length = 0;
|
||||
|
||||
await invokeHandleMessage(client, {
|
||||
event: 'property-change',
|
||||
name: 'secondary-sub-visibility',
|
||||
data: 'yes',
|
||||
});
|
||||
assert.deepEqual(commands, [{ command: ['set_property', 'secondary-sub-visibility', 'no'] }]);
|
||||
});
|
||||
|
||||
test('MpvIpcClient keeps secondary subtitle suppression when restoration send fails', async () => {
|
||||
const commands: unknown[] = [];
|
||||
const client = new MpvIpcClient('/tmp/mpv.sock', makeDeps());
|
||||
|
||||
(client as any).send = (payload: unknown) => {
|
||||
commands.push(payload);
|
||||
return false;
|
||||
};
|
||||
|
||||
await invokeHandleMessage(client, {
|
||||
request_id: MPV_REQUEST_ID_SECONDARY_SUB_VISIBILITY,
|
||||
data: 'yes',
|
||||
});
|
||||
client.restorePreviousSecondarySubVisibility();
|
||||
await invokeHandleMessage(client, {
|
||||
event: 'property-change',
|
||||
name: 'secondary-sid',
|
||||
data: 4,
|
||||
});
|
||||
|
||||
assert.deepEqual(commands, [
|
||||
{ command: ['set_property', 'secondary-sub-visibility', 'no'] },
|
||||
{ command: ['set_property', 'secondary-sub-visibility', 'yes'] },
|
||||
{ command: ['set_property', 'secondary-sub-visibility', 'no'] },
|
||||
]);
|
||||
});
|
||||
|
||||
test('MpvIpcClient updates current audio stream index from track list', async () => {
|
||||
|
||||
@@ -184,6 +184,7 @@ export class MpvIpcClient implements MpvClient {
|
||||
osdDimensions: null,
|
||||
};
|
||||
private previousSecondarySubVisibility: boolean | null = null;
|
||||
private enforceSecondarySubVisibilityHidden = true;
|
||||
private playbackPaused: boolean | null = null;
|
||||
private pauseAtTime: number | null = null;
|
||||
private pendingPauseAtSubEnd = false;
|
||||
@@ -199,6 +200,7 @@ export class MpvIpcClient implements MpvClient {
|
||||
socketFactory: deps.socketFactory,
|
||||
connectTimeoutMs: deps.connectTimeoutMs,
|
||||
onConnect: () => {
|
||||
this.enforceSecondarySubVisibilityHidden = true;
|
||||
this.connected = true;
|
||||
this.connecting = false;
|
||||
this.socket = this.transport.getSocket();
|
||||
@@ -476,6 +478,7 @@ export class MpvIpcClient implements MpvClient {
|
||||
},
|
||||
resolvePendingRequest: (requestId: number, message: MpvMessage) =>
|
||||
this.tryResolvePendingRequest(requestId, message),
|
||||
shouldEnforceSecondarySubVisibilityHidden: () => this.enforceSecondarySubVisibilityHidden,
|
||||
setSecondarySubVisibility: (visible: boolean) => this.setSecondarySubVisibility(visible),
|
||||
syncCurrentAudioStreamIndex: () => {
|
||||
this.syncCurrentAudioStreamIndex();
|
||||
@@ -647,9 +650,11 @@ export class MpvIpcClient implements MpvClient {
|
||||
restorePreviousSecondarySubVisibility(): void {
|
||||
const previous = this.previousSecondarySubVisibility;
|
||||
if (previous === null) return;
|
||||
this.send({
|
||||
const restored = this.send({
|
||||
command: ['set_property', 'secondary-sub-visibility', previous ? 'yes' : 'no'],
|
||||
});
|
||||
if (!restored) return;
|
||||
this.enforceSecondarySubVisibilityHidden = false;
|
||||
this.previousSecondarySubVisibility = null;
|
||||
}
|
||||
|
||||
|
||||
@@ -1449,15 +1449,17 @@ test('parseSubtitleCues keeps tall CC-style base dialogue publishable after remo
|
||||
'Dialogue: 0,0:00:06.11,0:00:10.11,Default,,0,0,0,,{\\pos(172,437)\\fscx50}({\\fscx100}立希{\\fscx50})',
|
||||
'Dialogue: 0,0:00:06.11,0:00:10.11,Default,,0,0,0,,{\\pos(332,443)\\fscx50\\fscy50}ともり',
|
||||
'Dialogue: 0,0:00:06.11,0:00:10.11,Default,,0,0,0,,{\\pos(192,497)}お前…{\\fscx50} {\\fscx100}燈をバンドに誘ったの?',
|
||||
// A second labeled turn, so the script reads as broadcast captions.
|
||||
'Dialogue: 0,0:00:10.11,0:00:12.00,Default,,0,0,0,,{\\pos(192,497)\\fscx50}({\\fscx100}燈{\\fscx50}){\\fscx100}うん。',
|
||||
].join('\n');
|
||||
|
||||
const cues = parseSubtitleCues(content, 'test.ass');
|
||||
// The bare speaker label row joins the dialogue row beneath it as one cue.
|
||||
assert.deepEqual(
|
||||
cues.map((cue) => cue.text),
|
||||
['(立希)', 'お前… 燈をバンドに誘ったの?'],
|
||||
['(立希)\nお前… 燈をバンドに誘ったの?', '(燈)うん。'],
|
||||
);
|
||||
assert.deepEqual(cues[0]?.assFurigana, ['たき']);
|
||||
assert.deepEqual(cues[1]?.assFurigana, ['ともり']);
|
||||
assert.deepEqual(cues[0]?.assFurigana, ['たき', 'ともり']);
|
||||
assert.ok(cues.every((cue) => cue.assLayout?.kind === 'positioned'));
|
||||
});
|
||||
|
||||
@@ -1485,14 +1487,184 @@ test('parseSubtitleCues removes half-size positioned furigana from broadcast cap
|
||||
[
|
||||
'(山田)ごめん 結局 ぬれたな。',
|
||||
'大丈夫。',
|
||||
'(山田の母)ほんなら',
|
||||
'隠し貯蔵のミルクまんじゅう➡',
|
||||
'(山田の母)ほんなら\n隠し貯蔵のミルクまんじゅう➡',
|
||||
'絶対違う',
|
||||
],
|
||||
);
|
||||
assert.deepEqual(cues[1]?.assFurigana, ['だいじょうぶ']);
|
||||
assert.deepEqual(cues[3]?.assFurigana, ['かく', 'ちょぞう']);
|
||||
assert.deepEqual(cues[4]?.assFurigana, ['ぜったい ちが']);
|
||||
assert.deepEqual(cues[2]?.assFurigana, ['かく', 'ちょぞう']);
|
||||
assert.deepEqual(cues[3]?.assFurigana, ['ぜったい ちが']);
|
||||
});
|
||||
|
||||
// Broadcast-caption rows from You and I Are Polar Opposites S02E09. Every pair shares
|
||||
// timing, style, and the bottom band; only the text tells a wrap from a second speaker.
|
||||
const captionRowsHeader = ['[Script Info]', 'PlayResY: 540', '', ...eventsHeader];
|
||||
|
||||
function captionRow(start: string, end: string, x: number, y: number, text: string): string {
|
||||
return `Dialogue: 0,${start},${end},Default,,0,0,0,,{\\pos(${x},${y})}${text}`;
|
||||
}
|
||||
|
||||
test('parseSubtitleCues joins caption rows that wrap one sentence across two events', () => {
|
||||
const content = [
|
||||
...captionRowsHeader,
|
||||
captionRow('0:00:19.08', '0:00:22.66', 172, 437, '⸨ぶっちゃけ'),
|
||||
captionRow(
|
||||
'0:00:19.08',
|
||||
'0:00:22.66',
|
||||
172,
|
||||
497,
|
||||
'早く{\\fscx50} {\\fscx100}この勉強生活 終えたいし⸩',
|
||||
),
|
||||
// No bracket at all: the upper row simply has not reached sentence punctuation.
|
||||
captionRow('0:02:42.33', '0:02:44.43', 232, 437, '(東)≪好きだと'),
|
||||
captionRow('0:02:42.33', '0:02:44.43', 232, 497, '自覚してしまったものの➡'),
|
||||
// Rows are centred independently, so a wrap can change x between rows.
|
||||
captionRow('0:00:42.21', '0:00:45.21', 252, 407, '≪ちょっとしたことで'),
|
||||
captionRow('0:00:42.21', '0:00:45.21', 292, 497, '勝手に落ち込んだり➡'),
|
||||
// A quote closed with 」 inside a still-open ≪…≫ span is not the end of the line.
|
||||
captionRow('0:19:02.84', '0:19:05.00', 212, 437, '≪「つきあえる自信がない」'),
|
||||
captionRow('0:19:02.84', '0:19:05.00', 452, 497, 'じゃない≫'),
|
||||
// An in-sentence 「 quote on the lower row is not a new turn.
|
||||
captionRow('0:18:35.55', '0:18:38.00', 232, 437, '今 「好きだ」と'),
|
||||
captionRow('0:18:35.55', '0:18:38.00', 192, 497, '「心地いい」と感じてるのも➡'),
|
||||
].join('\n');
|
||||
|
||||
const cues = parseSubtitleCues(content, 'polar-opposites-s02e09.ass');
|
||||
|
||||
assert.deepEqual(
|
||||
cues.map((cue) => cue.text),
|
||||
[
|
||||
'⸨ぶっちゃけ\n早く この勉強生活 終えたいし⸩',
|
||||
'≪ちょっとしたことで\n勝手に落ち込んだり➡',
|
||||
'(東)≪好きだと\n自覚してしまったものの➡',
|
||||
'今 「好きだ」と\n「心地いい」と感じてるのも➡',
|
||||
'≪「つきあえる自信がない」\nじゃない≫',
|
||||
],
|
||||
);
|
||||
assert.ok(cues.every((cue) => cue.assLayout?.kind === 'positioned'));
|
||||
});
|
||||
|
||||
test('parseSubtitleCues keeps simultaneous caption rows from two speakers separate', () => {
|
||||
const content = [
|
||||
...captionRowsHeader,
|
||||
// Both unlabeled: the upper row finished its sentence.
|
||||
captionRow('0:03:56.10', '0:04:00.04', 172, 437, 'なあ 車両 変えね?'),
|
||||
captionRow('0:03:56.10', '0:04:00.04', 632, 497, 'えっ?➡'),
|
||||
// Lower row opens a labeled turn.
|
||||
captionRow('0:03:38.48', '0:03:42.05', 592, 437, 'おはよう!'),
|
||||
captionRow('0:03:38.48', '0:03:42.05', 272, 497, '(平)あっ 声 でかっ。'),
|
||||
// A closed monologue span above a sound effect.
|
||||
captionRow('0:08:16.83', '0:08:19.50', 372, 437, '≪落ち着け 落ち着け≫'),
|
||||
captionRow('0:08:16.83', '0:08:19.50', 272, 497, 'ドクン ドクン ドクン…'),
|
||||
// Two labeled speakers.
|
||||
captionRow('0:09:27.90', '0:09:31.07', 312, 437, '(平)ぐぅ…。'),
|
||||
captionRow('0:09:27.90', '0:09:31.07', 352, 497, '(東)≪ちくしょう~!≫'),
|
||||
// A bare label never swallows a differently labeled row.
|
||||
captionRow('0:11:43.24', '0:11:45.00', 212, 437, '(長谷川)'),
|
||||
captionRow('0:11:43.24', '0:11:45.00', 412, 497, '(早乙女)ん?'),
|
||||
// A short sentence-final 。 closes the upper row like any other.
|
||||
captionRow('0:12:31.55', '0:12:33.55', 172, 437, '⚞(東)平。'),
|
||||
captionRow('0:12:31.55', '0:12:33.55', 532, 497, 'あっ。'),
|
||||
].join('\n');
|
||||
|
||||
const cues = parseSubtitleCues(content, 'polar-opposites-s02e09.ass');
|
||||
|
||||
assert.deepEqual(
|
||||
cues.map((cue) => cue.text),
|
||||
[
|
||||
'おはよう!',
|
||||
'(平)あっ 声 でかっ。',
|
||||
'なあ 車両 変えね?',
|
||||
'えっ?➡',
|
||||
'≪落ち着け 落ち着け≫',
|
||||
'ドクン ドクン ドクン…',
|
||||
'(平)ぐぅ…。',
|
||||
'(東)≪ちくしょう~!≫',
|
||||
'(長谷川)',
|
||||
'(早乙女)ん?',
|
||||
'⚞(東)平。',
|
||||
'あっ。',
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
test('parseSubtitleCues keeps caption rows apart across styles, bands, and timing', () => {
|
||||
const content = [
|
||||
...captionRowsHeader,
|
||||
// Same wording as a wrap, but the rows sit in different vertical bands.
|
||||
captionRow('0:01:00.00', '0:01:02.00', 172, 77, '≪ちょっとしたことで'),
|
||||
captionRow('0:01:00.00', '0:01:02.00', 172, 497, '勝手に落ち込んだり➡'),
|
||||
// Same band, but a sign style beside dialogue.
|
||||
'Dialogue: 0,0:01:05.00,0:01:07.00,Sign,,0,0,0,,{\\pos(172,437)}ちょっとしたことで',
|
||||
captionRow('0:01:05.00', '0:01:07.00', 172, 497, '勝手に落ち込んだり➡'),
|
||||
// Same rows, but the lower one ends later.
|
||||
captionRow('0:01:10.00', '0:01:12.00', 172, 437, '≪ちょっとしたことで'),
|
||||
captionRow('0:01:10.00', '0:01:13.00', 172, 497, '勝手に落ち込んだり➡'),
|
||||
// Style-aligned rows without \pos are never caption rows.
|
||||
'Dialogue: 0,0:01:15.00,0:01:17.00,Default,,0,0,0,,{\\an8}≪ちょっとしたことで',
|
||||
'Dialogue: 0,0:01:15.00,0:01:17.00,Default,,0,0,0,,{\\an2}勝手に落ち込んだり➡',
|
||||
// Same height: the events sit side by side, not one above the other.
|
||||
captionRow('0:01:20.00', '0:01:22.00', 172, 497, '≪ちょっとしたことで'),
|
||||
captionRow('0:01:20.00', '0:01:22.00', 612, 497, '勝手に落ち込んだり➡'),
|
||||
// Same bottom band, but further apart than two text rows.
|
||||
captionRow('0:01:25.00', '0:01:27.00', 172, 367, '≪ちょっとしたことで'),
|
||||
captionRow('0:01:25.00', '0:01:27.00', 172, 497, '勝手に落ち込んだり➡'),
|
||||
].join('\n');
|
||||
|
||||
const cues = parseSubtitleCues(content, 'test.ass');
|
||||
|
||||
assert.equal(cues.length, 12);
|
||||
assert.ok(cues.every((cue) => !cue.text.includes('\n')));
|
||||
});
|
||||
|
||||
test('parseSubtitleCues leaves typeset rows alone in scripts that are not broadcast captions', () => {
|
||||
// Fansub typesetting stacks positioned rows for signs, chat bubbles, and headlines. Such
|
||||
// text carries no caption punctuation, so without the script-level gate every stacked
|
||||
// pair here would read as an unfinished sentence and merge.
|
||||
const content = [
|
||||
...captionRowsHeader,
|
||||
captionRow('0:00:10.00', '0:00:14.00', 640, 200, 'Shocking Statement Leaves'),
|
||||
captionRow('0:00:10.00', '0:00:14.00', 640, 260, 'Listeners Speechless!'),
|
||||
captionRow('0:01:00.00', '0:01:04.00', 400, 300, 'shes here AGAIN'),
|
||||
captionRow('0:01:00.00', '0:01:04.00', 400, 360, 'make sakiko-chan go home'),
|
||||
// Japanese typesetting in the same script is held back by the same gate.
|
||||
captionRow('0:02:00.00', '0:02:04.00', 300, 400, '定休日'),
|
||||
captionRow('0:02:00.00', '0:02:04.00', 300, 460, '毎週水曜日'),
|
||||
].join('\n');
|
||||
|
||||
const cues = parseSubtitleCues(content, 'test.ass');
|
||||
|
||||
assert.deepEqual(
|
||||
cues.map((cue) => cue.text),
|
||||
[
|
||||
'Shocking Statement Leaves',
|
||||
'Listeners Speechless!',
|
||||
'shes here AGAIN',
|
||||
'make sakiko-chan go home',
|
||||
'定休日',
|
||||
'毎週水曜日',
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
test('parseSubtitleCues never joins caption rows that carry no Japanese', () => {
|
||||
// Even inside a caption script, romaji or English rows are not the wrapped Japanese
|
||||
// sentences this pass targets.
|
||||
const content = [
|
||||
...captionRowsHeader,
|
||||
captionRow('0:00:10.00', '0:00:13.00', 172, 437, '(東)≪好きだと'),
|
||||
captionRow('0:00:10.00', '0:00:13.00', 172, 497, '自覚してしまったものの➡'),
|
||||
captionRow('0:00:20.00', '0:00:23.00', 172, 437, '(平)ん?'),
|
||||
captionRow('0:00:30.00', '0:00:34.00', 640, 437, 'NOW LOADING'),
|
||||
captionRow('0:00:30.00', '0:00:34.00', 640, 497, 'please wait'),
|
||||
].join('\n');
|
||||
|
||||
const cues = parseSubtitleCues(content, 'test.ass');
|
||||
|
||||
assert.deepEqual(
|
||||
cues.map((cue) => cue.text),
|
||||
['(東)≪好きだと\n自覚してしまったものの➡', '(平)ん?', 'NOW LOADING', 'please wait'],
|
||||
);
|
||||
});
|
||||
|
||||
test('parseSubtitleCues scales furigana geometry by PlayResY', () => {
|
||||
|
||||
@@ -2342,6 +2342,185 @@ function removeAssFuriganaEvents(
|
||||
};
|
||||
}
|
||||
|
||||
// Broadcast-caption converters give every visual row of one utterance its own positioned
|
||||
// event, so a sentence that wraps arrives as two simultaneous cues with the same timing,
|
||||
// style, and vertical band. Captions punctuate every finished utterance, and each turn
|
||||
// opens with a speaker label or a ≪…≫ / ⸨…⸩ span, which is what tells a wrapped sentence
|
||||
// apart from two speakers sharing the screen.
|
||||
const CAPTION_SPEAKER_LABEL_ONLY_PATTERN = /^([^()]*)$/u;
|
||||
const CAPTION_SPEAKER_LABEL_PATTERN = /^(/u;
|
||||
const CAPTION_TURN_OPENER_PATTERN = /^[≪⸨(]/u;
|
||||
const CAPTION_TERMINAL_PATTERN = /[。?!?!…‥~〜➡⁉⁈≫⸩)」』]$/u;
|
||||
const CAPTION_SPANS: ReadonlyArray<readonly [open: string, close: string]> = [
|
||||
['≪', '≫'],
|
||||
['⸨', '⸩'],
|
||||
];
|
||||
// Rows of one utterance sit one text row apart (about 60 units in the 540-line space the
|
||||
// furigana geometry is tuned for), or two when a ruby row lies between them. Rows at the
|
||||
// same height sit side by side, and rows further apart are separate placements.
|
||||
const MAX_CAPTION_ROW_GAP = 120;
|
||||
// Only a broadcast-caption script gets rows joined. Typesetters position rows for signs,
|
||||
// chat bubbles, and lyric stacks too, and there the continuation rule below has no
|
||||
// convention to read: sign text rarely carries sentence punctuation, so unrelated rows
|
||||
// would run together. A caption script announces itself by labelling speakers (名) and
|
||||
// bracketing off-screen speech in ≪…≫ / ⸨…⸩; typeset scripts use those in a handful of
|
||||
// lines at most. Measured over local tracks, caption scripts sit near 25% and every typeset
|
||||
// script below 1%, so the threshold has room on both sides. It is deliberately strict: a
|
||||
// caption script wrongly held back just keeps one sentence on two rows, while a typeset
|
||||
// script wrongly let through concatenates unrelated signs.
|
||||
const MIN_CAPTION_EVIDENCE_EVENTS = 2;
|
||||
const MIN_CAPTION_EVIDENCE_RATIO = 0.05;
|
||||
const CAPTION_EVIDENCE_PATTERN = /^([^()]{1,14})|[≪⸨]/u;
|
||||
// Rows that carry no Japanese are not the broadcast captions this pass targets.
|
||||
const JAPANESE_SCRIPT_PATTERN = /[\p{Script=Hiragana}\p{Script=Katakana}\p{Script=Han}]/u;
|
||||
|
||||
function hasBroadcastCaptionConventions(cues: readonly AnnotatedSubtitleCue[]): boolean {
|
||||
let evidence = 0;
|
||||
let published = 0;
|
||||
for (const cue of cues) {
|
||||
if (!cue.text.trim()) continue;
|
||||
published += 1;
|
||||
if (CAPTION_EVIDENCE_PATTERN.test(cue.text)) evidence += 1;
|
||||
}
|
||||
return (
|
||||
evidence >= MIN_CAPTION_EVIDENCE_EVENTS && evidence >= published * MIN_CAPTION_EVIDENCE_RATIO
|
||||
);
|
||||
}
|
||||
|
||||
function captionSpanDepth(text: string, [open, close]: readonly [string, string]): number {
|
||||
let depth = 0;
|
||||
for (const char of text) {
|
||||
if (char === open) depth += 1;
|
||||
else if (char === close) depth -= 1;
|
||||
}
|
||||
return depth;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether `lower` continues the utterance `upper` started, both being simultaneous
|
||||
* caption rows. A bare speaker label labels the row beneath it. Otherwise the upper row
|
||||
* must not have finished: it ends without terminal punctuation, or a ≪…≫ / ⸨…⸩ span it
|
||||
* opened is still open (closing 」 inside such a span is not an ending). A lower row that
|
||||
* opens its own turn is always a different line.
|
||||
*/
|
||||
function isCaptionRowContinuation(upper: string, lower: string): boolean {
|
||||
if (CAPTION_SPEAKER_LABEL_ONLY_PATTERN.test(upper)) {
|
||||
return !CAPTION_SPEAKER_LABEL_PATTERN.test(lower);
|
||||
}
|
||||
if (CAPTION_TURN_OPENER_PATTERN.test(lower)) {
|
||||
return false;
|
||||
}
|
||||
const spanContinues = CAPTION_SPANS.some(
|
||||
(span) => captionSpanDepth(upper, span) > 0 || captionSpanDepth(lower, span) < 0,
|
||||
);
|
||||
return spanContinues || !CAPTION_TERMINAL_PATTERN.test(upper);
|
||||
}
|
||||
|
||||
// A half-height row is ruby or a whispered aside, not a row of the utterance.
|
||||
function isCaptionRowCandidate(cue: AnnotatedSubtitleCue): boolean {
|
||||
const scaleY = staticAssScalePercent(cue, 'fscy');
|
||||
return (
|
||||
cue.source === undefined &&
|
||||
cue.assLayout?.kind === 'positioned' &&
|
||||
cue.effect.trim() === '' &&
|
||||
!cue.text.includes('\n') &&
|
||||
!hasAssTemporalOverride(cue.overrides) &&
|
||||
(scaleY === null || scaleY > MAX_ASS_FURIGANA_SCALE_PERCENT) &&
|
||||
JAPANESE_SCRIPT_PATTERN.test(cue.text)
|
||||
);
|
||||
}
|
||||
|
||||
function captionRowGroupKey(cue: AnnotatedSubtitleCue): string {
|
||||
return [
|
||||
cue.startTime,
|
||||
cue.endTime,
|
||||
cue.style,
|
||||
cue.layer,
|
||||
cue.name,
|
||||
cue.assLayout?.verticalBand ?? '',
|
||||
].join('\0');
|
||||
}
|
||||
|
||||
function mergeCaptionRows(rows: readonly AnnotatedSubtitleCue[]): AnnotatedSubtitleCue {
|
||||
const [first] = rows;
|
||||
if (!first) throw new Error('mergeCaptionRows requires at least one row');
|
||||
const overrides = rows.flatMap((row) => row.overrides);
|
||||
const assFurigana = [...new Set(rows.flatMap((row) => row.assFurigana ?? []))];
|
||||
return {
|
||||
...first,
|
||||
text: rows.map((row) => row.text).join('\n'),
|
||||
rawText: rows.map((row) => row.rawText).join('\\N'),
|
||||
overrides,
|
||||
overrideSignature: assOverrideSignature(overrides),
|
||||
...(assFurigana.length === 0 ? {} : { assFurigana }),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Join simultaneous caption rows that spell one utterance into a single cue, so the
|
||||
* overlay can wrap or flatten it like an authored `\N` line and the sidebar and mining
|
||||
* paths see the whole sentence. Rows stack top to bottom; each row joins the cue above it
|
||||
* only while `isCaptionRowContinuation` holds, so a second speaker starts a new cue. The
|
||||
* whole pass is skipped unless the script reads as broadcast captions.
|
||||
*/
|
||||
function mergeAssCaptionRows(
|
||||
cues: AnnotatedSubtitleCue[],
|
||||
playResY: number | null,
|
||||
): AnnotatedSubtitleCue[] {
|
||||
if (!hasBroadcastCaptionConventions(cues)) return cues;
|
||||
|
||||
const groups = new Map<string, AnnotatedSubtitleCue[]>();
|
||||
for (const cue of cues) {
|
||||
if (!isCaptionRowCandidate(cue)) continue;
|
||||
const key = captionRowGroupKey(cue);
|
||||
const group = groups.get(key);
|
||||
if (group) group.push(cue);
|
||||
else groups.set(key, [cue]);
|
||||
}
|
||||
|
||||
const maxRowGap = MAX_CAPTION_ROW_GAP * assFuriganaGeometryScale(playResY);
|
||||
const rowY = (cue: AnnotatedSubtitleCue): number =>
|
||||
cue.assLayout?.kind === 'positioned' ? cue.assLayout.y : 0;
|
||||
const replacements = new Map<AnnotatedSubtitleCue, AnnotatedSubtitleCue>();
|
||||
const removed = new Set<AnnotatedSubtitleCue>();
|
||||
for (const group of groups.values()) {
|
||||
if (group.length < 2) continue;
|
||||
const rows = [...group].sort((a, b) => rowY(a) - rowY(b) || a.order - b.order);
|
||||
let run: AnnotatedSubtitleCue[] = [];
|
||||
const flush = (): void => {
|
||||
if (run.length < 2) return;
|
||||
const anchor = run.reduce((lowest, row) => (row.order < lowest.order ? row : lowest));
|
||||
replacements.set(anchor, mergeCaptionRows(run));
|
||||
for (const row of run) {
|
||||
if (row !== anchor) removed.add(row);
|
||||
}
|
||||
};
|
||||
for (const row of rows) {
|
||||
const previous = run.at(-1);
|
||||
const gap = previous ? rowY(row) - rowY(previous) : 0;
|
||||
if (
|
||||
previous &&
|
||||
gap > 0 &&
|
||||
gap <= maxRowGap &&
|
||||
previous.text !== row.text &&
|
||||
isCaptionRowContinuation(previous.text, row.text)
|
||||
) {
|
||||
run.push(row);
|
||||
continue;
|
||||
}
|
||||
flush();
|
||||
run = [row];
|
||||
}
|
||||
flush();
|
||||
}
|
||||
|
||||
if (replacements.size === 0) return cues;
|
||||
return cues.flatMap((cue) => {
|
||||
if (removed.has(cue)) return [];
|
||||
return [replacements.get(cue) ?? cue];
|
||||
});
|
||||
}
|
||||
|
||||
function parseAnnotatedAssEvents(content: string, placement: AssPlacementContext): ParsedAssEvents {
|
||||
const cues: AnnotatedSubtitleCue[] = [];
|
||||
const comments: AnnotatedSubtitleCue[] = [];
|
||||
@@ -2476,7 +2655,10 @@ function parseAnnotatedAssCues(content: string): AnnotatedSubtitleCue[] {
|
||||
removeAssFontTextureEvents(parseAnnotatedAssEvents(content, placement)),
|
||||
placement.playResY,
|
||||
);
|
||||
return recoverFragmentOnlyAssLines(recoverCanonicalAssEvents(events));
|
||||
return mergeAssCaptionRows(
|
||||
recoverFragmentOnlyAssLines(recoverCanonicalAssEvents(events)),
|
||||
placement.playResY,
|
||||
);
|
||||
}
|
||||
|
||||
export function parseAssCues(content: string): SubtitleCue[] {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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
@@ -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,
|
||||
@@ -684,7 +682,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;
|
||||
@@ -2489,7 +2486,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({
|
||||
@@ -3133,23 +3129,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) => {
|
||||
@@ -3215,7 +3194,6 @@ const {
|
||||
getActivePlayback: () => activeJellyfinRemotePlayback,
|
||||
clearActivePlayback: () => {
|
||||
activeJellyfinRemotePlayback = null;
|
||||
activeJellyfinSubtitleDelayKey = null;
|
||||
},
|
||||
getSession: () => appState.jellyfinRemoteSession,
|
||||
getNow: () => Date.now(),
|
||||
@@ -4572,7 +4550,6 @@ const {
|
||||
appState.activeParsedSubtitleSource = null;
|
||||
appState.activeParsedSubtitleMediaPath = null;
|
||||
}
|
||||
activeJellyfinSubtitleDelayKey = null;
|
||||
overlayManager.broadcastToOverlayWindows('subtitle:set', resetSubtitlePayload);
|
||||
subtitleWsService.broadcast(resetSubtitlePayload, frequencyOptions);
|
||||
annotationSubtitleWsService.broadcast(resetSubtitlePayload, frequencyOptions);
|
||||
|
||||
@@ -137,6 +137,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,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) {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { parseAssCues } from '../../core/services/subtitle-cue-parser';
|
||||
import { createBuildBindMpvMainEventHandlersMainDepsHandler } from './mpv-main-event-main-deps';
|
||||
|
||||
test('mpv main event main deps map app state updates and delegate callbacks', async () => {
|
||||
@@ -503,14 +504,21 @@ test('canonical ASS cues replace live glyph spam for display, history, and immer
|
||||
assert.deepEqual(timing.slice(3), [{ text: '今 手にある物差しでは', start: 1.2, end: 3.8 }]);
|
||||
assert.equal(immersion.length, 3);
|
||||
|
||||
// A jump of exactly the seek threshold counts as a seek, matching the time-pos
|
||||
// handler's own `>=` boundary.
|
||||
handlers.onTimePosUpdate?.(4.5);
|
||||
handlers.onTimePosUpdate?.(2);
|
||||
// Jumping back to a brief previous line moves time-pos by less than the general
|
||||
// seek threshold. It is still a backward seek, so the revisited line records
|
||||
// again; otherwise multi-line copy would keep treating the later line as current.
|
||||
handlers.onTimePosUpdate?.(3.9);
|
||||
handlers.onTimePosUpdate?.(2.9);
|
||||
handlers.recordSubtitleTiming('今', 0.8, 1.5);
|
||||
|
||||
assert.deepEqual(timing.slice(4), [{ text: '今 手にある物差しでは', start: 1.2, end: 3.8 }]);
|
||||
|
||||
// Tiny time-pos jitter is not a seek and must not re-record the line.
|
||||
handlers.onTimePosUpdate?.(3.0);
|
||||
handlers.onTimePosUpdate?.(2.9);
|
||||
handlers.recordSubtitleTiming('今', 0.8, 1.5);
|
||||
assert.equal(timing.length, 5);
|
||||
|
||||
handlers.recordImmersionSubtitleLine('Maid\nCafe', 10, 12);
|
||||
handlers.recordSubtitleTiming('Maid\nCafe', 10, 12);
|
||||
assert.equal(immersion.length, 3);
|
||||
@@ -575,3 +583,156 @@ test('subtitle-track changes stop stale canonical cues from substituting immedia
|
||||
assert.equal(appState.activeParsedSubtitleSource, null);
|
||||
assert.equal(handlers.resolveSubtitleText?.('今\n手にある'), '今\n手にある');
|
||||
});
|
||||
|
||||
test('subtitle recorders drop ASS furigana events the same way the display does', () => {
|
||||
// Broadcast-caption ASS (Caption2Ass style): furigana are separate half-scale events
|
||||
// positioned above their base line, and mpv lists them as their own live lines.
|
||||
const cues = parseAssCues(
|
||||
[
|
||||
'[Script Info]',
|
||||
'PlayResX: 960',
|
||||
'PlayResY: 540',
|
||||
'',
|
||||
'[V4+ Styles]',
|
||||
'Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding',
|
||||
'Style: Default,Yu Gothic,46,&H00FFFFFF,&H000000FF,&H00000000,&H7F000000,1,0,0,0,100,100,4,0,1,2,2,1,0,0,0,1',
|
||||
'',
|
||||
'[Events]',
|
||||
'Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text',
|
||||
'Dialogue: 0,0:04:56.26,0:04:59.63,Default,,0000,0000,0000,,{\\pos(472,443)\\fscx50\\fscy50}あくむ',
|
||||
'Dialogue: 0,0:04:56.26,0:04:59.63,Default,,0000,0000,0000,,{\\pos(172,497)}こんな短時間で{\\fscx50} {\\fscx100}悪夢{\\fscx50} {\\fscx100}見んなよ{\\fscx50}。',
|
||||
'Dialogue: 0,0:04:59.63,0:05:03.54,Default,,0000,0000,0000,,{\\pos(172,407)\\fscx50}({\\fscx100}平{\\fscx50}){\\fscx100}暗記教科は{\\fscx50} {\\fscx100}もう',
|
||||
'Dialogue: 0,0:04:59.63,0:05:03.54,Default,,0000,0000,0000,,{\\pos(332,443)\\fscx50\\fscy50}かた',
|
||||
'Dialogue: 0,0:04:59.63,0:05:03.54,Default,,0000,0000,0000,,{\\pos(412,443)\\fscx50\\fscy50}ぱし',
|
||||
'Dialogue: 0,0:04:59.63,0:05:03.54,Default,,0000,0000,0000,,{\\pos(172,497)}とにかく片っ端から覚えるんだよ{\\fscx50}。',
|
||||
].join('\n'),
|
||||
);
|
||||
assert.deepEqual(
|
||||
cues.map((cue) => cue.text),
|
||||
[
|
||||
'こんな短時間で 悪夢 見んなよ。',
|
||||
'(平)暗記教科は もう',
|
||||
'とにかく片っ端から覚えるんだよ。',
|
||||
],
|
||||
);
|
||||
|
||||
const immersion: string[] = [];
|
||||
const timing: string[] = [];
|
||||
const handlers = createBuildBindMpvMainEventHandlersMainDepsHandler({
|
||||
appState: {
|
||||
initialArgs: null,
|
||||
overlayRuntimeInitialized: true,
|
||||
mpvClient: { currentTimePos: 299.7 },
|
||||
immersionTracker: { recordSubtitleLine: (text: string) => immersion.push(text) },
|
||||
subtitleTimingTracker: { recordSubtitle: (text: string) => timing.push(text) },
|
||||
activeParsedSubtitleCues: cues,
|
||||
currentSubText: '',
|
||||
currentSubAssText: '',
|
||||
playbackPaused: null,
|
||||
previousSecondarySubVisibility: false,
|
||||
},
|
||||
getQuitOnDisconnectArmed: () => false,
|
||||
scheduleQuitCheck: () => {},
|
||||
quitApp: () => {},
|
||||
reportJellyfinRemoteStopped: () => {},
|
||||
syncOverlayMpvSubtitleSuppression: () => {},
|
||||
maybeRunAnilistPostWatchUpdate: async () => {},
|
||||
logSubtitleTimingError: () => {},
|
||||
broadcastToOverlayWindows: () => {},
|
||||
onSubtitleChange: () => {},
|
||||
ensureImmersionTrackerInitialized: () => {},
|
||||
updateCurrentMediaPath: () => {},
|
||||
restoreMpvSubVisibility: () => {},
|
||||
getCurrentAnilistMediaKey: () => null,
|
||||
resetAnilistMediaTracking: () => {},
|
||||
maybeProbeAnilistDuration: () => {},
|
||||
ensureAnilistMediaGuess: () => {},
|
||||
syncImmersionMediaState: () => {},
|
||||
updateCurrentMediaTitle: () => {},
|
||||
resetAnilistMediaGuessState: () => {},
|
||||
reportJellyfinRemoteProgress: () => {},
|
||||
updateSubtitleRenderMetrics: () => {},
|
||||
refreshDiscordPresence: () => {},
|
||||
})();
|
||||
|
||||
const liveText = '(平)暗記教科は もう\nかた\nぱし\nとにかく片っ端から覚えるんだよ。';
|
||||
const expected = '(平)暗記教科は もう\n\nとにかく片っ端から覚えるんだよ。';
|
||||
assert.equal(handlers.resolveSubtitleText?.(liveText), expected);
|
||||
handlers.recordImmersionSubtitleLine(liveText, 299.63, 303.54);
|
||||
handlers.recordSubtitleTiming(liveText, 299.63, 303.54);
|
||||
|
||||
assert.deepEqual(immersion, [expected]);
|
||||
assert.deepEqual(timing, [expected]);
|
||||
});
|
||||
|
||||
test('a resolved line survives recording while a fragment grid is on screen', () => {
|
||||
// Fragment stripping drops every line it can trace back to a cue, and returns nothing
|
||||
// at all when a fragment grid is nearby. Text the parsed cues already resolved is a
|
||||
// complete line, not raw mpv output, so it must not be fed through that path.
|
||||
const immersion: string[] = [];
|
||||
const timing: string[] = [];
|
||||
const handlers = createBuildBindMpvMainEventHandlersMainDepsHandler({
|
||||
appState: {
|
||||
initialArgs: null,
|
||||
overlayRuntimeInitialized: true,
|
||||
mpvClient: { currentTimePos: 3.2 },
|
||||
immersionTracker: { recordSubtitleLine: (text: string) => immersion.push(text) },
|
||||
subtitleTimingTracker: { recordSubtitle: (text: string) => timing.push(text) },
|
||||
activeParsedSubtitleCues: [
|
||||
{ startTime: 3, endTime: 6, text: '飛び越えてみたくて', source: 'canonical-ass' },
|
||||
{
|
||||
startTime: 3,
|
||||
endTime: 6,
|
||||
text: 'MaidCafeMaidCafe',
|
||||
source: 'reconstructed-ass',
|
||||
assLayout: { kind: 'fragment-grid', sourceOrder: 2 },
|
||||
},
|
||||
],
|
||||
currentSubText: '',
|
||||
currentSubAssText: '',
|
||||
playbackPaused: null,
|
||||
previousSecondarySubVisibility: false,
|
||||
},
|
||||
getQuitOnDisconnectArmed: () => false,
|
||||
scheduleQuitCheck: () => {},
|
||||
quitApp: () => {},
|
||||
reportJellyfinRemoteStopped: () => {},
|
||||
syncOverlayMpvSubtitleSuppression: () => {},
|
||||
maybeRunAnilistPostWatchUpdate: async () => {},
|
||||
logSubtitleTimingError: () => {},
|
||||
broadcastToOverlayWindows: () => {},
|
||||
onSubtitleChange: () => {},
|
||||
ensureImmersionTrackerInitialized: () => {},
|
||||
updateCurrentMediaPath: () => {},
|
||||
restoreMpvSubVisibility: () => {},
|
||||
getCurrentAnilistMediaKey: () => null,
|
||||
resetAnilistMediaTracking: () => {},
|
||||
maybeProbeAnilistDuration: () => {},
|
||||
ensureAnilistMediaGuess: () => {},
|
||||
syncImmersionMediaState: () => {},
|
||||
updateCurrentMediaTitle: () => {},
|
||||
resetAnilistMediaGuessState: () => {},
|
||||
reportJellyfinRemoteProgress: () => {},
|
||||
updateSubtitleRenderMetrics: () => {},
|
||||
refreshDiscordPresence: () => {},
|
||||
})();
|
||||
|
||||
// The grid fragment beside the lyric keeps canonical substitution from applying, so
|
||||
// recording falls to the parsed view -- which is where the whole line is recovered.
|
||||
const liveText = '飛び越え\nMaid';
|
||||
assert.equal(handlers.resolveSubtitleText?.(liveText), '飛び越えてみたくて');
|
||||
handlers.recordImmersionSubtitleLine(liveText, 3, 6);
|
||||
handlers.recordSubtitleTiming(liveText, 3, 6);
|
||||
|
||||
assert.deepEqual(immersion, ['飛び越えてみたくて']);
|
||||
assert.deepEqual(timing, ['飛び越えてみたくて']);
|
||||
|
||||
// A spacer event left as literal control debris is not a subtitle line. The display
|
||||
// drops it, so no recorder may keep it either.
|
||||
assert.equal(handlers.resolveSubtitleText?.('\\'), '');
|
||||
handlers.recordImmersionSubtitleLine('\\', 3, 6);
|
||||
handlers.recordSubtitleTiming('\\', 3, 6);
|
||||
|
||||
assert.equal(immersion.length, 1);
|
||||
assert.equal(timing.length, 1);
|
||||
});
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import { createSubtitleLineDedupGate } from '../../core/services/subtitle-line-dedup-gate';
|
||||
import type { MergedToken, SubtitleCue, SubtitleData } from '../../types';
|
||||
import { SEEK_LIKE_TIME_DELTA_SECONDS } from './mpv-main-event-actions';
|
||||
import {
|
||||
resolveCanonicalPrimarySubtitle,
|
||||
resolvePrimarySubtitleText,
|
||||
stripCanonicalFragmentLines,
|
||||
resolveRecordedPrimarySubtitleText,
|
||||
} from './primary-subtitle-text';
|
||||
|
||||
type AnilistPostWatchRunOptions = {
|
||||
@@ -115,6 +114,8 @@ export function createBuildBindMpvMainEventHandlersMainDepsHandler(deps: {
|
||||
// after the change is dropped instead of landing in the next session.
|
||||
let subtitleSessionEpoch = 0;
|
||||
let lastTimePosForTimingReset: number | null = null;
|
||||
// Small margin so time-pos jitter is not mistaken for a backward seek.
|
||||
const BACKWARD_SEEK_TIMING_RESET_SECONDS = 0.25;
|
||||
const canonicalCueKey = (cue: SubtitleCue): string =>
|
||||
`${cue.startTime}|${cue.endTime}|${cue.text}`;
|
||||
const resetSubtitleDeduplication = (): void => {
|
||||
@@ -130,10 +131,12 @@ export function createBuildBindMpvMainEventHandlersMainDepsHandler(deps: {
|
||||
currentTimeSec: startSec,
|
||||
cues: deps.appState.activeParsedSubtitleCues,
|
||||
});
|
||||
// When substitution declined because dialogue shares the screen with a song, record
|
||||
// the dialogue alone rather than the combined dialogue-plus-fragments stack.
|
||||
const stripFragmentsForRecording = (liveText: string, startSec: number) =>
|
||||
stripCanonicalFragmentLines({
|
||||
// Recorders see the same text the overlay displays: mpv's live `sub-text` lists every
|
||||
// simultaneously active ASS event, including furigana events the parser folded into
|
||||
// their base line. Cues the caller already resolved canonically are recorded one by
|
||||
// one above; everything else goes through the shared recording resolution.
|
||||
const resolveTextForRecording = (liveText: string, startSec: number): string =>
|
||||
resolveRecordedPrimarySubtitleText({
|
||||
liveText,
|
||||
currentTimeSec: startSec,
|
||||
cues: deps.appState.activeParsedSubtitleCues,
|
||||
@@ -217,7 +220,7 @@ export function createBuildBindMpvMainEventHandlersMainDepsHandler(deps: {
|
||||
}
|
||||
return;
|
||||
}
|
||||
text = stripFragmentsForRecording(text, start);
|
||||
text = resolveTextForRecording(text, start);
|
||||
if (!text.trim()) {
|
||||
return;
|
||||
}
|
||||
@@ -231,7 +234,7 @@ export function createBuildBindMpvMainEventHandlersMainDepsHandler(deps: {
|
||||
const secondaryText = deps.appState.mpvClient?.currentSecondarySubText || undefined;
|
||||
const canonical = resolveCanonicalSample(text, start);
|
||||
if (!canonical) {
|
||||
const recordableText = stripFragmentsForRecording(text, start);
|
||||
const recordableText = resolveTextForRecording(text, start);
|
||||
if (!recordableText.trim()) {
|
||||
return;
|
||||
}
|
||||
@@ -344,13 +347,15 @@ export function createBuildBindMpvMainEventHandlersMainDepsHandler(deps: {
|
||||
deps.reportJellyfinRemoteProgress(forceImmediate),
|
||||
consumeExplicitSeek: deps.consumeExplicitSeek,
|
||||
onTimePosUpdate: (time: number) => {
|
||||
// Timing history is a viewing log: after a real backward seek, a rewatched
|
||||
// canonical line should enter it again. Immersion stats keep their
|
||||
// once-per-media deduplication and are not reset here.
|
||||
// Timing history is a viewing log: after any backward seek, a rewatched canonical
|
||||
// line should enter it again so multi-line copy treats it as the current line.
|
||||
// Playback never moves time-pos backward on its own, so even a short jump to a
|
||||
// brief previous line counts. Immersion stats keep their once-per-media
|
||||
// deduplication and are not reset here.
|
||||
if (
|
||||
Number.isFinite(time) &&
|
||||
lastTimePosForTimingReset !== null &&
|
||||
time <= lastTimePosForTimingReset - SEEK_LIKE_TIME_DELTA_SECONDS
|
||||
time <= lastTimePosForTimingReset - BACKWARD_SEEK_TIMING_RESET_SECONDS
|
||||
) {
|
||||
recordedTimingCanonicalKeys.clear();
|
||||
}
|
||||
|
||||
@@ -674,3 +674,31 @@ test('resolvePrimarySubtitleText keeps source order when no cue declares a place
|
||||
'First line\n\nSecond line',
|
||||
);
|
||||
});
|
||||
|
||||
test('resolvePrimarySubtitleText publishes a wrapped caption sentence as one cue', () => {
|
||||
// mpv still reports the two source rows (plus the ruby row) as separate live lines, so
|
||||
// the merged cue must explain all of them and come back as a single-break line the
|
||||
// display layer may flatten, not as a two-cue boundary.
|
||||
const ass = [
|
||||
'[Script Info]',
|
||||
'PlayResY: 540',
|
||||
'',
|
||||
'[Events]',
|
||||
'Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text',
|
||||
'Dialogue: 0,0:02:42.33,0:02:44.43,Default,,0,0,0,,{\\pos(232,437)\\fscx50}({\\fscx100}東{\\fscx50}){\\fscx100}≪好きだと',
|
||||
'Dialogue: 0,0:02:42.33,0:02:44.43,Default,,0,0,0,,{\\pos(292,443)\\fscx50\\fscy50}じかく',
|
||||
'Dialogue: 0,0:02:42.33,0:02:44.43,Default,,0,0,0,,{\\pos(232,497)}自覚してしまったものの➡',
|
||||
// A second labeled turn, so the script reads as broadcast captions.
|
||||
'Dialogue: 0,0:02:44.43,0:02:47.37,Default,,0,0,0,,{\\pos(212,497)\\fscx50}({\\fscx100}平{\\fscx50}){\\fscx100}どうした?',
|
||||
].join('\n');
|
||||
const cues = parseSubtitleCues(ass, 'polar-opposites-s02e09.ass');
|
||||
|
||||
assert.equal(
|
||||
resolvePrimarySubtitleText({
|
||||
liveText: '(東)≪好きだと\nじかく\n自覚してしまったものの➡',
|
||||
currentTimeSec: 163,
|
||||
cues,
|
||||
}),
|
||||
'(東)≪好きだと\n自覚してしまったものの➡',
|
||||
);
|
||||
});
|
||||
|
||||
@@ -26,6 +26,13 @@ function cuesUseAssSyntax(cues: readonly SubtitleCue[] | null | undefined): bool
|
||||
);
|
||||
}
|
||||
|
||||
function decodedLiveText(
|
||||
liveText: string,
|
||||
cues: readonly SubtitleCue[] | null | undefined,
|
||||
): string {
|
||||
return cuesUseAssSyntax(cues) ? removeAssControlDebrisLines(liveText) : liveText;
|
||||
}
|
||||
|
||||
function animationSpan(cue: SubtitleCue): { start: number; end: number } {
|
||||
return {
|
||||
start: cue.animationStartTime ?? cue.startTime,
|
||||
@@ -290,14 +297,34 @@ export function stripCanonicalFragmentLines(options: {
|
||||
return removeLiveGlyphFragmentLines(options.liveText);
|
||||
}
|
||||
|
||||
/**
|
||||
* Recording text for a live sample. Callers substitute canonical cues themselves and
|
||||
* record those cue by cue, so what is resolved here is the parsed view -- the one that
|
||||
* folds ASS furigana events back into their base line. Its text is already a complete
|
||||
* line, while fragment stripping takes raw mpv text and would discard a resolved line
|
||||
* whole while a fragment grid is on screen, so only one of the two ever runs.
|
||||
*/
|
||||
export function resolveRecordedPrimarySubtitleText(options: {
|
||||
liveText: string;
|
||||
currentTimeSec: number;
|
||||
cues: readonly SubtitleCue[] | null | undefined;
|
||||
}): string {
|
||||
const liveText = decodedLiveText(options.liveText, options.cues);
|
||||
if (!liveText.trim()) {
|
||||
return liveText;
|
||||
}
|
||||
return (
|
||||
resolveActiveParsedPrimarySubtitle({ ...options, liveText })?.text ??
|
||||
stripCanonicalFragmentLines({ ...options, liveText })
|
||||
);
|
||||
}
|
||||
|
||||
export function resolvePrimarySubtitleText(options: {
|
||||
liveText: string;
|
||||
currentTimeSec: number;
|
||||
cues: readonly SubtitleCue[] | null | undefined;
|
||||
}): string {
|
||||
const liveText = cuesUseAssSyntax(options.cues)
|
||||
? removeAssControlDebrisLines(options.liveText)
|
||||
: options.liveText;
|
||||
const liveText = decodedLiveText(options.liveText, options.cues);
|
||||
if (!liveText.trim()) {
|
||||
return liveText;
|
||||
}
|
||||
|
||||
@@ -499,8 +499,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">
|
||||
|
||||
@@ -60,6 +60,7 @@ const RUNTIME_OPTION_IDS: RuntimeOptionId[] = [
|
||||
'subtitle.annotation.jlpt',
|
||||
'subtitle.annotation.frequency',
|
||||
'anki.kikuFieldGrouping',
|
||||
'anki.senrenFieldGrouping',
|
||||
'anki.nPlusOneMatchMode',
|
||||
];
|
||||
|
||||
|
||||
@@ -67,8 +67,13 @@ export class SubtitleTimingTracker {
|
||||
|
||||
// Check for duplicate of most recent entry (deduplicate adjacent repeats)
|
||||
const lastEntry = this.history[this.history.length - 1];
|
||||
if (lastEntry && lastEntry.timingKey === timingKey) {
|
||||
// Update timing to most recent occurrence
|
||||
if (
|
||||
lastEntry &&
|
||||
lastEntry.timingKey === timingKey &&
|
||||
lastEntry.startTime === startTime &&
|
||||
lastEntry.endTime === endTime
|
||||
) {
|
||||
// Refresh metadata for repeated notifications of the same subtitle event.
|
||||
lastEntry.startTime = startTime;
|
||||
lastEntry.endTime = endTime;
|
||||
lastEntry.secondaryText = displaySecondaryText;
|
||||
@@ -107,28 +112,20 @@ export class SubtitleTimingTracker {
|
||||
}
|
||||
|
||||
/**
|
||||
* Get recent subtitle blocks in chronological order.
|
||||
* Returns the last `count` subtitle events (oldest → newest).
|
||||
* Get recent subtitle blocks in timeline order.
|
||||
* Returns up to `count` known subtitle events ending at the current event.
|
||||
* Blocks preserve internal line breaks and are joined with blank lines.
|
||||
*/
|
||||
getRecentBlocks(count: number): string[] {
|
||||
if (count <= 0) return [];
|
||||
if (count > this.history.length) {
|
||||
count = this.history.length;
|
||||
}
|
||||
return this.history.slice(-count).map((entry) => entry.displayText);
|
||||
return this.getRecentTimelineEntries(count).map((entry) => entry.displayText);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get recent subtitle blocks with their original event timings.
|
||||
* Returns the last `count` subtitle events (oldest → newest).
|
||||
* Get recent subtitle blocks with their original event timings in timeline order.
|
||||
* Returns up to `count` known subtitle events ending at the current event.
|
||||
*/
|
||||
getRecentEntries(count: number): SubtitleTimingBlock[] {
|
||||
if (count <= 0) return [];
|
||||
if (count > this.history.length) {
|
||||
count = this.history.length;
|
||||
}
|
||||
return this.history.slice(-count).map((entry) => ({
|
||||
return this.getRecentTimelineEntries(count).map((entry) => ({
|
||||
displayText: entry.displayText,
|
||||
startTime: entry.startTime,
|
||||
endTime: entry.endTime,
|
||||
@@ -144,6 +141,43 @@ export class SubtitleTimingTracker {
|
||||
return lastEntry ? lastEntry.displayText : null;
|
||||
}
|
||||
|
||||
private getRecentTimelineEntries(count: number): HistoryEntry[] {
|
||||
if (count <= 0) return [];
|
||||
|
||||
const currentEntry = this.history[this.history.length - 1];
|
||||
if (!currentEntry) return [];
|
||||
|
||||
const timelineEntries: HistoryEntry[] = [];
|
||||
for (const entry of this.history) {
|
||||
const existingIndex = timelineEntries.findIndex((candidate) =>
|
||||
this.isSameSubtitleEvent(candidate, entry),
|
||||
);
|
||||
if (existingIndex === -1) {
|
||||
timelineEntries.push(entry);
|
||||
} else {
|
||||
timelineEntries[existingIndex] = entry;
|
||||
}
|
||||
}
|
||||
|
||||
timelineEntries.sort(
|
||||
(left, right) => left.startTime - right.startTime || left.endTime - right.endTime,
|
||||
);
|
||||
const currentIndex = timelineEntries.findIndex((entry) =>
|
||||
this.isSameSubtitleEvent(entry, currentEntry),
|
||||
);
|
||||
if (currentIndex === -1) return [];
|
||||
|
||||
return timelineEntries.slice(Math.max(0, currentIndex - count + 1), currentIndex + 1);
|
||||
}
|
||||
|
||||
private isSameSubtitleEvent(left: HistoryEntry, right: HistoryEntry): boolean {
|
||||
return (
|
||||
left.timingKey === right.timingKey &&
|
||||
left.startTime === right.startTime &&
|
||||
left.endTime === right.endTime
|
||||
);
|
||||
}
|
||||
|
||||
private findFuzzyMatch(text: string): { startTime: number; endTime: number } | null {
|
||||
let bestMatch: TimingEntry | null = null;
|
||||
let bestScore = 0;
|
||||
|
||||
@@ -195,6 +195,11 @@ export interface AnkiConnectConfig {
|
||||
fieldGrouping?: 'auto' | 'manual' | 'disabled';
|
||||
deleteDuplicateInAuto?: boolean;
|
||||
};
|
||||
isSenren?: {
|
||||
enabled?: boolean;
|
||||
fieldGrouping?: 'auto' | 'manual' | 'disabled';
|
||||
deleteDuplicateInAuto?: boolean;
|
||||
};
|
||||
lapisKiku?: {
|
||||
wordCardKind?: WordCardKind;
|
||||
};
|
||||
|
||||
@@ -286,6 +286,11 @@ export interface ResolvedConfig {
|
||||
fieldGrouping: 'auto' | 'manual' | 'disabled';
|
||||
deleteDuplicateInAuto: boolean;
|
||||
};
|
||||
isSenren: {
|
||||
enabled: boolean;
|
||||
fieldGrouping: 'auto' | 'manual' | 'disabled';
|
||||
deleteDuplicateInAuto: boolean;
|
||||
};
|
||||
lapisKiku: {
|
||||
wordCardKind: WordCardKind;
|
||||
};
|
||||
|
||||
@@ -7,6 +7,7 @@ export type RuntimeOptionId =
|
||||
| 'subtitle.annotation.jlpt'
|
||||
| 'subtitle.annotation.frequency'
|
||||
| 'anki.kikuFieldGrouping'
|
||||
| 'anki.senrenFieldGrouping'
|
||||
| 'anki.nPlusOneMatchMode';
|
||||
|
||||
export type RuntimeOptionScope = 'ankiConnect' | 'subtitle';
|
||||
|
||||
Reference in New Issue
Block a user