mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-08-05 07:21:34 -07:00
Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
a0dde4ee3e
|
|||
| fe4dacc1e7 | |||
| b08cd0db35 | |||
| bffb1c5982 | |||
| 5b8848518a | |||
|
176edd67f1
|
|||
| 4d65dec340 | |||
|
6607c333bc
|
|||
|
b2bbf1ae12
|
|||
| b204d4dd6e | |||
| 89ed675935 |
@@ -1,5 +1,32 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## v0.19.2 (2026-08-04)
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- Subsync: The sync modal now lets you choose both the reference subtitle (correct timing) and the out-of-sync subtitle to retime, for both alass and ffsubsync. alass can also use the loaded video's audio as a reference for local files. Retiming the secondary track now reloads the result into the secondary slot instead of overwriting the primary subtitle.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Streaming Subtitle Tokenization: Jellyfin streams now seed subtitle tokenization directly from the downloaded subtitle file instead of relying on an mpv event that could be missed, and prefetching now runs to the end of the file and clears between episodes. The tokenization cache was raised from 256 to 2500 lines, and parsed cues are no longer lost when the active subtitle track briefly can't be resolved (e.g. switching to an embedded track). Together these prevent episodes from falling back to slow, line-by-line tokenization during playback.
|
||||||
|
- Overlay: Subtitle lines now appear immediately at their cue time even on a tokenization cache miss, upgrading in place once tokens and annotations are ready, instead of waiting on a line still being processed. A failed tokenization is no longer cached as plain text, so repeated lines get another chance at annotations.
|
||||||
|
- Background Logging: Background startup now respects the configured logging level when no explicit log level is passed.
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>Internal changes</summary>
|
||||||
|
|
||||||
|
### Internal
|
||||||
|
- Patched three high-severity dependency advisories (`undici`, `brace-expansion`, `fast-uri`).
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
## v0.19.1 (2026-08-01)
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Word Card Type: Adds a setting (Settings > Mining/Anki > Kiku/Lapis Features > "Word Card Type") to choose which card-type flag SubMiner marks on Kiku/Lapis word cards — `word-and-sentence` (default), `click`, `sentence`, `audio`, or `none`. Click cards (`IsClickCard`) can now be flagged, and setting any card-type flag clears the others so a note can't claim two types at once.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Yomitan Popup: Fixes the macOS Yomitan popup going inert after mining a card — clicks outside the popup no longer pass through to mpv, and scrolling over the popup scrolls its definitions instead of seeking playback.
|
||||||
|
- YouTube Playlist Links: Fixes opening a video from a playlist URL (e.g. a Watch Later link with `list=`/`index=`) timing out while probing subtitles, metadata, or the playback URL.
|
||||||
|
|
||||||
## v0.19.0 (2026-07-29)
|
## v0.19.0 (2026-07-29)
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
@@ -26,7 +26,7 @@
|
|||||||
"eslint": "^10.8.0",
|
"eslint": "^10.8.0",
|
||||||
"prettier": "^3.8.1",
|
"prettier": "^3.8.1",
|
||||||
"typescript": "^5.9.3",
|
"typescript": "^5.9.3",
|
||||||
"undici": "7.28.0",
|
"undici": "7.29.0",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -36,8 +36,9 @@
|
|||||||
"overrides": {
|
"overrides": {
|
||||||
"@xmldom/xmldom": "0.8.13",
|
"@xmldom/xmldom": "0.8.13",
|
||||||
"app-builder-lib": "26.15.3",
|
"app-builder-lib": "26.15.3",
|
||||||
"brace-expansion": "5.0.8",
|
"brace-expansion": "5.0.9",
|
||||||
"electron-builder-squirrel-windows": "26.15.3",
|
"electron-builder-squirrel-windows": "26.15.3",
|
||||||
|
"fast-uri": "3.1.5",
|
||||||
"form-data": "4.0.6",
|
"form-data": "4.0.6",
|
||||||
"ip-address": "10.2.0",
|
"ip-address": "10.2.0",
|
||||||
"js-yaml": "4.3.0",
|
"js-yaml": "4.3.0",
|
||||||
@@ -46,6 +47,7 @@
|
|||||||
"picomatch": "4.0.4",
|
"picomatch": "4.0.4",
|
||||||
"tar": "7.5.21",
|
"tar": "7.5.21",
|
||||||
"tmp": "0.2.7",
|
"tmp": "0.2.7",
|
||||||
|
"undici": "7.29.0",
|
||||||
},
|
},
|
||||||
"packages": {
|
"packages": {
|
||||||
"@discordjs/collection": ["@discordjs/collection@2.1.1", "", {}, "sha512-LiSusze9Tc7qF03sLCujF5iZp7K+vRNEDBZ86FT9aQAv3vxMLihUvKvpsCWiQ2DJq1tVckopKm1rxomgNUc9hg=="],
|
"@discordjs/collection": ["@discordjs/collection@2.1.1", "", {}, "sha512-LiSusze9Tc7qF03sLCujF5iZp7K+vRNEDBZ86FT9aQAv3vxMLihUvKvpsCWiQ2DJq1tVckopKm1rxomgNUc9hg=="],
|
||||||
@@ -266,7 +268,7 @@
|
|||||||
|
|
||||||
"boolean": ["boolean@3.2.0", "", {}, "sha512-d0II/GO9uf9lfUHH2BQsjxzRJZBdsjgsBiW4BvhWk/3qoKwQFjIDVN19PfX8F2D/r9PCMTtLWjYVCFrpeYUzsw=="],
|
"boolean": ["boolean@3.2.0", "", {}, "sha512-d0II/GO9uf9lfUHH2BQsjxzRJZBdsjgsBiW4BvhWk/3qoKwQFjIDVN19PfX8F2D/r9PCMTtLWjYVCFrpeYUzsw=="],
|
||||||
|
|
||||||
"brace-expansion": ["brace-expansion@5.0.8", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-JZyDyq3D4AUifKTPOB7DELf6XsB3WdPuNxCtob1vFXPsSXhdAiHBWJ/tJ8HAc9aH84BK+5JFZLNkJKx3G9kzQg=="],
|
"brace-expansion": ["brace-expansion@5.0.9", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg=="],
|
||||||
|
|
||||||
"buffer-from": ["buffer-from@1.1.2", "", {}, "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="],
|
"buffer-from": ["buffer-from@1.1.2", "", {}, "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="],
|
||||||
|
|
||||||
@@ -404,7 +406,7 @@
|
|||||||
|
|
||||||
"fast-levenshtein": ["fast-levenshtein@2.0.6", "", {}, "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw=="],
|
"fast-levenshtein": ["fast-levenshtein@2.0.6", "", {}, "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw=="],
|
||||||
|
|
||||||
"fast-uri": ["fast-uri@3.1.4", "", {}, "sha512-8JnbkQ4juDyvYs4mgFGQqg4yCYtFDtUtmp2QIQq11ZZe5CFQ5wcqm1rqDgAh/QdMySuBnPzMUiJUNZG5N/AiQw=="],
|
"fast-uri": ["fast-uri@3.1.5", "", {}, "sha512-gHwA1O9LDIcKunMKhObS/HimwtehO1nPUECKAu5TpKgaO19fcWEl4bliWe1jWxVFvIXztJjjQ4L8XQ1EU9f7Jw=="],
|
||||||
|
|
||||||
"fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
|
"fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
|
||||||
|
|
||||||
@@ -714,7 +716,7 @@
|
|||||||
|
|
||||||
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
|
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
|
||||||
|
|
||||||
"undici": ["undici@7.28.0", "", {}, "sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA=="],
|
"undici": ["undici@7.29.0", "", {}, "sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw=="],
|
||||||
|
|
||||||
"undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="],
|
"undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="],
|
||||||
|
|
||||||
@@ -754,8 +756,6 @@
|
|||||||
|
|
||||||
"@discordjs/rest/discord-api-types": ["discord-api-types@0.38.43", "", {}, "sha512-sSoBf/nK6m7BGtw65mi+QBuvEWaHE8MMziFLqWL+gT6ME/BLg34dRSVKS3Husx40uU06bvxUc3/X+D9Y6/zAbw=="],
|
"@discordjs/rest/discord-api-types": ["discord-api-types@0.38.43", "", {}, "sha512-sSoBf/nK6m7BGtw65mi+QBuvEWaHE8MMziFLqWL+gT6ME/BLg34dRSVKS3Husx40uU06bvxUc3/X+D9Y6/zAbw=="],
|
||||||
|
|
||||||
"@discordjs/rest/undici": ["undici@6.27.0", "", {}, "sha512-YmfV3YnEDzXRC5lZ2jWtWWHKGUm1zIt8AhesR1tens+HTNv+YZlN/dp6G727LOvMJ8xjP9Be7Y2Sdr96LDm+pg=="],
|
|
||||||
|
|
||||||
"@discordjs/util/discord-api-types": ["discord-api-types@0.38.43", "", {}, "sha512-sSoBf/nK6m7BGtw65mi+QBuvEWaHE8MMziFLqWL+gT6ME/BLg34dRSVKS3Husx40uU06bvxUc3/X+D9Y6/zAbw=="],
|
"@discordjs/util/discord-api-types": ["discord-api-types@0.38.43", "", {}, "sha512-sSoBf/nK6m7BGtw65mi+QBuvEWaHE8MMziFLqWL+gT6ME/BLg34dRSVKS3Husx40uU06bvxUc3/X+D9Y6/zAbw=="],
|
||||||
|
|
||||||
"@electron/asar/commander": ["commander@5.1.0", "", {}, "sha512-P0CysNDQ7rtVw4QIQtm+MRxV66vKFSvlsQvGYXZWR3qFU0jlMKHZZZgw8e+8DSah4UDKMqnknRDQz+xuQXQ/Zg=="],
|
"@electron/asar/commander": ["commander@5.1.0", "", {}, "sha512-P0CysNDQ7rtVw4QIQtm+MRxV66vKFSvlsQvGYXZWR3qFU0jlMKHZZZgw8e+8DSah4UDKMqnknRDQz+xuQXQ/Zg=="],
|
||||||
@@ -808,8 +808,6 @@
|
|||||||
|
|
||||||
"node-gyp/env-paths": ["env-paths@2.2.1", "", {}, "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A=="],
|
"node-gyp/env-paths": ["env-paths@2.2.1", "", {}, "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A=="],
|
||||||
|
|
||||||
"node-gyp/undici": ["undici@6.27.0", "", {}, "sha512-YmfV3YnEDzXRC5lZ2jWtWWHKGUm1zIt8AhesR1tens+HTNv+YZlN/dp6G727LOvMJ8xjP9Be7Y2Sdr96LDm+pg=="],
|
|
||||||
|
|
||||||
"node-gyp/which": ["which@6.0.1", "", { "dependencies": { "isexe": "^4.0.0" }, "bin": { "node-which": "bin/which.js" } }, "sha512-oGLe46MIrCRqX7ytPUf66EAYvdeMIZYn3WaocqqKZAxrBpkqHfL/qvTyJ/bTk5+AqHCjXmrv3CEWgy368zhRUg=="],
|
"node-gyp/which": ["which@6.0.1", "", { "dependencies": { "isexe": "^4.0.0" }, "bin": { "node-which": "bin/which.js" } }, "sha512-oGLe46MIrCRqX7ytPUf66EAYvdeMIZYn3WaocqqKZAxrBpkqHfL/qvTyJ/bTk5+AqHCjXmrv3CEWgy368zhRUg=="],
|
||||||
|
|
||||||
"pkijs/@noble/hashes": ["@noble/hashes@1.4.0", "", {}, "sha512-V1JJ1WTRUqHHrOSh597hURcMqVKVGL/ea3kv0gSnEdsEZ0/+VyPghM1lMNGc00z7CIQorSvbKpuJkxvuHbvdbg=="],
|
"pkijs/@noble/hashes": ["@noble/hashes@1.4.0", "", {}, "sha512-V1JJ1WTRUqHHrOSh597hURcMqVKVGL/ea3kv0gSnEdsEZ0/+VyPghM1lMNGc00z7CIQorSvbKpuJkxvuHbvdbg=="],
|
||||||
|
|||||||
@@ -523,7 +523,7 @@
|
|||||||
// ==========================================
|
// ==========================================
|
||||||
// AnkiConnect Integration
|
// AnkiConnect Integration
|
||||||
// Automatic Anki updates and media generation options.
|
// Automatic Anki updates and media generation options.
|
||||||
// Hot-reload: ankiConnect.ai.enabled, media.normalizeAudio/mirrorMpvVolume, knownWords, nPlusOne, fields.word/audio/image/sentence/miscInfo, behavior.autoUpdateNewCards, isLapis.sentenceCardModel, and isKiku.fieldGrouping update live while SubMiner is running.
|
// Hot-reload: ankiConnect.ai.enabled, media.normalizeAudio/mirrorMpvVolume, knownWords, nPlusOne, fields.word/audio/image/sentence/miscInfo, behavior.autoUpdateNewCards, isLapis.sentenceCardModel, isKiku.fieldGrouping, and lapisKiku.wordCardKind update live while SubMiner is running.
|
||||||
// Shared AI provider transport settings are read from top-level ai and typically require restart.
|
// Shared AI provider transport settings are read from top-level ai and typically require restart.
|
||||||
// Most other AnkiConnect settings still require restart.
|
// Most other AnkiConnect settings still require restart.
|
||||||
// ==========================================
|
// ==========================================
|
||||||
@@ -605,7 +605,10 @@
|
|||||||
"enabled": false, // Enable Kiku-specific mining behaviors (duplicate handling, field grouping). Values: true | false
|
"enabled": false, // Enable Kiku-specific mining behaviors (duplicate handling, field grouping). Values: true | false
|
||||||
"fieldGrouping": "disabled", // Kiku duplicate-card field grouping mode. Values: auto | manual | disabled
|
"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
|
"deleteDuplicateInAuto": true // When Kiku field grouping is "auto", delete the duplicate source card after grouping completes. Values: true | false
|
||||||
} // Is kiku setting.
|
}, // Is kiku 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.
|
||||||
}, // Automatic Anki updates and media generation options.
|
}, // Automatic Anki updates and media generation options.
|
||||||
|
|
||||||
// ==========================================
|
// ==========================================
|
||||||
|
|||||||
@@ -289,6 +289,21 @@ Trigger with the mine sentence shortcut (`Ctrl/Cmd+S` by default). The card is c
|
|||||||
|
|
||||||
To mine multiple subtitle lines as one sentence card, use `Ctrl/Cmd+Shift+S` followed by a digit (1–9) to select how many recent lines to combine.
|
To mine multiple subtitle lines as one sentence card, use `Ctrl/Cmd+Shift+S` followed by a digit (1–9) to select how many recent lines to combine.
|
||||||
|
|
||||||
|
## Word Card Type (Kiku/Lapis)
|
||||||
|
|
||||||
|
Word cards get a card-type flag when SubMiner fills their sentence, whether that comes from Yomitan auto-enrichment, a manual clipboard update, or stats-dashboard word mining. By default the flag is `IsWordAndSentenceCard`; pick a different one with `ankiConnect.lapisKiku.wordCardKind`.
|
||||||
|
|
||||||
|
```jsonc
|
||||||
|
"ankiConnect": {
|
||||||
|
"isKiku": { "enabled": true },
|
||||||
|
"lapisKiku": {
|
||||||
|
"wordCardKind": "click" // word-and-sentence (default), click, sentence, audio, none
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`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)
|
||||||
|
|
||||||
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 like [Kiku](https://github.com/youyoumu/kiku) that support grouped sentence/audio/image fields.
|
||||||
@@ -314,7 +329,7 @@ When you mine the same word multiple times, SubMiner can merge the cards instead
|
|||||||
### What Gets Merged
|
### What Gets Merged
|
||||||
|
|
||||||
| Field | Merge behavior |
|
| Field | Merge behavior |
|
||||||
| -------- | ---------------------------------------- |
|
| -------- | --------------------------------------------- |
|
||||||
| Sentence | Both cards' sentences kept as grouped entries |
|
| Sentence | Both cards' sentences kept as grouped entries |
|
||||||
| Audio | Both cards' `[sound:...]` entries kept |
|
| Audio | Both cards' `[sound:...]` entries kept |
|
||||||
| Image | Both cards' images kept |
|
| Image | Both cards' images kept |
|
||||||
|
|||||||
@@ -1,5 +1,32 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## v0.19.2 (2026-08-04)
|
||||||
|
|
||||||
|
**Changed**
|
||||||
|
- Subsync: The sync modal now lets you choose both the reference subtitle (correct timing) and the out-of-sync subtitle to retime, for both alass and ffsubsync. alass can also use the loaded video's audio as a reference for local files. Retiming the secondary track now reloads the result into the secondary slot instead of overwriting the primary subtitle.
|
||||||
|
|
||||||
|
**Fixed**
|
||||||
|
- Streaming Subtitle Tokenization: Jellyfin streams now seed subtitle tokenization directly from the downloaded subtitle file instead of relying on an mpv event that could be missed, and prefetching now runs to the end of the file and clears between episodes. The tokenization cache was raised from 256 to 2500 lines, and parsed cues are no longer lost when the active subtitle track briefly can't be resolved (e.g. switching to an embedded track). Together these prevent episodes from falling back to slow, line-by-line tokenization during playback.
|
||||||
|
- Overlay: Subtitle lines now appear immediately at their cue time even on a tokenization cache miss, upgrading in place once tokens and annotations are ready, instead of waiting on a line still being processed. A failed tokenization is no longer cached as plain text, so repeated lines get another chance at annotations.
|
||||||
|
- Background Logging: Background startup now respects the configured logging level when no explicit log level is passed.
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>Internal changes</summary>
|
||||||
|
|
||||||
|
**Internal**
|
||||||
|
- Patched three high-severity dependency advisories (`undici`, `brace-expansion`, `fast-uri`).
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
## v0.19.1 (2026-08-01)
|
||||||
|
|
||||||
|
**Added**
|
||||||
|
- Word Card Type: Adds a setting (Settings > Mining/Anki > Kiku/Lapis Features > "Word Card Type") to choose which card-type flag SubMiner marks on Kiku/Lapis word cards — `word-and-sentence` (default), `click`, `sentence`, `audio`, or `none`. Click cards (`IsClickCard`) can now be flagged, and setting any card-type flag clears the others so a note can't claim two types at once.
|
||||||
|
|
||||||
|
**Fixed**
|
||||||
|
- Yomitan Popup: Fixes the macOS Yomitan popup going inert after mining a card — clicks outside the popup no longer pass through to mpv, and scrolling over the popup scrolls its definitions instead of seeking playback.
|
||||||
|
- YouTube Playlist Links: Fixes opening a video from a playlist URL (e.g. a Watch Later link with `list=`/`index=`) timing out while probing subtitles, metadata, or the playback URL.
|
||||||
|
|
||||||
## v0.19.0 (2026-07-29)
|
## v0.19.0 (2026-07-29)
|
||||||
|
|
||||||
**Added**
|
**Added**
|
||||||
|
|||||||
@@ -399,7 +399,7 @@ See `config.example.jsonc` for detailed configuration options.
|
|||||||
```
|
```
|
||||||
|
|
||||||
| Option | Values | Description |
|
| Option | Values | Description |
|
||||||
| ---------------------------------- | -------- | ---------------------------------------------------------------------------------------------------------------------------- |
|
| ---------------------------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
| `primaryDefaultMode` | string | Default primary subtitle bar visibility mode: `"hidden"`, `"visible"`, or `"hover"` (default: `"visible"`) |
|
| `primaryDefaultMode` | string | Default primary subtitle bar visibility mode: `"hidden"`, `"visible"`, or `"hover"` (default: `"visible"`) |
|
||||||
| `subtitleStyle.css` | object | CSS declaration object applied to primary subtitles after normal style defaults. Use CSS property names such as `font-size`. |
|
| `subtitleStyle.css` | object | CSS declaration object applied to primary subtitles after normal style defaults. Use CSS property names such as `font-size`. |
|
||||||
| `secondary.css` | object | CSS declaration object applied to secondary subtitles after normal secondary style defaults. |
|
| `secondary.css` | object | CSS declaration object applied to secondary subtitles after normal secondary style defaults. |
|
||||||
@@ -556,7 +556,7 @@ Secondary subtitles do **not** auto-load by default. To turn them on for local a
|
|||||||
```
|
```
|
||||||
|
|
||||||
| Option | Values | Description |
|
| Option | Values | Description |
|
||||||
| ----------------------- | ---------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
| ----------------------- | ---------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
| `secondarySubLanguages` | string[] | Language codes to auto-load (e.g., `["eng", "en"]`); non-Signs/Songs tracks are preferred when several tracks match. Default is empty (`[]`). |
|
| `secondarySubLanguages` | string[] | Language codes to auto-load (e.g., `["eng", "en"]`); non-Signs/Songs tracks are preferred when several tracks match. Default is empty (`[]`). |
|
||||||
| `autoLoadSecondarySub` | `true`, `false` | Auto-detect and load a matching secondary subtitle track for local/Jellyfin sidecar files (default: `false`) |
|
| `autoLoadSecondarySub` | `true`, `false` | Auto-detect and load a matching secondary subtitle track for local/Jellyfin sidecar files (default: `false`) |
|
||||||
| `defaultMode` | `"hidden"`, `"visible"`, `"hover"` | Initial display mode (default: `"hover"`) |
|
| `defaultMode` | `"hidden"`, `"visible"`, `"hover"` | Initial display mode (default: `"hover"`) |
|
||||||
@@ -1069,6 +1069,9 @@ SubMiner is intentionally built for [Kiku](https://kiku.youyoumu.my.id/) and [La
|
|||||||
"enabled": true,
|
"enabled": true,
|
||||||
"fieldGrouping": "manual",
|
"fieldGrouping": "manual",
|
||||||
"deleteDuplicateInAuto": true
|
"deleteDuplicateInAuto": true
|
||||||
|
},
|
||||||
|
"lapisKiku": {
|
||||||
|
"wordCardKind": "word-and-sentence"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
@@ -1077,6 +1080,21 @@ 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.
|
- 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`.
|
- 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).
|
- `isKiku.fieldGrouping` supports `disabled`, `auto`, and `manual` merge modes; see [Field Grouping Modes](#field-grouping-modes).
|
||||||
|
- `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
|
||||||
|
|
||||||
|
When SubMiner fills the sentence on a mined word card - from Yomitan auto-enrichment, a manual clipboard update, or stats-dashboard word mining - it marks which card that note should generate. `ankiConnect.lapisKiku.wordCardKind` chooses the flag:
|
||||||
|
|
||||||
|
| Value | Flag set |
|
||||||
|
| ----------------------------- | ----------------------- |
|
||||||
|
| `word-and-sentence` (default) | `IsWordAndSentenceCard` |
|
||||||
|
| `click` | `IsClickCard` |
|
||||||
|
| `sentence` | `IsSentenceCard` |
|
||||||
|
| `audio` | `IsAudioCard` |
|
||||||
|
| `none` | none; flags left as-is |
|
||||||
|
|
||||||
|
The other card-type flags are cleared so a note never claims two card types at once. Notes are skipped when the note type has no field for the chosen flag, and when the note was already mined as a sentence or audio card. Cards created by Mine Sentence and Mine Audio keep their own flag regardless of this setting.
|
||||||
|
|
||||||
### N+1 Word Highlighting
|
### N+1 Word Highlighting
|
||||||
|
|
||||||
@@ -1168,7 +1186,7 @@ TsukiHime subtitle search works out of the box and needs no account or API key.
|
|||||||
```
|
```
|
||||||
|
|
||||||
| Option | Values | Description |
|
| Option | Values | Description |
|
||||||
| ---------------------------- | ------------ | ---------------------------------------------------------------------------------------------------- |
|
| ---------------------------- | ------------ | ----------------------------------------------------------------------------------------------------- |
|
||||||
| `tsukihime.apiBaseUrl` | string (URL) | Base URL of the TsukiHime API (default: `https://api.tsukihime.org/v1`). Only change it for a mirror. |
|
| `tsukihime.apiBaseUrl` | string (URL) | Base URL of the TsukiHime API (default: `https://api.tsukihime.org/v1`). Only change it for a mirror. |
|
||||||
| `tsukihime.maxSearchResults` | number | Maximum releases returned per search (default: `10`; the API caps this at 100) |
|
| `tsukihime.maxSearchResults` | number | Maximum releases returned per search (default: `10`; the API caps this at 100) |
|
||||||
|
|
||||||
@@ -1178,9 +1196,9 @@ See [TsukiHime Integration](/tsukihime-integration) for the modal workflow, lang
|
|||||||
|
|
||||||
### Subtitle Sync
|
### Subtitle Sync
|
||||||
|
|
||||||
Sync the active subtitle track from the overlay picker using `alass` or `ffsubsync`. Both are **optional external tools** that must be installed separately and available on your `PATH` (or configured via the path options below).
|
Sync a subtitle track from the overlay picker using `alass` or `ffsubsync`. The picker lets you choose which track gets retimed (the active primary track by default) and, for alass, which reference it is aligned against (the secondary subtitle track by default). Both are **optional external tools** that must be installed separately and available on your `PATH` (or configured via the path options below).
|
||||||
|
|
||||||
- [`alass`](https://github.com/kaegi/alass) - fast, audio-independent sync using a secondary subtitle as reference
|
- [`alass`](https://github.com/kaegi/alass) - fast, audio-independent sync using another subtitle as reference; it can also take the local video file as reference (alass extracts the audio itself)
|
||||||
- [`ffsubsync`](https://github.com/smacke/ffsubsync) - audio-based sync using the video file as reference
|
- [`ffsubsync`](https://github.com/smacke/ffsubsync) - audio-based sync using the video file as reference
|
||||||
|
|
||||||
```json
|
```json
|
||||||
@@ -1229,7 +1247,7 @@ AniList integration is opt-in and disabled by default. Enable it to allow SubMin
|
|||||||
```
|
```
|
||||||
|
|
||||||
| Option | Values | Description |
|
| Option | Values | Description |
|
||||||
| -------------------------------------------------------------- | ------------------- | ------------------------------------------------------------------------------------------------------------- |
|
| -------------------------------------------------------------- | ----------------------- | ------------------------------------------------------------------------------------------------------------- |
|
||||||
| `anilist.enabled` | `true`, `false` | Enable AniList post-watch progress updates (default: `false`) |
|
| `anilist.enabled` | `true`, `false` | Enable AniList post-watch progress updates (default: `false`) |
|
||||||
| `accessToken` | string | Optional explicit AniList access token override (default: empty string) |
|
| `accessToken` | string | Optional explicit AniList access token override (default: empty string) |
|
||||||
| `characterDictionary.maxLoaded` | number | Maximum number of most-recently-used AniList media snapshots included in the merged dictionary (default: `3`) |
|
| `characterDictionary.maxLoaded` | number | Maximum number of most-recently-used AniList media snapshots included in the merged dictionary (default: `3`) |
|
||||||
@@ -1540,7 +1558,7 @@ Configure the mpv executable, profile, and window state for SubMiner-managed mpv
|
|||||||
```
|
```
|
||||||
|
|
||||||
| Option | Values | Description |
|
| Option | Values | Description |
|
||||||
| ------------------------ | --------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------- |
|
| ------------------------ | --------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
| `executablePath` | string | Absolute path to `mpv.exe` for Windows launch flows. Leave empty to auto-discover from `SUBMINER_MPV_PATH` or `PATH` (default `""`) |
|
| `executablePath` | string | Absolute path to `mpv.exe` for Windows launch flows. Leave empty to auto-discover from `SUBMINER_MPV_PATH` or `PATH` (default `""`) |
|
||||||
| `profile` | string | mpv profile name passed as `--profile=<name>`. Leave empty to pass no profile (default `""`) |
|
| `profile` | string | mpv profile name passed as `--profile=<name>`. Leave empty to pass no profile (default `""`) |
|
||||||
| `launchMode` | `"normal"` \| `"maximized"` \| `"fullscreen"` | Window state when SubMiner spawns mpv (default `"normal"`) |
|
| `launchMode` | `"normal"` \| `"maximized"` \| `"fullscreen"` | Window state when SubMiner spawns mpv (default `"normal"`) |
|
||||||
|
|||||||
@@ -161,10 +161,13 @@ If your subtitle file is out of sync with the audio, SubMiner can resynchronize
|
|||||||
|
|
||||||
1. Open the subsync modal from the overlay.
|
1. Open the subsync modal from the overlay.
|
||||||
2. Select the sync engine (alass or ffsubsync).
|
2. Select the sync engine (alass or ffsubsync).
|
||||||
3. For alass, select a reference subtitle track from the video.
|
3. For alass, pick the **reference** - the subtitle with correct timing. This defaults to the secondary subtitle track. The loaded video file can also be used as the reference (alass extracts the audio itself), but it is never the default.
|
||||||
4. SubMiner runs the sync and reloads the corrected subtitle.
|
4. Pick the **out-of-sync subtitle** - the track that gets retimed. This defaults to the active primary subtitle track and applies to both engines.
|
||||||
|
5. SubMiner runs the sync and reloads the corrected subtitle into the slot the out-of-sync track came from: retiming the secondary track keeps it secondary and leaves the primary track selected.
|
||||||
|
|
||||||
For remote streams, including Jellyfin playback, the modal only offers alass. Jellyfin subtitle URLs are cached as temporary subtitle files so alass can read them, but the video stream is not downloaded. ffsubsync needs direct access to the local media file and is unavailable for stream URLs.
|
The reference and the out-of-sync subtitle must be different tracks; the reference list hides whichever track is selected as the target.
|
||||||
|
|
||||||
|
For remote streams, including Jellyfin playback, the modal only offers alass with a subtitle reference. Jellyfin subtitle URLs are cached as temporary subtitle files so alass can read them, but the video stream is not downloaded. ffsubsync and the video-file reference need direct access to the local media file and are unavailable for stream URLs.
|
||||||
|
|
||||||
When you mine a sentence card from the stats dashboard, SubMiner can also use `alass` automatically to align a local English sidecar against the matching local Japanese sidecar before filling the card translation field. The source subtitle files are not modified; SubMiner writes a temporary retimed copy and reuses it while the stats server is running.
|
When you mine a sentence card from the stats dashboard, SubMiner can also use `alass` automatically to align a local English sidecar against the matching local Japanese sidecar before filling the card translation field. The source subtitle files are not modified; SubMiner writes a temporary retimed copy and reuses it while the stats server is running.
|
||||||
|
|
||||||
|
|||||||
@@ -523,7 +523,7 @@
|
|||||||
// ==========================================
|
// ==========================================
|
||||||
// AnkiConnect Integration
|
// AnkiConnect Integration
|
||||||
// Automatic Anki updates and media generation options.
|
// Automatic Anki updates and media generation options.
|
||||||
// Hot-reload: ankiConnect.ai.enabled, media.normalizeAudio/mirrorMpvVolume, knownWords, nPlusOne, fields.word/audio/image/sentence/miscInfo, behavior.autoUpdateNewCards, isLapis.sentenceCardModel, and isKiku.fieldGrouping update live while SubMiner is running.
|
// Hot-reload: ankiConnect.ai.enabled, media.normalizeAudio/mirrorMpvVolume, knownWords, nPlusOne, fields.word/audio/image/sentence/miscInfo, behavior.autoUpdateNewCards, isLapis.sentenceCardModel, isKiku.fieldGrouping, and lapisKiku.wordCardKind update live while SubMiner is running.
|
||||||
// Shared AI provider transport settings are read from top-level ai and typically require restart.
|
// Shared AI provider transport settings are read from top-level ai and typically require restart.
|
||||||
// Most other AnkiConnect settings still require restart.
|
// Most other AnkiConnect settings still require restart.
|
||||||
// ==========================================
|
// ==========================================
|
||||||
@@ -605,7 +605,10 @@
|
|||||||
"enabled": false, // Enable Kiku-specific mining behaviors (duplicate handling, field grouping). Values: true | false
|
"enabled": false, // Enable Kiku-specific mining behaviors (duplicate handling, field grouping). Values: true | false
|
||||||
"fieldGrouping": "disabled", // Kiku duplicate-card field grouping mode. Values: auto | manual | disabled
|
"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
|
"deleteDuplicateInAuto": true // When Kiku field grouping is "auto", delete the duplicate source card after grouping completes. Values: true | false
|
||||||
} // Is kiku setting.
|
}, // Is kiku 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.
|
||||||
}, // Automatic Anki updates and media generation options.
|
}, // Automatic Anki updates and media generation options.
|
||||||
|
|
||||||
// ==========================================
|
// ==========================================
|
||||||
|
|||||||
@@ -227,7 +227,7 @@ Install ffsubsync or configure the path:
|
|||||||
|
|
||||||
If subtitle sync fails (the error message is prefixed with the engine name):
|
If subtitle sync fails (the error message is prefixed with the engine name):
|
||||||
|
|
||||||
- Ensure the reference subtitle track exists in the video (alass requires a source track).
|
- Ensure a reference is selected (alass needs either a second subtitle track or the local video file, and it cannot be the same track that is being retimed).
|
||||||
- Check that `ffmpeg` is available (used to extract the internal subtitle track).
|
- Check that `ffmpeg` is available (used to extract the internal subtitle track).
|
||||||
- Try running the sync tool manually to see detailed error output.
|
- Try running the sync tool manually to see detailed error output.
|
||||||
- ffsubsync requires local files and cannot handle remote media streams (e.g., streaming URLs).
|
- ffsubsync requires local files and cannot handle remote media streams (e.g., streaming URLs).
|
||||||
|
|||||||
+1
-1
@@ -149,7 +149,7 @@ Once Jellyfin is configured, the tray menu includes `Jellyfin Discovery` for sta
|
|||||||
|
|
||||||
- `--log-level` controls logger verbosity.
|
- `--log-level` controls logger verbosity.
|
||||||
- `--dev` and `--debug` are app/dev-mode switches; they are not log-level aliases.
|
- `--dev` and `--debug` are app/dev-mode switches; they are not log-level aliases.
|
||||||
- `--background` defaults to quieter logging (`warn`) unless `--log-level` is set.
|
- `--background` starts at the default quieter logging level (`warn`), then follows `logging.level` after config loads. An explicit `--log-level` remains the override.
|
||||||
- `--background` launched from a terminal detaches and returns the prompt; stop it with tray Quit or `SubMiner.AppImage --stop` (`SubMiner.exe --stop` on Windows).
|
- `--background` launched from a terminal detaches and returns the prompt; stop it with tray Quit or `SubMiner.AppImage --stop` (`SubMiner.exe --stop` on Windows).
|
||||||
- Linux desktop launcher starts SubMiner with `--background` by default (via electron-builder `linux.executableArgs`).
|
- Linux desktop launcher starts SubMiner with `--background` by default (via electron-builder `linux.executableArgs`).
|
||||||
- On Hyprland and other Wayland compositors, the tray icon appears only when your panel provides a StatusNotifier/AppIndicator tray host.
|
- On Hyprland and other Wayland compositors, the tray icon appears only when your panel provides a StatusNotifier/AppIndicator tray host.
|
||||||
|
|||||||
@@ -64,7 +64,7 @@ Use the basic subtitle websocket when you only need the current subtitle line as
|
|||||||
- **Client auth:** none
|
- **Client auth:** none
|
||||||
- **Reconnects:** client-managed
|
- **Reconnects:** client-managed
|
||||||
|
|
||||||
When a client connects, SubMiner immediately sends the latest subtitle payload if one is available. After that, it pushes a new message each time the current subtitle changes.
|
When a client connects, SubMiner immediately sends the latest subtitle payload if one is available. After that, it pushes a new message each time the current subtitle changes. Annotation-only upgrades do not repeat the same line on this basic stream.
|
||||||
|
|
||||||
#### Message shape
|
#### Message shape
|
||||||
|
|
||||||
@@ -96,6 +96,8 @@ Use the annotation websocket for custom clients that want the same structured to
|
|||||||
|
|
||||||
In practice, if you are building a new client, prefer `annotationWebsocket` unless you specifically need compatibility with an existing `websocket` consumer.
|
In practice, if you are building a new client, prefer `annotationWebsocket` unless you specifically need compatibility with an existing `websocket` consumer.
|
||||||
|
|
||||||
|
On a tokenization cache miss, this stream first sends the cue as plain text with an empty `tokens` array, then sends the annotated replacement when tokenization finishes. Treat each message as the complete current state, replacing the previous payload.
|
||||||
|
|
||||||
#### Message shape
|
#### Message shape
|
||||||
|
|
||||||
```json
|
```json
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ SubMiner auto-loads Japanese subtitles when you play a YouTube URL, giving you t
|
|||||||
|
|
||||||
When SubMiner detects a YouTube URL (or `ytsearch:` target), it pauses mpv at startup and runs a subtitle pipeline before resuming playback:
|
When SubMiner detects a YouTube URL (or `ytsearch:` target), it pauses mpv at startup and runs a subtitle pipeline before resuming playback:
|
||||||
|
|
||||||
1. **Probe** --- `yt-dlp --dump-single-json` extracts all available subtitle tracks (manual uploads and auto-generated captions) along with video metadata.
|
1. **Probe** --- `yt-dlp --dump-single-json` extracts all available subtitle tracks (manual uploads and auto-generated captions) along with video metadata. Every yt-dlp call passes `--no-playlist`, so playlist links (for example a Watch Later URL with `list=`/`index=`) resolve to the single video instead of the whole playlist.
|
||||||
2. **Discover** --- Each track is normalized into a `YoutubeTrackOption` with language code, kind (`manual` or `auto`), display label, and direct download URL.
|
2. **Discover** --- Each track is normalized into a `YoutubeTrackOption` with language code, kind (`manual` or `auto`), display label, and direct download URL.
|
||||||
3. **Select** --- SubMiner picks the best primary track (Japanese, preferring manual over auto) and secondary track (English, preferring manual over auto).
|
3. **Select** --- SubMiner picks the best primary track (Japanese, preferring manual over auto) and secondary track (English, preferring manual over auto).
|
||||||
4. **Download** --- Selected tracks are fetched via direct URL when available, falling back to `yt-dlp --write-subs` / `--write-auto-subs`. YouTube TimedText XML formats (`srv1`/`srv2`/`srv3`) are converted to VTT on the fly. Auto-generated VTT captions are normalized to remove rolling-caption duplication.
|
4. **Download** --- Selected tracks are fetched via direct URL when available, falling back to `yt-dlp --write-subs` / `--write-auto-subs`. YouTube TimedText XML formats (`srv1`/`srv2`/`srv3`) are converted to VTT on the fly. Auto-generated VTT captions are normalized to remove rolling-caption duplication.
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
# Subtitle Overlay Priming
|
# Subtitle Overlay Priming
|
||||||
|
|
||||||
Status: active
|
Status: active
|
||||||
Last verified: 2026-06-14
|
Last verified: 2026-08-04
|
||||||
Owner: Kyle Yasuda
|
Owner: Kyle Yasuda
|
||||||
Read when: debugging subtitle state or blank Linux/X11 overlay windows when the visible overlay is shown or recreated
|
Read when: debugging subtitle state or blank Linux/X11 overlay windows when the visible overlay is shown or recreated
|
||||||
|
|
||||||
@@ -47,18 +47,31 @@ subtitles do not draw.
|
|||||||
`emitSubtitle(payload)` and `refreshCurrentSubtitle(text)`, then prime secondary subtitles.
|
`emitSubtitle(payload)` and `refreshCurrentSubtitle(text)`, then prime secondary subtitles.
|
||||||
6. Tokenization cache hit: call `consumeCachedSubtitle(text)`, `onSubtitleChange(text)`, and
|
6. Tokenization cache hit: call `consumeCachedSubtitle(text)`, `onSubtitleChange(text)`, and
|
||||||
`emitSubtitle(cachedPayload)`, then prime secondary subtitles.
|
`emitSubtitle(cachedPayload)`, then prime secondary subtitles.
|
||||||
7. Cache miss: call `refreshCurrentSubtitle(text)` and let normal tokenization emit the final
|
7. Cache miss: call `refreshCurrentSubtitle(text)`. Normal processing emits a plain payload
|
||||||
payload.
|
synchronously, then replaces it with the tokenized payload when ready.
|
||||||
|
|
||||||
In `src/main.ts`, both `onSubtitleChange` and `refreshCurrentSubtitle` pause
|
In `src/main.ts`, both `onSubtitleChange` and `refreshCurrentSubtitle` pause
|
||||||
`subtitlePrefetchService`, notify it with `onSeek(lastObservedTimePos)`, and then call the matching
|
`subtitlePrefetchService`, notify it with `onSeek(lastObservedTimePos)`, and then call the matching
|
||||||
`subtitleProcessingController` method. This gives the visible overlay priority over background
|
`subtitleProcessingController` method. This gives the visible overlay priority over background
|
||||||
prefetch work and re-centers prefetch around the live playback time.
|
prefetch work and re-centers prefetch around the live playback time.
|
||||||
|
|
||||||
|
## Live Cue Delivery
|
||||||
|
|
||||||
|
- A tokenization cache miss emits the plain cue synchronously. Tokenization remains serialized so
|
||||||
|
live work does not contend for Yomitan state.
|
||||||
|
- If a newer cue arrives while an older line is still tokenizing, the newer plain cue or empty
|
||||||
|
clear payload is emitted immediately. The older tokenization result is dropped before it can
|
||||||
|
replace the current cue.
|
||||||
|
- The current cue upgrades in place when its tokens and annotations are ready. This can reflow text
|
||||||
|
or character images, but cue visibility does not wait for that work.
|
||||||
|
|
||||||
## Emitted State
|
## Emitted State
|
||||||
|
|
||||||
- `emitSubtitle(payload)` maps to `emitSubtitlePayload(payload)`, which sends the normal
|
- `emitSubtitle(payload)` maps to `emitSubtitlePayload(payload)`. Overlay windows and annotation
|
||||||
annotated subtitle payload to overlay windows and subtitle websocket listeners.
|
websocket listeners receive both the immediate plain cue and its later annotation upgrade.
|
||||||
|
- The basic subtitle websocket receives the immediate plain cue only. Because its serialized
|
||||||
|
payload discards annotations, the later upgrade would be an identical duplicate and is skipped
|
||||||
|
when text and cue timing match.
|
||||||
- Secondary priming reads mpv `secondary-sub-text`, stores it in
|
- Secondary priming reads mpv `secondary-sub-text`, stores it in
|
||||||
`mpvClient.currentSecondarySubText`, and broadcasts `secondary-subtitle:set` to overlay windows.
|
`mpvClient.currentSecondarySubText`, and broadcasts `secondary-subtitle:set` to overlay windows.
|
||||||
- If secondary `requestProperty` fails, the primary flow stays complete and only a debug line is
|
- If secondary `requestProperty` fails, the primary flow stays complete and only a debug line is
|
||||||
|
|||||||
+6
-4
@@ -2,7 +2,7 @@
|
|||||||
"name": "subminer",
|
"name": "subminer",
|
||||||
"productName": "SubMiner",
|
"productName": "SubMiner",
|
||||||
"desktopName": "SubMiner.desktop",
|
"desktopName": "SubMiner.desktop",
|
||||||
"version": "0.19.0",
|
"version": "0.19.2",
|
||||||
"description": "All-in-one sentence mining overlay with AnkiConnect and dictionary integration",
|
"description": "All-in-one sentence mining overlay with AnkiConnect and dictionary integration",
|
||||||
"packageManager": "bun@1.3.5",
|
"packageManager": "bun@1.3.5",
|
||||||
"main": "dist/main-entry.js",
|
"main": "dist/main-entry.js",
|
||||||
@@ -84,8 +84,9 @@
|
|||||||
"overrides": {
|
"overrides": {
|
||||||
"@xmldom/xmldom": "0.8.13",
|
"@xmldom/xmldom": "0.8.13",
|
||||||
"app-builder-lib": "26.15.3",
|
"app-builder-lib": "26.15.3",
|
||||||
"brace-expansion": "5.0.8",
|
"brace-expansion": "5.0.9",
|
||||||
"electron-builder-squirrel-windows": "26.15.3",
|
"electron-builder-squirrel-windows": "26.15.3",
|
||||||
|
"fast-uri": "3.1.5",
|
||||||
"form-data": "4.0.6",
|
"form-data": "4.0.6",
|
||||||
"ip-address": "10.2.0",
|
"ip-address": "10.2.0",
|
||||||
"js-yaml": "4.3.0",
|
"js-yaml": "4.3.0",
|
||||||
@@ -93,7 +94,8 @@
|
|||||||
"minimatch": "10.2.5",
|
"minimatch": "10.2.5",
|
||||||
"picomatch": "4.0.4",
|
"picomatch": "4.0.4",
|
||||||
"tar": "7.5.21",
|
"tar": "7.5.21",
|
||||||
"tmp": "0.2.7"
|
"tmp": "0.2.7",
|
||||||
|
"undici": "7.29.0"
|
||||||
},
|
},
|
||||||
"keywords": [
|
"keywords": [
|
||||||
"anki",
|
"anki",
|
||||||
@@ -125,7 +127,7 @@
|
|||||||
"@types/ws": "^8.18.1",
|
"@types/ws": "^8.18.1",
|
||||||
"electron": "42.6.0",
|
"electron": "42.6.0",
|
||||||
"electron-builder": "26.15.3",
|
"electron-builder": "26.15.3",
|
||||||
"undici": "7.28.0",
|
"undici": "7.29.0",
|
||||||
"esbuild": "^0.25.12",
|
"esbuild": "^0.25.12",
|
||||||
"eslint": "^10.8.0",
|
"eslint": "^10.8.0",
|
||||||
"prettier": "^3.8.1",
|
"prettier": "^3.8.1",
|
||||||
|
|||||||
+20
-47
@@ -1,57 +1,30 @@
|
|||||||
## Highlights
|
## Highlights
|
||||||
### Added
|
|
||||||
- **Anki Maturity Known-Word Highlighting**
|
|
||||||
- Subtitle words you already know can now be color-coded by their Anki card maturity (new, learning, young, mature), like asbplayer's known-word coloring.
|
|
||||||
- Enable it with `ankiConnect.knownWords.maturityEnabled` (or toggle it mid-session); tier colors and the "mature" day threshold are configurable, and the in-session help legend shows the active colors.
|
|
||||||
- **Cross-Machine Sync for Stats & Watch History**
|
|
||||||
- Sync immersion stats and watch history between machines over SSH from a new Sync window (tray menu → Sync Stats & History, or `subminer sync --ui`) or the CLI (`subminer sync <host>`).
|
|
||||||
- Save multiple devices with per-host sync direction, run one-click syncs with live progress, and take manual database snapshots for backup or transfer.
|
|
||||||
- Windows remotes are supported over OpenSSH, and hosts can auto-sync in the background on a schedule, even during playback.
|
|
||||||
- **History Menu After Playback**
|
|
||||||
- After a watch-history episode ends (or mpv closes), the fzf/rofi launcher now offers to play the previous or next episode, rewatch, pick another episode, or quit, right from where you left off. Previous/Next continue across season folders.
|
|
||||||
- **Delete Entire Library Titles from Stats**
|
|
||||||
- The stats Library detail view now has a "Delete Entry" action that removes a whole title in one step, episodes, sessions, subtitle lines, rollups, cover art, and vocabulary counts, instead of clearing it episode by episode.
|
|
||||||
- Delete progress (sessions, episodes, or whole titles) now shows app-wide with a progress bar and status toast visible from any tab or window.
|
|
||||||
- **TsukiHime English Subtitle Downloads**
|
|
||||||
- Download subtitles for the currently playing video directly from TsukiHime, with Japanese loaded as the primary track and your configured secondary language alongside it.
|
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
- **Configurable Clipboard-Video Shortcut**
|
|
||||||
- The "append clipboard video to queue" shortcut is now configurable via `shortcuts.appendClipboardVideoToQueue` instead of fixed.
|
- **Subsync Reference & Target Picker**
|
||||||
|
- You can now choose both sides of a sync run: which subtitle is the timing reference and which one gets retimed.
|
||||||
|
- The video file itself can be used as the reference for local files (audio-based sync), though a subtitle track stays the default.
|
||||||
|
- Works for both alass and ffsubsync, and retiming the secondary subtitle track no longer overwrites your primary one.
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
- **AniList Season Matching**
|
|
||||||
- Season 2+ episodes now resolve to the correct AniList entry instead of silently falling back to season 1. SubMiner follows AniList's sequel relations to find the right season, and cover art and watch progress now use the same season-aware match.
|
- **Startup Logging**
|
||||||
- If a season still can't be found, SubMiner no longer force-writes progress or a cover to the season 1 entry, it skips the update and points you to a manual AniList override, which now fixes the character dictionary and watch progress together.
|
- Background startup now respects your configured log level even when no `--log-level` flag is passed.
|
||||||
- Manual overrides now stay applied consistently across every episode in a season folder, even when filenames guess differently episode to episode.
|
- **Streaming Subtitle Tokenization**
|
||||||
- **Subtitle Highlighting Accuracy**
|
- Jellyfin playback now seeds tokenization straight from the downloaded subtitle file, so episodes no longer fall back to slow, line-by-line tokenizing while waiting on playback events.
|
||||||
- Fixed several known-word/annotation edge cases: part-of-speech exclusions now apply consistently to merged quote-particle tokens, annotations for rarer kanji are preserved, katakana punctuation is no longer mistaken for plain kana, and a specific noun-tagging case no longer loses its known+1 highlight.
|
- Subtitle cues are no longer dropped when switching to a subtitle track embedded in the stream.
|
||||||
- **AnkiConnect Proxy Port Conflicts**
|
- Prefetching now runs through the whole episode instead of stopping once the cache filled, and the cache clears between episodes so slowdowns don't carry over to later titles.
|
||||||
- Fixed a crash on video startup when another process already held the configured AnkiConnect proxy port; you'll now get a notification explaining how to resolve it instead.
|
- The tokenization cache was expanded from 256 to 2,500 lines, leaving more room for repeated lines (like openings and endings) to stay cached across episodes.
|
||||||
- **AppImage Crash Notification on Quit**
|
- **Subtitle Line Display**
|
||||||
- Fixed a spurious "Service Crash" desktop notification appearing after closing a video when running the Linux AppImage.
|
- Subtitle lines now appear immediately at their cue time even if tokenization hasn't finished, upgrading in place with annotations once ready.
|
||||||
- **Startup Playback Pause Timing**
|
- A failed tokenization attempt is no longer cached as plain text, so the line gets another chance at full annotations later.
|
||||||
- Fixed playback occasionally resuming a couple seconds before subtitle tokenization actually finished warming up, most noticeable when resuming mid-episode or when a subtitle appears in the first two seconds.
|
|
||||||
- **Stats Library Cover After Relinking**
|
|
||||||
- Fixed the stats Library grid showing a stale cover image after relinking a title to a different AniList entry.
|
|
||||||
- **Faster Stats Deletes and Vocabulary Tab**
|
|
||||||
- Deleting sessions, episodes, and titles from stats is now dramatically faster and no longer stalls playback while it runs; the Vocabulary tab also loads much faster.
|
|
||||||
- The first launch after updating runs a one-time database migration (a few seconds, database grows about 20%); no action needed.
|
|
||||||
- **Settings Validation and Stats Server Hardening**
|
|
||||||
- Invalid AnkiConnect settings now fall back safely with a warning instead of silently breaking, and the stats server is hardened against malformed requests, stalled AniList searches, and other edge cases that could previously crash it.
|
|
||||||
- **Rofi Prompt Spacing**
|
|
||||||
- Fixed rofi menu prompts running into the search placeholder text with no space between them.
|
|
||||||
|
|
||||||
## What's Changed
|
## What's Changed
|
||||||
|
|
||||||
- feat(shortcuts): make clipboard-video-append shortcut configurable by @ksyasuda in #158
|
- feat(subsync): add reference and target subtitle track picker by @ksyasuda in #181
|
||||||
- refactor(tokenizer): extract subtitle annotation filter into rule table by @ksyasuda in #162
|
- fix(logging): surface subtitle processing debug/warn logs by @ksyasuda in #182
|
||||||
- refactor(tsukihime): swap Animetosho backend for TsukiHime API by @ksyasuda in #165
|
- fix(streaming): keep subtitle tokenization prefetch warm for full episodes by @ksyasuda in #183
|
||||||
- refactor: split anki-connect and stats-server resolvers into modules by @ksyasuda in #169
|
- fix(overlay): show plain subtitle line immediately on tokenization cache miss by @ksyasuda in #184
|
||||||
- feat(launcher): add post-playback history menu with previous episode by @ksyasuda in #170
|
|
||||||
- Anki maturity-based known-word highlighting by @ksyasuda in #172
|
|
||||||
- fix(anilist): resolve later seasons via sequel relations, not title guessing by @ksyasuda in #173
|
|
||||||
- feat(stats): add library entry deletion and app-wide delete progress by @ksyasuda in #174
|
|
||||||
|
|
||||||
## Installation
|
## Installation
|
||||||
|
|
||||||
|
|||||||
+7
-74
@@ -22,10 +22,12 @@ import { MediaGenerator } from './media-generator';
|
|||||||
import path from 'path';
|
import path from 'path';
|
||||||
import {
|
import {
|
||||||
AnkiConnectConfig,
|
AnkiConnectConfig,
|
||||||
|
type CardKind,
|
||||||
KikuDuplicateCardInfo,
|
KikuDuplicateCardInfo,
|
||||||
KikuFieldGroupingChoice,
|
KikuFieldGroupingChoice,
|
||||||
KikuMergePreviewResponse,
|
KikuMergePreviewResponse,
|
||||||
NotificationOptions,
|
NotificationOptions,
|
||||||
|
type WordCardKind,
|
||||||
} from './types/anki';
|
} from './types/anki';
|
||||||
import { AiConfig } from './types/integrations';
|
import { AiConfig } from './types/integrations';
|
||||||
import type { KnownWordMaturityTier } from './types/subtitle';
|
import type { KnownWordMaturityTier } from './types/subtitle';
|
||||||
@@ -50,6 +52,7 @@ import {
|
|||||||
withUpdateProgress,
|
withUpdateProgress,
|
||||||
UiFeedbackState,
|
UiFeedbackState,
|
||||||
} from './anki-integration/ui-feedback';
|
} from './anki-integration/ui-feedback';
|
||||||
|
import { applyCardKindFlagFields, resolveWordCardKindSetting } from './anki-integration/card-kinds';
|
||||||
import { KnownWordCacheManager } from './anki-integration/known-word-cache';
|
import { KnownWordCacheManager } from './anki-integration/known-word-cache';
|
||||||
import { PollingRunner } from './anki-integration/polling';
|
import { PollingRunner } from './anki-integration/polling';
|
||||||
import type { AnkiConnectProxyServer } from './anki-integration/anki-connect-proxy';
|
import type { AnkiConnectProxyServer } from './anki-integration/anki-connect-proxy';
|
||||||
@@ -83,8 +86,6 @@ interface NoteInfo {
|
|||||||
fields: Record<string, { value: string }>;
|
fields: Record<string, { value: string }>;
|
||||||
}
|
}
|
||||||
|
|
||||||
type CardKind = 'sentence' | 'audio' | 'word-and-sentence';
|
|
||||||
|
|
||||||
function trimToNonEmptyString(value: unknown): string | null {
|
function trimToNonEmptyString(value: unknown): string | null {
|
||||||
if (typeof value !== 'string') return null;
|
if (typeof value !== 'string') return null;
|
||||||
const trimmed = value.trim();
|
const trimmed = value.trim();
|
||||||
@@ -840,6 +841,7 @@ export class AnkiIntegration {
|
|||||||
kikuEnabled: boolean;
|
kikuEnabled: boolean;
|
||||||
kikuFieldGrouping: 'auto' | 'manual' | 'disabled';
|
kikuFieldGrouping: 'auto' | 'manual' | 'disabled';
|
||||||
kikuDeleteDuplicateInAuto: boolean;
|
kikuDeleteDuplicateInAuto: boolean;
|
||||||
|
wordCardKind: WordCardKind;
|
||||||
} {
|
} {
|
||||||
const lapis = this.getLapisConfig();
|
const lapis = this.getLapisConfig();
|
||||||
const kiku = this.getKikuConfig();
|
const kiku = this.getKikuConfig();
|
||||||
@@ -852,6 +854,7 @@ export class AnkiIntegration {
|
|||||||
kikuEnabled: kiku.enabled,
|
kikuEnabled: kiku.enabled,
|
||||||
kikuFieldGrouping: (kiku.fieldGrouping || 'disabled') as 'auto' | 'manual' | 'disabled',
|
kikuFieldGrouping: (kiku.fieldGrouping || 'disabled') as 'auto' | 'manual' | 'disabled',
|
||||||
kikuDeleteDuplicateInAuto: kiku.deleteDuplicateInAuto !== false,
|
kikuDeleteDuplicateInAuto: kiku.deleteDuplicateInAuto !== false,
|
||||||
|
wordCardKind: resolveWordCardKindSetting(this.config.lapisKiku?.wordCardKind),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1315,79 +1318,9 @@ export class AnkiIntegration {
|
|||||||
availableFieldNames: string[],
|
availableFieldNames: string[],
|
||||||
cardKind: CardKind,
|
cardKind: CardKind,
|
||||||
): void {
|
): void {
|
||||||
const audioFlagNames = ['IsAudioCard'];
|
applyCardKindFlagFields(updatedFields, cardKind, (preferredName) =>
|
||||||
|
this.resolveFieldName(availableFieldNames, preferredName),
|
||||||
if (cardKind === 'word-and-sentence') {
|
|
||||||
const wordAndSentenceFlag = this.resolveFieldName(
|
|
||||||
availableFieldNames,
|
|
||||||
'IsWordAndSentenceCard',
|
|
||||||
);
|
);
|
||||||
if (!wordAndSentenceFlag) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
updatedFields[wordAndSentenceFlag] = 'x';
|
|
||||||
|
|
||||||
const sentenceFlag = this.resolveFieldName(availableFieldNames, 'IsSentenceCard');
|
|
||||||
if (sentenceFlag && sentenceFlag !== wordAndSentenceFlag) {
|
|
||||||
updatedFields[sentenceFlag] = '';
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const audioFlagName of audioFlagNames) {
|
|
||||||
const resolved = this.resolveFieldName(availableFieldNames, audioFlagName);
|
|
||||||
if (resolved && resolved !== wordAndSentenceFlag) {
|
|
||||||
updatedFields[resolved] = '';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (cardKind === 'sentence') {
|
|
||||||
const sentenceFlag = this.resolveFieldName(availableFieldNames, 'IsSentenceCard');
|
|
||||||
if (sentenceFlag) {
|
|
||||||
updatedFields[sentenceFlag] = 'x';
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const audioFlagName of audioFlagNames) {
|
|
||||||
const resolved = this.resolveFieldName(availableFieldNames, audioFlagName);
|
|
||||||
if (resolved && resolved !== sentenceFlag) {
|
|
||||||
updatedFields[resolved] = '';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const wordAndSentenceFlag = this.resolveFieldName(
|
|
||||||
availableFieldNames,
|
|
||||||
'IsWordAndSentenceCard',
|
|
||||||
);
|
|
||||||
if (wordAndSentenceFlag && wordAndSentenceFlag !== sentenceFlag) {
|
|
||||||
updatedFields[wordAndSentenceFlag] = '';
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const resolvedAudioFlags = Array.from(
|
|
||||||
new Set(
|
|
||||||
audioFlagNames
|
|
||||||
.map((name) => this.resolveFieldName(availableFieldNames, name))
|
|
||||||
.filter((name): name is string => Boolean(name)),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
const audioFlagName = resolvedAudioFlags[0] || null;
|
|
||||||
if (audioFlagName) {
|
|
||||||
updatedFields[audioFlagName] = 'x';
|
|
||||||
}
|
|
||||||
for (const extraAudioFlag of resolvedAudioFlags.slice(1)) {
|
|
||||||
updatedFields[extraAudioFlag] = '';
|
|
||||||
}
|
|
||||||
|
|
||||||
const sentenceFlag = this.resolveFieldName(availableFieldNames, 'IsSentenceCard');
|
|
||||||
if (sentenceFlag && sentenceFlag !== audioFlagName) {
|
|
||||||
updatedFields[sentenceFlag] = '';
|
|
||||||
}
|
|
||||||
|
|
||||||
const wordAndSentenceFlag = this.resolveFieldName(availableFieldNames, 'IsWordAndSentenceCard');
|
|
||||||
if (wordAndSentenceFlag && wordAndSentenceFlag !== audioFlagName) {
|
|
||||||
updatedFields[wordAndSentenceFlag] = '';
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private async showNotification(
|
private async showNotification(
|
||||||
|
|||||||
@@ -4,29 +4,23 @@ import test from 'node:test';
|
|||||||
import { CardCreationService } from './card-creation';
|
import { CardCreationService } from './card-creation';
|
||||||
import { toMpvEdlValue } from './mpv-edl-test-utils';
|
import { toMpvEdlValue } from './mpv-edl-test-utils';
|
||||||
import type { MediaInput } from '../media-generator';
|
import type { MediaInput } from '../media-generator';
|
||||||
import type { AnkiConnectConfig } from '../types/anki';
|
import type { AnkiConnectConfig, CardKind } from '../types/anki';
|
||||||
|
import { applyCardKindFlagFields } from './card-kinds';
|
||||||
|
|
||||||
type CardCreationDeps = ConstructorParameters<typeof CardCreationService>[0];
|
type CardCreationDeps = ConstructorParameters<typeof CardCreationService>[0];
|
||||||
|
|
||||||
function setWordAndSentenceCardTypeFields(
|
function setCardTypeFields(
|
||||||
updatedFields: Record<string, string>,
|
updatedFields: Record<string, string>,
|
||||||
availableFieldNames: string[],
|
availableFieldNames: string[],
|
||||||
cardKind: 'sentence' | 'audio' | 'word-and-sentence',
|
cardKind: CardKind,
|
||||||
): void {
|
): void {
|
||||||
if (cardKind !== 'word-and-sentence') return;
|
applyCardKindFlagFields(
|
||||||
|
updatedFields,
|
||||||
const resolveFieldName = (preferredName: string): string | null =>
|
cardKind,
|
||||||
availableFieldNames.find((name) => name.toLowerCase() === preferredName.toLowerCase()) ?? null;
|
(preferredName) =>
|
||||||
const wordAndSentenceFlag = resolveFieldName('IsWordAndSentenceCard');
|
availableFieldNames.find((name) => name.toLowerCase() === preferredName.toLowerCase()) ??
|
||||||
if (!wordAndSentenceFlag) return;
|
null,
|
||||||
|
);
|
||||||
updatedFields[wordAndSentenceFlag] = 'x';
|
|
||||||
for (const flagName of ['IsSentenceCard', 'IsAudioCard']) {
|
|
||||||
const resolved = resolveFieldName(flagName);
|
|
||||||
if (resolved && resolved !== wordAndSentenceFlag) {
|
|
||||||
updatedFields[resolved] = '';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function createManualUpdateService(overrides: Partial<CardCreationDeps> = {}): {
|
function createManualUpdateService(overrides: Partial<CardCreationDeps> = {}): {
|
||||||
@@ -217,7 +211,7 @@ test('manual clipboard subtitle update marks Kiku word cards as word-and-sentenc
|
|||||||
kikuFieldGrouping: 'disabled',
|
kikuFieldGrouping: 'disabled',
|
||||||
kikuDeleteDuplicateInAuto: false,
|
kikuDeleteDuplicateInAuto: false,
|
||||||
}),
|
}),
|
||||||
setCardTypeFields: setWordAndSentenceCardTypeFields,
|
setCardTypeFields,
|
||||||
});
|
});
|
||||||
|
|
||||||
await service.updateLastAddedFromClipboard('字幕');
|
await service.updateLastAddedFromClipboard('字幕');
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import {
|
|||||||
getConfiguredWordFieldName,
|
getConfiguredWordFieldName,
|
||||||
getPreferredWordValueFromExtractedFields,
|
getPreferredWordValueFromExtractedFields,
|
||||||
} from '../anki-field-config';
|
} from '../anki-field-config';
|
||||||
import { AnkiConnectConfig } from '../types/anki';
|
import { AnkiConnectConfig, type CardKind, type WordCardKind } from '../types/anki';
|
||||||
import { createLogger } from '../logger';
|
import { createLogger } from '../logger';
|
||||||
import type { MediaInput } from '../media-input';
|
import type { MediaInput } from '../media-input';
|
||||||
import { SubtitleTimingTracker } from '../subtitle-timing-tracker';
|
import { SubtitleTimingTracker } from '../subtitle-timing-tracker';
|
||||||
@@ -15,7 +15,7 @@ import {
|
|||||||
resolveAudioStreamIndexForMediaGeneration,
|
resolveAudioStreamIndexForMediaGeneration,
|
||||||
type MediaGenerationInputResolverOptions,
|
type MediaGenerationInputResolverOptions,
|
||||||
} from './media-source';
|
} from './media-source';
|
||||||
import { shouldMarkWordAndSentenceCard } from './note-field-utils';
|
import { resolveWordCardKind } from './note-field-utils';
|
||||||
import type { PendingYoutubeMediaUpdate } from './pending-youtube-media';
|
import type { PendingYoutubeMediaUpdate } from './pending-youtube-media';
|
||||||
import { resolveMpvVolumeScale } from './mpv-volume';
|
import { resolveMpvVolumeScale } from './mpv-volume';
|
||||||
|
|
||||||
@@ -42,8 +42,6 @@ export interface CardCreationNoteInfo {
|
|||||||
fields: Record<string, { value: string }>;
|
fields: Record<string, { value: string }>;
|
||||||
}
|
}
|
||||||
|
|
||||||
type CardKind = 'sentence' | 'audio' | 'word-and-sentence';
|
|
||||||
|
|
||||||
interface CardCreationClient {
|
interface CardCreationClient {
|
||||||
addNote(
|
addNote(
|
||||||
deck: string,
|
deck: string,
|
||||||
@@ -136,6 +134,7 @@ interface CardCreationDeps {
|
|||||||
kikuEnabled: boolean;
|
kikuEnabled: boolean;
|
||||||
kikuFieldGrouping: 'auto' | 'manual' | 'disabled';
|
kikuFieldGrouping: 'auto' | 'manual' | 'disabled';
|
||||||
kikuDeleteDuplicateInAuto: boolean;
|
kikuDeleteDuplicateInAuto: boolean;
|
||||||
|
wordCardKind?: WordCardKind;
|
||||||
};
|
};
|
||||||
getFallbackDurationSeconds: () => number;
|
getFallbackDurationSeconds: () => number;
|
||||||
appendKnownWordsFromNoteInfo: (noteInfo: CardCreationNoteInfo) => void;
|
appendKnownWordsFromNoteInfo: (noteInfo: CardCreationNoteInfo) => void;
|
||||||
@@ -274,12 +273,9 @@ export class CardCreationService {
|
|||||||
if (sentenceField) {
|
if (sentenceField) {
|
||||||
const processedSentence = this.deps.processSentence(sentence, fields);
|
const processedSentence = this.deps.processSentence(sentence, fields);
|
||||||
updatedFields[sentenceField] = processedSentence;
|
updatedFields[sentenceField] = processedSentence;
|
||||||
if (shouldMarkWordAndSentenceCard(noteInfo, sentenceCardConfig)) {
|
const wordCardKind = resolveWordCardKind(noteInfo, sentenceCardConfig);
|
||||||
this.deps.setCardTypeFields(
|
if (wordCardKind) {
|
||||||
updatedFields,
|
this.deps.setCardTypeFields(updatedFields, Object.keys(noteInfo.fields), wordCardKind);
|
||||||
Object.keys(noteInfo.fields),
|
|
||||||
'word-and-sentence',
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
updatePerformed = true;
|
updatePerformed = true;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
|
||||||
|
import { applyCardKindFlagFields } from './card-kinds';
|
||||||
|
|
||||||
|
function resolverFor(availableFieldNames: string[]) {
|
||||||
|
return (preferredName: string): string | null =>
|
||||||
|
availableFieldNames.find((name) => name.toLowerCase() === preferredName.toLowerCase()) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const KIKU_FLAG_FIELDS = ['IsWordAndSentenceCard', 'IsClickCard', 'IsSentenceCard', 'IsAudioCard'];
|
||||||
|
|
||||||
|
test('flags the requested card kind and clears the others', () => {
|
||||||
|
const fields: Record<string, string> = {};
|
||||||
|
|
||||||
|
applyCardKindFlagFields(fields, 'click', resolverFor(KIKU_FLAG_FIELDS));
|
||||||
|
|
||||||
|
assert.deepEqual(fields, {
|
||||||
|
IsClickCard: 'x',
|
||||||
|
IsWordAndSentenceCard: '',
|
||||||
|
IsSentenceCard: '',
|
||||||
|
IsAudioCard: '',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('matches flag fields case-insensitively', () => {
|
||||||
|
const fields: Record<string, string> = {};
|
||||||
|
|
||||||
|
applyCardKindFlagFields(fields, 'word-and-sentence', resolverFor(['iswordandsentencecard']));
|
||||||
|
|
||||||
|
assert.deepEqual(fields, { iswordandsentencecard: 'x' });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('leaves flags untouched when the note type has no flag for a word card kind', () => {
|
||||||
|
const fields: Record<string, string> = {};
|
||||||
|
|
||||||
|
applyCardKindFlagFields(
|
||||||
|
fields,
|
||||||
|
'click',
|
||||||
|
resolverFor(['IsWordAndSentenceCard', 'IsSentenceCard']),
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.deepEqual(fields, {});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('clears stale flags for explicit mine actions even without the target flag', () => {
|
||||||
|
const fields: Record<string, string> = {};
|
||||||
|
|
||||||
|
applyCardKindFlagFields(
|
||||||
|
fields,
|
||||||
|
'audio',
|
||||||
|
resolverFor(['IsWordAndSentenceCard', 'IsSentenceCard']),
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.deepEqual(fields, { IsWordAndSentenceCard: '', IsSentenceCard: '' });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('does not blank the target flag it just set', () => {
|
||||||
|
const fields: Record<string, string> = {};
|
||||||
|
|
||||||
|
applyCardKindFlagFields(fields, 'sentence', resolverFor(['IsSentenceCard']));
|
||||||
|
|
||||||
|
assert.deepEqual(fields, { IsSentenceCard: 'x' });
|
||||||
|
});
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import type { CardKind, WordCardKind } from '../types/anki';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Kiku/Lapis note types decide which card a note generates from mutually exclusive
|
||||||
|
* `Is...Card` flag fields. Setting one always means clearing the others.
|
||||||
|
*/
|
||||||
|
export const CARD_KIND_FLAG_FIELDS: Record<CardKind, string> = {
|
||||||
|
'word-and-sentence': 'IsWordAndSentenceCard',
|
||||||
|
click: 'IsClickCard',
|
||||||
|
sentence: 'IsSentenceCard',
|
||||||
|
audio: 'IsAudioCard',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const WORD_CARD_KINDS: readonly WordCardKind[] = [
|
||||||
|
'word-and-sentence',
|
||||||
|
'click',
|
||||||
|
'sentence',
|
||||||
|
'audio',
|
||||||
|
'none',
|
||||||
|
];
|
||||||
|
|
||||||
|
export const DEFAULT_WORD_CARD_KIND: WordCardKind = 'word-and-sentence';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Card kinds SubMiner marks on its own initiative (word cards). They are only applied
|
||||||
|
* when the note type actually carries the matching flag field, so plain note types keep
|
||||||
|
* their fields untouched.
|
||||||
|
*/
|
||||||
|
const IMPLICIT_CARD_KINDS = new Set<CardKind>(['word-and-sentence', 'click']);
|
||||||
|
|
||||||
|
export function isWordCardKind(value: unknown): value is WordCardKind {
|
||||||
|
return typeof value === 'string' && WORD_CARD_KINDS.includes(value as WordCardKind);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveWordCardKindSetting(value: unknown): WordCardKind {
|
||||||
|
return isWordCardKind(value) ? value : DEFAULT_WORD_CARD_KIND;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Flags `cardKind` on the note and clears every other card-kind flag it has, so the note
|
||||||
|
* never ends up claiming to be two kinds of card at once.
|
||||||
|
*/
|
||||||
|
export function applyCardKindFlagFields(
|
||||||
|
updatedFields: Record<string, string>,
|
||||||
|
cardKind: CardKind,
|
||||||
|
resolveFieldName: (preferredName: string) => string | null,
|
||||||
|
): void {
|
||||||
|
const targetFlag = resolveFieldName(CARD_KIND_FLAG_FIELDS[cardKind]);
|
||||||
|
if (!targetFlag && IMPLICIT_CARD_KINDS.has(cardKind)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (targetFlag) {
|
||||||
|
updatedFields[targetFlag] = 'x';
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [kind, flagName] of Object.entries(CARD_KIND_FLAG_FIELDS)) {
|
||||||
|
if (kind === cardKind) continue;
|
||||||
|
const resolved = resolveFieldName(flagName);
|
||||||
|
if (resolved && resolved !== targetFlag) {
|
||||||
|
updatedFields[resolved] = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
|
||||||
|
import { resolveWordCardKind, type NoteFieldValueInfo } from './note-field-utils';
|
||||||
|
|
||||||
|
function kikuNote(values: Record<string, string> = {}): NoteFieldValueInfo {
|
||||||
|
const defaults: Record<string, string> = {
|
||||||
|
Expression: '単語',
|
||||||
|
Sentence: '',
|
||||||
|
IsWordAndSentenceCard: '',
|
||||||
|
IsClickCard: '',
|
||||||
|
IsSentenceCard: '',
|
||||||
|
IsAudioCard: '',
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
fields: Object.fromEntries(
|
||||||
|
Object.entries({ ...defaults, ...values }).map(([name, value]) => [name, { value }]),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
test('marks word-and-sentence cards by default when Kiku is enabled', () => {
|
||||||
|
assert.equal(
|
||||||
|
resolveWordCardKind(kikuNote(), { lapisEnabled: false, kikuEnabled: true }),
|
||||||
|
'word-and-sentence',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('honors the configured word card kind', () => {
|
||||||
|
assert.equal(
|
||||||
|
resolveWordCardKind(kikuNote(), {
|
||||||
|
lapisEnabled: false,
|
||||||
|
kikuEnabled: true,
|
||||||
|
wordCardKind: 'click',
|
||||||
|
}),
|
||||||
|
'click',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('marks nothing when neither Kiku nor Lapis is enabled', () => {
|
||||||
|
assert.equal(
|
||||||
|
resolveWordCardKind(kikuNote(), {
|
||||||
|
lapisEnabled: false,
|
||||||
|
kikuEnabled: false,
|
||||||
|
wordCardKind: 'click',
|
||||||
|
}),
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('marks nothing when the word card kind is "none"', () => {
|
||||||
|
assert.equal(
|
||||||
|
resolveWordCardKind(kikuNote(), {
|
||||||
|
lapisEnabled: true,
|
||||||
|
kikuEnabled: false,
|
||||||
|
wordCardKind: 'none',
|
||||||
|
}),
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('falls back to the default kind for an unrecognized setting', () => {
|
||||||
|
assert.equal(
|
||||||
|
resolveWordCardKind(kikuNote(), {
|
||||||
|
lapisEnabled: false,
|
||||||
|
kikuEnabled: true,
|
||||||
|
wordCardKind: 'bogus' as never,
|
||||||
|
}),
|
||||||
|
'word-and-sentence',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('marks nothing when the note type lacks the configured flag field', () => {
|
||||||
|
const note: NoteFieldValueInfo = {
|
||||||
|
fields: { Expression: { value: '単語' }, Sentence: { value: '' } },
|
||||||
|
};
|
||||||
|
|
||||||
|
assert.equal(
|
||||||
|
resolveWordCardKind(note, { lapisEnabled: false, kikuEnabled: true, wordCardKind: 'click' }),
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('leaves cards already mined as sentence or audio cards alone', () => {
|
||||||
|
for (const flagField of ['IsSentenceCard', 'IsAudioCard']) {
|
||||||
|
assert.equal(
|
||||||
|
resolveWordCardKind(kikuNote({ [flagField]: 'x' }), {
|
||||||
|
lapisEnabled: false,
|
||||||
|
kikuEnabled: true,
|
||||||
|
wordCardKind: 'click',
|
||||||
|
}),
|
||||||
|
null,
|
||||||
|
flagField,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('re-affirms the configured kind when the note already carries its flag', () => {
|
||||||
|
assert.equal(
|
||||||
|
resolveWordCardKind(kikuNote({ IsSentenceCard: 'x' }), {
|
||||||
|
lapisEnabled: false,
|
||||||
|
kikuEnabled: true,
|
||||||
|
wordCardKind: 'sentence',
|
||||||
|
}),
|
||||||
|
'sentence',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('overrides a differently flagged word card', () => {
|
||||||
|
assert.equal(
|
||||||
|
resolveWordCardKind(kikuNote({ IsWordAndSentenceCard: 'x' }), {
|
||||||
|
lapisEnabled: false,
|
||||||
|
kikuEnabled: true,
|
||||||
|
wordCardKind: 'click',
|
||||||
|
}),
|
||||||
|
'click',
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -1,3 +1,13 @@
|
|||||||
|
import type { CardKind, WordCardKind } from '../types/anki';
|
||||||
|
import { createLogger } from '../logger';
|
||||||
|
import {
|
||||||
|
CARD_KIND_FLAG_FIELDS,
|
||||||
|
DEFAULT_WORD_CARD_KIND,
|
||||||
|
resolveWordCardKindSetting,
|
||||||
|
} from './card-kinds';
|
||||||
|
|
||||||
|
const log = createLogger('anki').child('integration.note-fields');
|
||||||
|
|
||||||
export interface NoteFieldValueInfo {
|
export interface NoteFieldValueInfo {
|
||||||
fields: Record<string, { value: string }>;
|
fields: Record<string, { value: string }>;
|
||||||
}
|
}
|
||||||
@@ -16,22 +26,57 @@ export function hasNoteFieldValue(noteInfo: NoteFieldValueInfo, preferredName: s
|
|||||||
return (getNoteFieldValue(noteInfo, preferredName) ?? '').trim().length > 0;
|
return (getNoteFieldValue(noteInfo, preferredName) ?? '').trim().length > 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function shouldMarkWordAndSentenceCard(
|
/** Flags set only by an explicit mine action; a note carrying one is not a word card. */
|
||||||
noteInfo: NoteFieldValueInfo,
|
const EXPLICIT_CARD_FLAG_FIELDS = [CARD_KIND_FLAG_FIELDS.sentence, CARD_KIND_FLAG_FIELDS.audio];
|
||||||
sentenceCardConfig: { lapisEnabled: boolean; kikuEnabled: boolean },
|
|
||||||
): boolean {
|
|
||||||
if (!sentenceCardConfig.lapisEnabled && !sentenceCardConfig.kikuEnabled) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
const wordAndSentenceValue = getNoteFieldValue(noteInfo, 'IsWordAndSentenceCard');
|
const warnedMissingFlagFields = new Set<CardKind>();
|
||||||
if (wordAndSentenceValue === null) {
|
|
||||||
return false;
|
function warnMissingFlagFieldOnce(wordCardKind: CardKind, flagField: string): void {
|
||||||
|
if (wordCardKind === DEFAULT_WORD_CARD_KIND || warnedMissingFlagFields.has(wordCardKind)) {
|
||||||
|
// The default kind is also the fallback for plain note types, so its absence is expected.
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
if (wordAndSentenceValue.trim().length > 0) {
|
warnedMissingFlagFields.add(wordCardKind);
|
||||||
return true;
|
log.warn(
|
||||||
}
|
`Word card type "${wordCardKind}" is configured but the note has no ${flagField} field; leaving card type flags unchanged.`,
|
||||||
return (
|
|
||||||
!hasNoteFieldValue(noteInfo, 'IsSentenceCard') && !hasNoteFieldValue(noteInfo, 'IsAudioCard')
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Card kind to flag when SubMiner fills a word card's sentence, or null to leave the
|
||||||
|
* card-kind flags alone. Kiku/Lapis only: other note types have no such fields.
|
||||||
|
*/
|
||||||
|
export function resolveWordCardKind(
|
||||||
|
noteInfo: NoteFieldValueInfo,
|
||||||
|
sentenceCardConfig: {
|
||||||
|
lapisEnabled: boolean;
|
||||||
|
kikuEnabled: boolean;
|
||||||
|
wordCardKind?: WordCardKind;
|
||||||
|
},
|
||||||
|
): CardKind | null {
|
||||||
|
if (!sentenceCardConfig.lapisEnabled && !sentenceCardConfig.kikuEnabled) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const wordCardKind = resolveWordCardKindSetting(sentenceCardConfig.wordCardKind);
|
||||||
|
if (wordCardKind === 'none') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const flagField = CARD_KIND_FLAG_FIELDS[wordCardKind];
|
||||||
|
const flagValue = getNoteFieldValue(noteInfo, flagField);
|
||||||
|
if (flagValue === null) {
|
||||||
|
// Note type has no flag field for the configured kind.
|
||||||
|
warnMissingFlagFieldOnce(wordCardKind, flagField);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (flagValue.trim().length > 0) {
|
||||||
|
return wordCardKind;
|
||||||
|
}
|
||||||
|
|
||||||
|
const alreadyExplicitCard = EXPLICIT_CARD_FLAG_FIELDS.some(
|
||||||
|
(fieldName) =>
|
||||||
|
fieldName.toLowerCase() !== flagField.toLowerCase() && hasNoteFieldValue(noteInfo, fieldName),
|
||||||
|
);
|
||||||
|
return alreadyExplicitCard ? null : wordCardKind;
|
||||||
|
}
|
||||||
|
|||||||
@@ -6,26 +6,21 @@ import {
|
|||||||
type NoteUpdateWorkflowNoteInfo,
|
type NoteUpdateWorkflowNoteInfo,
|
||||||
} from './note-update-workflow';
|
} from './note-update-workflow';
|
||||||
import type { SubtitleMiningContext } from '../types/subtitle';
|
import type { SubtitleMiningContext } from '../types/subtitle';
|
||||||
|
import type { CardKind } from '../types/anki';
|
||||||
|
import { applyCardKindFlagFields } from './card-kinds';
|
||||||
|
|
||||||
function setWordAndSentenceCardTypeFields(
|
function setCardTypeFields(
|
||||||
updatedFields: Record<string, string>,
|
updatedFields: Record<string, string>,
|
||||||
availableFieldNames: string[],
|
availableFieldNames: string[],
|
||||||
cardKind: 'word-and-sentence',
|
cardKind: CardKind,
|
||||||
): void {
|
): void {
|
||||||
assert.equal(cardKind, 'word-and-sentence');
|
applyCardKindFlagFields(
|
||||||
const resolveFieldName = (preferredName: string): string | null =>
|
updatedFields,
|
||||||
availableFieldNames.find((name) => name.toLowerCase() === preferredName.toLowerCase()) ?? null;
|
cardKind,
|
||||||
|
(preferredName) =>
|
||||||
const wordAndSentenceFlag = resolveFieldName('IsWordAndSentenceCard');
|
availableFieldNames.find((name) => name.toLowerCase() === preferredName.toLowerCase()) ??
|
||||||
if (!wordAndSentenceFlag) return;
|
null,
|
||||||
|
);
|
||||||
updatedFields[wordAndSentenceFlag] = 'x';
|
|
||||||
for (const flagName of ['IsSentenceCard', 'IsAudioCard']) {
|
|
||||||
const resolved = resolveFieldName(flagName);
|
|
||||||
if (resolved && resolved !== wordAndSentenceFlag) {
|
|
||||||
updatedFields[resolved] = '';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function createWorkflowHarness() {
|
function createWorkflowHarness() {
|
||||||
@@ -79,7 +74,7 @@ function createWorkflowHarness() {
|
|||||||
handleFieldGroupingManual: async (_originalNoteId, _newNoteId, _newNoteInfo, _expression) =>
|
handleFieldGroupingManual: async (_originalNoteId, _newNoteId, _newNoteInfo, _expression) =>
|
||||||
false,
|
false,
|
||||||
processSentence: (text: string, _noteFields: Record<string, string>) => text,
|
processSentence: (text: string, _noteFields: Record<string, string>) => text,
|
||||||
setCardTypeFields: setWordAndSentenceCardTypeFields,
|
setCardTypeFields,
|
||||||
resolveConfiguredFieldName: (noteInfo: NoteUpdateWorkflowNoteInfo, preferred?: string) => {
|
resolveConfiguredFieldName: (noteInfo: NoteUpdateWorkflowNoteInfo, preferred?: string) => {
|
||||||
if (!preferred) return null;
|
if (!preferred) return null;
|
||||||
const names = Object.keys(noteInfo.fields);
|
const names = Object.keys(noteInfo.fields);
|
||||||
@@ -183,6 +178,73 @@ test('NoteUpdateWorkflow marks enriched Kiku word cards as word-and-sentence car
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('NoteUpdateWorkflow marks the configured word card kind instead of word-and-sentence', async () => {
|
||||||
|
const harness = createWorkflowHarness();
|
||||||
|
harness.deps.getEffectiveSentenceCardConfig = () => ({
|
||||||
|
sentenceField: 'Sentence',
|
||||||
|
lapisEnabled: false,
|
||||||
|
kikuEnabled: true,
|
||||||
|
kikuFieldGrouping: 'manual',
|
||||||
|
wordCardKind: 'click',
|
||||||
|
});
|
||||||
|
harness.deps.client.notesInfo = async () =>
|
||||||
|
[
|
||||||
|
{
|
||||||
|
noteId: 42,
|
||||||
|
fields: {
|
||||||
|
Expression: { value: 'taberu' },
|
||||||
|
Sentence: { value: '' },
|
||||||
|
IsWordAndSentenceCard: { value: 'x' },
|
||||||
|
IsClickCard: { value: '' },
|
||||||
|
IsSentenceCard: { value: '' },
|
||||||
|
IsAudioCard: { value: '' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
] satisfies NoteUpdateWorkflowNoteInfo[];
|
||||||
|
|
||||||
|
await harness.workflow.execute(42);
|
||||||
|
|
||||||
|
assert.equal(harness.updates.length, 1);
|
||||||
|
assert.deepEqual(harness.updates[0]?.fields, {
|
||||||
|
Sentence: 'subtitle-text',
|
||||||
|
IsClickCard: 'x',
|
||||||
|
IsWordAndSentenceCard: '',
|
||||||
|
IsSentenceCard: '',
|
||||||
|
IsAudioCard: '',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('NoteUpdateWorkflow leaves card type flags alone when the word card kind is none', async () => {
|
||||||
|
const harness = createWorkflowHarness();
|
||||||
|
harness.deps.getEffectiveSentenceCardConfig = () => ({
|
||||||
|
sentenceField: 'Sentence',
|
||||||
|
lapisEnabled: false,
|
||||||
|
kikuEnabled: true,
|
||||||
|
kikuFieldGrouping: 'manual',
|
||||||
|
wordCardKind: 'none',
|
||||||
|
});
|
||||||
|
harness.deps.client.notesInfo = async () =>
|
||||||
|
[
|
||||||
|
{
|
||||||
|
noteId: 42,
|
||||||
|
fields: {
|
||||||
|
Expression: { value: 'taberu' },
|
||||||
|
Sentence: { value: '' },
|
||||||
|
IsWordAndSentenceCard: { value: '' },
|
||||||
|
IsSentenceCard: { value: '' },
|
||||||
|
IsAudioCard: { value: '' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
] satisfies NoteUpdateWorkflowNoteInfo[];
|
||||||
|
|
||||||
|
await harness.workflow.execute(42);
|
||||||
|
|
||||||
|
assert.equal(harness.updates.length, 1);
|
||||||
|
assert.deepEqual(harness.updates[0]?.fields, {
|
||||||
|
Sentence: 'subtitle-text',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
test('NoteUpdateWorkflow does not set Kiku card flags when Lapis and Kiku are disabled', async () => {
|
test('NoteUpdateWorkflow does not set Kiku card flags when Lapis and Kiku are disabled', async () => {
|
||||||
const harness = createWorkflowHarness();
|
const harness = createWorkflowHarness();
|
||||||
harness.deps.client.notesInfo = async () =>
|
harness.deps.client.notesInfo = async () =>
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { DEFAULT_ANKI_CONNECT_CONFIG } from '../config';
|
import { DEFAULT_ANKI_CONNECT_CONFIG } from '../config';
|
||||||
import { getPreferredWordValueFromExtractedFields } from '../anki-field-config';
|
import { getPreferredWordValueFromExtractedFields } from '../anki-field-config';
|
||||||
import type { SubtitleMiningContext } from '../types/subtitle';
|
import type { SubtitleMiningContext } from '../types/subtitle';
|
||||||
import { shouldMarkWordAndSentenceCard } from './note-field-utils';
|
import type { CardKind, WordCardKind } from '../types/anki';
|
||||||
|
import { resolveWordCardKind } from './note-field-utils';
|
||||||
|
|
||||||
export interface NoteUpdateWorkflowNoteInfo {
|
export interface NoteUpdateWorkflowNoteInfo {
|
||||||
noteId: number;
|
noteId: number;
|
||||||
@@ -39,6 +40,7 @@ export interface NoteUpdateWorkflowDeps {
|
|||||||
lapisEnabled: boolean;
|
lapisEnabled: boolean;
|
||||||
kikuEnabled: boolean;
|
kikuEnabled: boolean;
|
||||||
kikuFieldGrouping: 'auto' | 'manual' | 'disabled';
|
kikuFieldGrouping: 'auto' | 'manual' | 'disabled';
|
||||||
|
wordCardKind?: WordCardKind;
|
||||||
};
|
};
|
||||||
appendKnownWordsFromNoteInfo: (noteInfo: NoteUpdateWorkflowNoteInfo) => void;
|
appendKnownWordsFromNoteInfo: (noteInfo: NoteUpdateWorkflowNoteInfo) => void;
|
||||||
extractFields: (fields: Record<string, { value: string }>) => Record<string, string>;
|
extractFields: (fields: Record<string, { value: string }>) => Record<string, string>;
|
||||||
@@ -67,7 +69,7 @@ export interface NoteUpdateWorkflowDeps {
|
|||||||
setCardTypeFields: (
|
setCardTypeFields: (
|
||||||
updatedFields: Record<string, string>,
|
updatedFields: Record<string, string>,
|
||||||
availableFieldNames: string[],
|
availableFieldNames: string[],
|
||||||
cardKind: 'word-and-sentence',
|
cardKind: CardKind,
|
||||||
) => void;
|
) => void;
|
||||||
resolveConfiguredFieldName: (
|
resolveConfiguredFieldName: (
|
||||||
noteInfo: NoteUpdateWorkflowNoteInfo,
|
noteInfo: NoteUpdateWorkflowNoteInfo,
|
||||||
@@ -207,12 +209,9 @@ export class NoteUpdateWorkflow {
|
|||||||
if (sentenceField && currentSubtitleText) {
|
if (sentenceField && currentSubtitleText) {
|
||||||
const processedSentence = this.deps.processSentence(currentSubtitleText, fields);
|
const processedSentence = this.deps.processSentence(currentSubtitleText, fields);
|
||||||
updatedFields[sentenceField] = processedSentence;
|
updatedFields[sentenceField] = processedSentence;
|
||||||
if (shouldMarkWordAndSentenceCard(noteInfo, sentenceCardConfig)) {
|
const wordCardKind = resolveWordCardKind(noteInfo, sentenceCardConfig);
|
||||||
this.deps.setCardTypeFields(
|
if (wordCardKind) {
|
||||||
updatedFields,
|
this.deps.setCardTypeFields(updatedFields, Object.keys(noteInfo.fields), wordCardKind);
|
||||||
Object.keys(noteInfo.fields),
|
|
||||||
'word-and-sentence',
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
updatePerformed = true;
|
updatePerformed = true;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -116,6 +116,10 @@ export function normalizeAnkiIntegrationConfig(config: AnkiConnectConfig): AnkiC
|
|||||||
...DEFAULT_ANKI_CONNECT_CONFIG.isKiku,
|
...DEFAULT_ANKI_CONNECT_CONFIG.isKiku,
|
||||||
...(config.isKiku ?? {}),
|
...(config.isKiku ?? {}),
|
||||||
},
|
},
|
||||||
|
lapisKiku: {
|
||||||
|
...DEFAULT_ANKI_CONNECT_CONFIG.lapisKiku,
|
||||||
|
...(config.lapisKiku ?? {}),
|
||||||
|
},
|
||||||
} as AnkiConnectConfig;
|
} as AnkiConnectConfig;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -205,6 +209,10 @@ export class AnkiIntegrationRuntime {
|
|||||||
patch.isKiku !== undefined
|
patch.isKiku !== undefined
|
||||||
? { ...this.config.isKiku, ...patch.isKiku }
|
? { ...this.config.isKiku, ...patch.isKiku }
|
||||||
: this.config.isKiku,
|
: this.config.isKiku,
|
||||||
|
lapisKiku:
|
||||||
|
patch.lapisKiku !== undefined
|
||||||
|
? { ...this.config.lapisKiku, ...patch.lapisKiku }
|
||||||
|
: this.config.lapisKiku,
|
||||||
};
|
};
|
||||||
this.config = normalizeAnkiIntegrationConfig(mergedConfig);
|
this.config = normalizeAnkiIntegrationConfig(mergedConfig);
|
||||||
this.deps.onConfigChanged?.(this.config);
|
this.deps.onConfigChanged?.(this.config);
|
||||||
|
|||||||
@@ -2738,6 +2738,43 @@ test('ignores deprecated isLapis sentence-card field overrides', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('accepts a Kiku/Lapis word card kind and warns on an unknown one', () => {
|
||||||
|
const dir = makeTempDir();
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(dir, 'config.jsonc'),
|
||||||
|
`{
|
||||||
|
"ankiConnect": {
|
||||||
|
"isKiku": { "enabled": true },
|
||||||
|
"lapisKiku": { "wordCardKind": "click" }
|
||||||
|
}
|
||||||
|
}`,
|
||||||
|
'utf-8',
|
||||||
|
);
|
||||||
|
|
||||||
|
const service = new ConfigService(dir);
|
||||||
|
assert.equal(service.getConfig().ankiConnect.lapisKiku.wordCardKind, 'click');
|
||||||
|
assert.equal(service.getWarnings().length, 0);
|
||||||
|
|
||||||
|
const invalidDir = makeTempDir();
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(invalidDir, 'config.jsonc'),
|
||||||
|
`{
|
||||||
|
"ankiConnect": {
|
||||||
|
"lapisKiku": { "wordCardKind": "isClickCard" }
|
||||||
|
}
|
||||||
|
}`,
|
||||||
|
'utf-8',
|
||||||
|
);
|
||||||
|
|
||||||
|
const invalidService = new ConfigService(invalidDir);
|
||||||
|
assert.equal(invalidService.getConfig().ankiConnect.lapisKiku.wordCardKind, 'word-and-sentence');
|
||||||
|
assert.ok(
|
||||||
|
invalidService
|
||||||
|
.getWarnings()
|
||||||
|
.some((warning) => warning.path === 'ankiConnect.lapisKiku.wordCardKind'),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
test('accepts valid ankiConnect knownWords deck object', () => {
|
test('accepts valid ankiConnect knownWords deck object', () => {
|
||||||
const dir = makeTempDir();
|
const dir = makeTempDir();
|
||||||
fs.writeFileSync(
|
fs.writeFileSync(
|
||||||
|
|||||||
@@ -91,6 +91,9 @@ export const INTEGRATIONS_DEFAULT_CONFIG: Pick<
|
|||||||
fieldGrouping: 'disabled',
|
fieldGrouping: 'disabled',
|
||||||
deleteDuplicateInAuto: true,
|
deleteDuplicateInAuto: true,
|
||||||
},
|
},
|
||||||
|
lapisKiku: {
|
||||||
|
wordCardKind: 'word-and-sentence',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
jimaku: {
|
jimaku: {
|
||||||
apiBaseUrl: 'https://jimaku.cc',
|
apiBaseUrl: 'https://jimaku.cc',
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { ResolvedConfig } from '../../types/config';
|
import { ResolvedConfig } from '../../types/config';
|
||||||
|
import { WORD_CARD_KINDS } from '../../anki-integration/card-kinds';
|
||||||
import { MPV_LAUNCH_MODE_VALUES } from '../../shared/mpv-launch-mode';
|
import { MPV_LAUNCH_MODE_VALUES } from '../../shared/mpv-launch-mode';
|
||||||
import {
|
import {
|
||||||
NOTIFICATION_TYPE_VALUES,
|
NOTIFICATION_TYPE_VALUES,
|
||||||
@@ -374,6 +375,21 @@ export function buildIntegrationConfigOptionRegistry(
|
|||||||
defaultValue: defaultConfig.ankiConnect.isLapis.sentenceCardModel,
|
defaultValue: defaultConfig.ankiConnect.isLapis.sentenceCardModel,
|
||||||
description: 'Note type name used by Lapis sentence cards.',
|
description: 'Note type name used by Lapis sentence cards.',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'ankiConnect.lapisKiku.wordCardKind',
|
||||||
|
kind: 'enum',
|
||||||
|
enumValues: WORD_CARD_KINDS,
|
||||||
|
enumLabels: {
|
||||||
|
'word-and-sentence': 'Word and sentence card (IsWordAndSentenceCard)',
|
||||||
|
click: 'Click card (IsClickCard)',
|
||||||
|
sentence: 'Sentence card (IsSentenceCard)',
|
||||||
|
audio: 'Audio card (IsAudioCard)',
|
||||||
|
none: 'Leave card type flags untouched',
|
||||||
|
},
|
||||||
|
defaultValue: defaultConfig.ankiConnect.lapisKiku.wordCardKind,
|
||||||
|
description:
|
||||||
|
'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.',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: 'ankiConnect.metadata.pattern',
|
path: 'ankiConnect.metadata.pattern',
|
||||||
kind: 'string',
|
kind: 'string',
|
||||||
|
|||||||
@@ -135,7 +135,7 @@ const INTEGRATION_TEMPLATE_SECTIONS: ConfigTemplateSection[] = [
|
|||||||
title: 'AnkiConnect Integration',
|
title: 'AnkiConnect Integration',
|
||||||
description: ['Automatic Anki updates and media generation options.'],
|
description: ['Automatic Anki updates and media generation options.'],
|
||||||
notes: [
|
notes: [
|
||||||
'Hot-reload: ankiConnect.ai.enabled, media.normalizeAudio/mirrorMpvVolume, knownWords, nPlusOne, fields.word/audio/image/sentence/miscInfo, behavior.autoUpdateNewCards, isLapis.sentenceCardModel, and isKiku.fieldGrouping update live while SubMiner is running.',
|
'Hot-reload: ankiConnect.ai.enabled, media.normalizeAudio/mirrorMpvVolume, knownWords, nPlusOne, fields.word/audio/image/sentence/miscInfo, behavior.autoUpdateNewCards, isLapis.sentenceCardModel, isKiku.fieldGrouping, and lapisKiku.wordCardKind update live while SubMiner is running.',
|
||||||
'Shared AI provider transport settings are read from top-level ai and typically require restart.',
|
'Shared AI provider transport settings are read from top-level ai and typically require restart.',
|
||||||
'Most other AnkiConnect settings still require restart.',
|
'Most other AnkiConnect settings still require restart.',
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import type { ResolveContext } from './context';
|
import type { ResolveContext } from './context';
|
||||||
import { initializeAnkiConnectResolution } from './anki-connect/initialize';
|
import { initializeAnkiConnectResolution } from './anki-connect/initialize';
|
||||||
import { applyAnkiKikuResolution } from './anki-connect/kiku';
|
import { applyAnkiKikuResolution } from './anki-connect/kiku';
|
||||||
|
import { applyAnkiLapisKikuResolution } from './anki-connect/lapis-kiku';
|
||||||
import { applyAnkiKnownWordsResolution } from './anki-connect/known-words';
|
import { applyAnkiKnownWordsResolution } from './anki-connect/known-words';
|
||||||
import { applyAnkiLegacyResolution } from './anki-connect/legacy';
|
import { applyAnkiLegacyResolution } from './anki-connect/legacy';
|
||||||
import { applyAnkiModernResolution } from './anki-connect/modern';
|
import { applyAnkiModernResolution } from './anki-connect/modern';
|
||||||
@@ -22,4 +23,5 @@ export function applyAnkiConnectResolution(context: ResolveContext): void {
|
|||||||
applyAnkiLegacyResolution(context, ankiConnect, behavior, fields, media, metadata);
|
applyAnkiLegacyResolution(context, ankiConnect, behavior, fields, media, metadata);
|
||||||
applyAnkiKnownWordsResolution(context, ankiConnect, behavior);
|
applyAnkiKnownWordsResolution(context, ankiConnect, behavior);
|
||||||
applyAnkiKikuResolution(context);
|
applyAnkiKikuResolution(context);
|
||||||
|
applyAnkiLapisKikuResolution(context, ankiConnect);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -77,5 +77,8 @@ export function initializeAnkiConnectResolution(
|
|||||||
? (ankiConnect.isKiku as (typeof context.resolved)['ankiConnect']['isKiku'])
|
? (ankiConnect.isKiku as (typeof context.resolved)['ankiConnect']['isKiku'])
|
||||||
: {}),
|
: {}),
|
||||||
},
|
},
|
||||||
|
lapisKiku: {
|
||||||
|
...context.resolved.ankiConnect.lapisKiku,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import { isWordCardKind, WORD_CARD_KINDS } from '../../../anki-integration/card-kinds';
|
||||||
|
import { DEFAULT_CONFIG } from '../../definitions';
|
||||||
|
import type { ResolveContext } from '../context';
|
||||||
|
import { isObject } from '../shared';
|
||||||
|
|
||||||
|
export function applyAnkiLapisKikuResolution(
|
||||||
|
context: ResolveContext,
|
||||||
|
ankiConnect: Record<string, unknown>,
|
||||||
|
): void {
|
||||||
|
if (!isObject(ankiConnect.lapisKiku)) {
|
||||||
|
if (ankiConnect.lapisKiku !== undefined) {
|
||||||
|
context.warn(
|
||||||
|
'ankiConnect.lapisKiku',
|
||||||
|
ankiConnect.lapisKiku,
|
||||||
|
context.resolved.ankiConnect.lapisKiku,
|
||||||
|
'Expected object.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const wordCardKind = ankiConnect.lapisKiku.wordCardKind;
|
||||||
|
if (wordCardKind === undefined) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (isWordCardKind(wordCardKind)) {
|
||||||
|
context.resolved.ankiConnect.lapisKiku.wordCardKind = wordCardKind;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
context.warn(
|
||||||
|
'ankiConnect.lapisKiku.wordCardKind',
|
||||||
|
wordCardKind,
|
||||||
|
DEFAULT_CONFIG.ankiConnect.lapisKiku.wordCardKind,
|
||||||
|
`Expected one of ${WORD_CARD_KINDS.join(', ')}.`,
|
||||||
|
);
|
||||||
|
context.resolved.ankiConnect.lapisKiku.wordCardKind =
|
||||||
|
DEFAULT_CONFIG.ankiConnect.lapisKiku.wordCardKind;
|
||||||
|
}
|
||||||
@@ -221,6 +221,7 @@ const LABEL_OVERRIDES: Record<string, string> = {
|
|||||||
'ankiConnect.nPlusOne.enabled': 'Enabled',
|
'ankiConnect.nPlusOne.enabled': 'Enabled',
|
||||||
'ankiConnect.isLapis.enabled': 'Enable Lapis Features',
|
'ankiConnect.isLapis.enabled': 'Enable Lapis Features',
|
||||||
'ankiConnect.isKiku.enabled': 'Enable Kiku Features',
|
'ankiConnect.isKiku.enabled': 'Enable Kiku Features',
|
||||||
|
'ankiConnect.lapisKiku.wordCardKind': 'Word Card Type',
|
||||||
'stats.toggleKey': 'Toggle Stats Overlay',
|
'stats.toggleKey': 'Toggle Stats Overlay',
|
||||||
'shortcuts.openCharacterDictionaryManager': 'Open Character Dictionary Manager',
|
'shortcuts.openCharacterDictionaryManager': 'Open Character Dictionary Manager',
|
||||||
'subtitleSidebar.pauseVideoOnHover': 'Pause Video On Hover - Sidebar',
|
'subtitleSidebar.pauseVideoOnHover': 'Pause Video On Hover - Sidebar',
|
||||||
@@ -255,6 +256,8 @@ const DESCRIPTION_OVERRIDES: Record<string, string> = {
|
|||||||
'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.',
|
'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':
|
'ankiConnect.isLapis.sentenceCardModel':
|
||||||
'Anki note type used for Lapis sentence cards. Select from note types reported by AnkiConnect.',
|
'Anki note type used for Lapis sentence cards. Select from note types reported by AnkiConnect.',
|
||||||
|
'ankiConnect.lapisKiku.wordCardKind':
|
||||||
|
'Card-type flag marked on mined word cards. Only one flag is set at a time; the others are cleared. Requires Kiku or Lapis to be enabled.',
|
||||||
'subtitleStyle.css':
|
'subtitleStyle.css':
|
||||||
'CSS declarations applied to primary subtitles. Includes color, background-color, and all font properties.',
|
'CSS declarations applied to primary subtitles. Includes color, background-color, and all font properties.',
|
||||||
'subtitleStyle.secondary.css':
|
'subtitleStyle.secondary.css':
|
||||||
@@ -401,7 +404,11 @@ function categoryAndSection(path: string): { category: ConfigSettingsCategory; s
|
|||||||
if (path.startsWith('ankiConnect.media.')) {
|
if (path.startsWith('ankiConnect.media.')) {
|
||||||
return { category: 'mining-anki', section: 'Media Capture' };
|
return { category: 'mining-anki', section: 'Media Capture' };
|
||||||
}
|
}
|
||||||
if (path.startsWith('ankiConnect.isKiku.') || path.startsWith('ankiConnect.isLapis.')) {
|
if (
|
||||||
|
path.startsWith('ankiConnect.isKiku.') ||
|
||||||
|
path.startsWith('ankiConnect.isLapis.') ||
|
||||||
|
path.startsWith('ankiConnect.lapisKiku.')
|
||||||
|
) {
|
||||||
return { category: 'mining-anki', section: 'Kiku/Lapis Features' };
|
return { category: 'mining-anki', section: 'Kiku/Lapis Features' };
|
||||||
}
|
}
|
||||||
if (path.startsWith('ankiConnect.ai.')) {
|
if (path.startsWith('ankiConnect.ai.')) {
|
||||||
@@ -702,6 +709,7 @@ function restartBehaviorForPath(path: string): ConfigSettingsRestartBehavior {
|
|||||||
path === 'ankiConnect.fields.miscInfo' ||
|
path === 'ankiConnect.fields.miscInfo' ||
|
||||||
path === 'ankiConnect.isLapis.sentenceCardModel' ||
|
path === 'ankiConnect.isLapis.sentenceCardModel' ||
|
||||||
path === 'ankiConnect.isKiku.fieldGrouping' ||
|
path === 'ankiConnect.isKiku.fieldGrouping' ||
|
||||||
|
path === 'ankiConnect.lapisKiku.wordCardKind' ||
|
||||||
path === 'mpv.aniskipEnabled' ||
|
path === 'mpv.aniskipEnabled' ||
|
||||||
path === 'mpv.aniskipButtonKey' ||
|
path === 'mpv.aniskipButtonKey' ||
|
||||||
path === 'stats.toggleKey' ||
|
path === 'stats.toggleKey' ||
|
||||||
|
|||||||
@@ -2454,6 +2454,80 @@ Aligned English subtitle
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('POST /api/stats/mine-card marks the configured Kiku word card kind', async () => {
|
||||||
|
await withTempDir(async (dir) => {
|
||||||
|
const sourcePath = path.join(dir, 'episode.mkv');
|
||||||
|
fs.writeFileSync(sourcePath, 'fake media');
|
||||||
|
|
||||||
|
await withFakeAnkiConnect(
|
||||||
|
async (requests, url) => {
|
||||||
|
const app = createStatsApp(createMockTracker(), {
|
||||||
|
addYomitanNote: async () => 777,
|
||||||
|
createMediaGenerator: () => ({
|
||||||
|
generateAudio: async () => null,
|
||||||
|
generateScreenshot: async () => null,
|
||||||
|
generateAnimatedImage: async () => null,
|
||||||
|
}),
|
||||||
|
ankiConnectConfig: {
|
||||||
|
url,
|
||||||
|
deck: 'Mining',
|
||||||
|
fields: {
|
||||||
|
image: 'Picture',
|
||||||
|
sentence: 'Sentence',
|
||||||
|
},
|
||||||
|
media: {
|
||||||
|
generateAudio: false,
|
||||||
|
generateImage: false,
|
||||||
|
},
|
||||||
|
isKiku: {
|
||||||
|
enabled: true,
|
||||||
|
fieldGrouping: 'disabled',
|
||||||
|
deleteDuplicateInAuto: true,
|
||||||
|
},
|
||||||
|
lapisKiku: {
|
||||||
|
wordCardKind: 'click',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await app.request('/api/stats/mine-card?mode=word', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
sourcePath,
|
||||||
|
startMs: 1_000,
|
||||||
|
endMs: 2_000,
|
||||||
|
sentence: '猫を見た',
|
||||||
|
word: '猫',
|
||||||
|
videoTitle: 'Episode 1',
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const body = await res.json();
|
||||||
|
assert.equal(res.status, 200, JSON.stringify(body));
|
||||||
|
|
||||||
|
const updateRequest = requests.find((request) => request.action === 'updateNoteFields');
|
||||||
|
const fields = updateRequest?.params?.note?.fields ?? {};
|
||||||
|
assert.equal(fields.IsClickCard, 'x');
|
||||||
|
assert.equal(fields.IsWordAndSentenceCard, '');
|
||||||
|
assert.equal(fields.IsSentenceCard, '');
|
||||||
|
assert.equal(fields.IsAudioCard, '');
|
||||||
|
},
|
||||||
|
{
|
||||||
|
notesInfoFields: {
|
||||||
|
Expression: { value: '猫' },
|
||||||
|
Sentence: { value: '' },
|
||||||
|
Picture: { value: '' },
|
||||||
|
IsWordAndSentenceCard: { value: '' },
|
||||||
|
IsClickCard: { value: '' },
|
||||||
|
IsSentenceCard: { value: '' },
|
||||||
|
IsAudioCard: { value: '' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it('POST /api/stats/mine-card writes word mining sentence audio and image together', async () => {
|
it('POST /api/stats/mine-card writes word mining sentence audio and image together', async () => {
|
||||||
await withTempDir(async (dir) => {
|
await withTempDir(async (dir) => {
|
||||||
const sourcePath = path.join(dir, 'episode.mkv');
|
const sourcePath = path.join(dir, 'episode.mkv');
|
||||||
|
|||||||
@@ -85,6 +85,7 @@ const HOT_RELOAD_EXACT_OR_PREFIX_PATHS = [
|
|||||||
'ankiConnect.fields.miscInfo',
|
'ankiConnect.fields.miscInfo',
|
||||||
'ankiConnect.isLapis.sentenceCardModel',
|
'ankiConnect.isLapis.sentenceCardModel',
|
||||||
'ankiConnect.isKiku.fieldGrouping',
|
'ankiConnect.isKiku.fieldGrouping',
|
||||||
|
'ankiConnect.lapisKiku.wordCardKind',
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
function hotReloadFieldForChangedPath(path: string): string | null {
|
function hotReloadFieldForChangedPath(path: string): string | null {
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
export { Texthooker } from './texthooker';
|
export { Texthooker } from './texthooker';
|
||||||
export { hasMpvWebsocketPlugin, SubtitleWebSocket } from './subtitle-ws';
|
export {
|
||||||
|
hasMpvWebsocketPlugin,
|
||||||
|
isSubtitleAnnotationUpgrade,
|
||||||
|
SubtitleWebSocket,
|
||||||
|
} from './subtitle-ws';
|
||||||
export { registerGlobalShortcuts } from './shortcut';
|
export { registerGlobalShortcuts } from './shortcut';
|
||||||
export { createIpcDepsRuntime, registerIpcHandlers } from './ipc';
|
export { createIpcDepsRuntime, registerIpcHandlers } from './ipc';
|
||||||
export { shortcutMatchesInputForLocalFallback } from './shortcut-fallback';
|
export { shortcutMatchesInputForLocalFallback } from './shortcut-fallback';
|
||||||
|
|||||||
@@ -205,7 +205,7 @@ test('runStartupBootstrapRuntime skips lifecycle when generate-config flow handl
|
|||||||
assert.deepEqual(calls, ['setLog:warn:cli', 'forceX11', 'enforceWayland']);
|
assert.deepEqual(calls, ['setLog:warn:cli', 'forceX11', 'enforceWayland']);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('runStartupBootstrapRuntime enables quiet background mode by default', () => {
|
test('runStartupBootstrapRuntime lets config govern background log level by default', () => {
|
||||||
const calls: string[] = [];
|
const calls: string[] = [];
|
||||||
const args = makeArgs({ background: true });
|
const args = makeArgs({ background: true });
|
||||||
|
|
||||||
@@ -222,7 +222,7 @@ test('runStartupBootstrapRuntime enables quiet background mode by default', () =
|
|||||||
});
|
});
|
||||||
|
|
||||||
assert.equal(result.backgroundMode, true);
|
assert.equal(result.backgroundMode, true);
|
||||||
assert.deepEqual(calls, ['setLog:warn:cli', 'forceX11', 'enforceWayland', 'startLifecycle']);
|
assert.deepEqual(calls, ['forceX11', 'enforceWayland', 'startLifecycle']);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('runStartupBootstrapRuntime enables quiet update mode by default', () => {
|
test('runStartupBootstrapRuntime enables quiet update mode by default', () => {
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ export function runStartupBootstrapRuntime(
|
|||||||
|
|
||||||
if (initialArgs.logLevel) {
|
if (initialArgs.logLevel) {
|
||||||
deps.setLogLevel(initialArgs.logLevel, 'cli');
|
deps.setLogLevel(initialArgs.logLevel, 'cli');
|
||||||
} else if (initialArgs.background || initialArgs.update) {
|
} else if (initialArgs.update) {
|
||||||
deps.setLogLevel('warn', 'cli');
|
deps.setLogLevel('warn', 'cli');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import {
|
|||||||
resolveSecondarySubtitleTextFromSidecar,
|
resolveSecondarySubtitleTextFromSidecar,
|
||||||
} from '../secondary-subtitle-sidecar.js';
|
} from '../secondary-subtitle-sidecar.js';
|
||||||
import {
|
import {
|
||||||
applyStatsWordAndSentenceCardFields,
|
applyStatsWordCardFields,
|
||||||
createStatsMiningContext,
|
createStatsMiningContext,
|
||||||
getStatsDirectMiningAudioFieldNames,
|
getStatsDirectMiningAudioFieldNames,
|
||||||
getStatsWordMiningAudioFieldName,
|
getStatsWordMiningAudioFieldName,
|
||||||
@@ -272,7 +272,7 @@ export function registerStatsMiningRoutes(app: Hono, options?: StatsMiningRouteO
|
|||||||
const imageFieldName = ankiConfig.fields?.image ?? 'Picture';
|
const imageFieldName = ankiConfig.fields?.image ?? 'Picture';
|
||||||
|
|
||||||
mediaFields[sentenceFieldName] = highlightedSentence;
|
mediaFields[sentenceFieldName] = highlightedSentence;
|
||||||
applyStatsWordAndSentenceCardFields(mediaFields, noteInfo, ankiConfig);
|
applyStatsWordCardFields(mediaFields, noteInfo, ankiConfig);
|
||||||
|
|
||||||
if (audioBuffer) {
|
if (audioBuffer) {
|
||||||
const audioFilename = `subminer_audio_${timestamp}_${noteId}.mp3`;
|
const audioFilename = `subminer_audio_${timestamp}_${noteId}.mp3`;
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import type { MediaGenerator } from '../../../media-generator.js';
|
import type { MediaGenerator } from '../../../media-generator.js';
|
||||||
import type { AnkiConnectConfig } from '../../../types.js';
|
import type { AnkiConnectConfig } from '../../../types.js';
|
||||||
|
import { applyCardKindFlagFields } from '../../../anki-integration/card-kinds.js';
|
||||||
|
import { resolveWordCardKind } from '../../../anki-integration/note-field-utils.js';
|
||||||
import { createLogger } from '../../../logger.js';
|
import { createLogger } from '../../../logger.js';
|
||||||
import type { RetimedSecondarySubtitleInput } from '../secondary-subtitle-sidecar.js';
|
import type { RetimedSecondarySubtitleInput } from '../secondary-subtitle-sidecar.js';
|
||||||
|
|
||||||
@@ -94,20 +96,22 @@ export function shouldUseStatsLapisKikuCardFields(ankiConfig: AnkiConnectConfig)
|
|||||||
return ankiConfig.isLapis?.enabled === true || ankiConfig.isKiku?.enabled === true;
|
return ankiConfig.isLapis?.enabled === true || ankiConfig.isKiku?.enabled === true;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function applyStatsWordAndSentenceCardFields(
|
export function applyStatsWordCardFields(
|
||||||
fields: Record<string, string>,
|
fields: Record<string, string>,
|
||||||
noteInfo: StatsServerNoteInfo | null,
|
noteInfo: StatsServerNoteInfo | null,
|
||||||
ankiConfig: AnkiConnectConfig,
|
ankiConfig: AnkiConnectConfig,
|
||||||
): void {
|
): void {
|
||||||
if (!shouldUseStatsLapisKikuCardFields(ankiConfig) || !noteInfo) return;
|
if (!noteInfo) return;
|
||||||
const wordAndSentenceFlag = resolveStatsNoteFieldName(noteInfo, 'IsWordAndSentenceCard');
|
const cardKind = resolveWordCardKind(noteInfo, {
|
||||||
if (!wordAndSentenceFlag) return;
|
lapisEnabled: ankiConfig.isLapis?.enabled === true,
|
||||||
|
kikuEnabled: ankiConfig.isKiku?.enabled === true,
|
||||||
|
wordCardKind: ankiConfig.lapisKiku?.wordCardKind,
|
||||||
|
});
|
||||||
|
if (!cardKind) return;
|
||||||
|
|
||||||
fields[wordAndSentenceFlag] = 'x';
|
applyCardKindFlagFields(fields, cardKind, (preferredName) =>
|
||||||
for (const flagName of ['IsSentenceCard', 'IsAudioCard']) {
|
resolveStatsNoteFieldName(noteInfo, preferredName),
|
||||||
const resolved = resolveStatsNoteFieldName(noteInfo, flagName);
|
);
|
||||||
if (resolved && resolved !== wordAndSentenceFlag) fields[resolved] = '';
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getStatsDirectMiningAudioFieldNames(
|
export function getStatsDirectMiningAudioFieldNames(
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
runSubsyncManual,
|
runSubsyncManual,
|
||||||
triggerSubsyncFromConfig,
|
triggerSubsyncFromConfig,
|
||||||
} from './subsync';
|
} from './subsync';
|
||||||
|
import type { SubsyncManualPayload } from '../../types';
|
||||||
|
|
||||||
function makeDeps(
|
function makeDeps(
|
||||||
overrides: Partial<TriggerSubsyncFromConfigDeps> = {},
|
overrides: Partial<TriggerSubsyncFromConfigDeps> = {},
|
||||||
@@ -76,7 +77,7 @@ test('triggerSubsyncFromConfig opens manual picker', async () => {
|
|||||||
await triggerSubsyncFromConfig(
|
await triggerSubsyncFromConfig(
|
||||||
makeDeps({
|
makeDeps({
|
||||||
openManualPicker: (payload) => {
|
openManualPicker: (payload) => {
|
||||||
payloadTrackCount = payload.sourceTracks.length;
|
payloadTrackCount = payload.subtitleTracks.length;
|
||||||
ffsubsyncAvailable = payload.ffsubsyncAvailable;
|
ffsubsyncAvailable = payload.ffsubsyncAvailable;
|
||||||
},
|
},
|
||||||
showMpvOsd: (text) => {
|
showMpvOsd: (text) => {
|
||||||
@@ -88,9 +89,9 @@ test('triggerSubsyncFromConfig opens manual picker', async () => {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
assert.equal(payloadTrackCount, 1);
|
assert.equal(payloadTrackCount, 2);
|
||||||
assert.equal(ffsubsyncAvailable, true);
|
assert.equal(ffsubsyncAvailable, true);
|
||||||
assert.ok(osd.includes('Subsync: choose engine and source'));
|
assert.ok(osd.includes('Subsync: choose engine and subtitles'));
|
||||||
assert.equal(inProgressState, false);
|
assert.equal(inProgressState, false);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -140,7 +141,7 @@ test('triggerSubsyncFromConfig does not run automatic sync', async () => {
|
|||||||
await triggerSubsyncFromConfig(
|
await triggerSubsyncFromConfig(
|
||||||
makeDeps({
|
makeDeps({
|
||||||
openManualPicker: (payload) => {
|
openManualPicker: (payload) => {
|
||||||
payloadTrackCount = payload.sourceTracks.length;
|
payloadTrackCount = payload.subtitleTracks.length;
|
||||||
},
|
},
|
||||||
showMpvOsd: (text) => {
|
showMpvOsd: (text) => {
|
||||||
osd.push(text);
|
osd.push(text);
|
||||||
@@ -152,9 +153,9 @@ test('triggerSubsyncFromConfig does not run automatic sync', async () => {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
assert.equal(payloadTrackCount, 1);
|
assert.equal(payloadTrackCount, 2);
|
||||||
assert.equal(spinnerRan, false);
|
assert.equal(spinnerRan, false);
|
||||||
assert.deepEqual(osd, ['Subsync: choose engine and source']);
|
assert.deepEqual(osd, ['Subsync: choose engine and subtitles']);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('triggerSubsyncFromConfig dedupes repeated subtitle source tracks', async () => {
|
test('triggerSubsyncFromConfig dedupes repeated subtitle source tracks', async () => {
|
||||||
@@ -195,12 +196,71 @@ test('triggerSubsyncFromConfig dedupes repeated subtitle source tracks', async (
|
|||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
openManualPicker: (payload) => {
|
openManualPicker: (payload) => {
|
||||||
payloadTrackCount = payload.sourceTracks.length;
|
payloadTrackCount = payload.subtitleTracks.length;
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
assert.equal(payloadTrackCount, 1);
|
assert.equal(payloadTrackCount, 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('triggerSubsyncFromConfig keeps both active tracks when they share a file', async () => {
|
||||||
|
let payload: SubsyncManualPayload | null = null;
|
||||||
|
|
||||||
|
await triggerSubsyncFromConfig(
|
||||||
|
makeDeps({
|
||||||
|
getMpvClient: () => ({
|
||||||
|
connected: true,
|
||||||
|
currentAudioStreamIndex: null,
|
||||||
|
send: () => {},
|
||||||
|
requestProperty: async (name: string) => {
|
||||||
|
if (name === 'path') return '/tmp/video.mkv';
|
||||||
|
if (name === 'sid') return 1;
|
||||||
|
if (name === 'secondary-sid') return 2;
|
||||||
|
if (name === 'track-list') {
|
||||||
|
// mpv appends a duplicate entry when the same file is re-added, so
|
||||||
|
// the primary and secondary slots can point at one path.
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
type: 'sub',
|
||||||
|
selected: true,
|
||||||
|
external: true,
|
||||||
|
'external-filename': '/tmp/ref.srt',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
type: 'sub',
|
||||||
|
selected: true,
|
||||||
|
external: true,
|
||||||
|
'external-filename': '/tmp/ref.srt',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 3,
|
||||||
|
type: 'sub',
|
||||||
|
selected: false,
|
||||||
|
external: true,
|
||||||
|
'external-filename': '/tmp/ref.srt',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
openManualPicker: (nextPayload) => {
|
||||||
|
payload = nextPayload;
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.ok(payload);
|
||||||
|
const resolved = payload as SubsyncManualPayload;
|
||||||
|
assert.deepEqual(
|
||||||
|
resolved.subtitleTracks.map((track) => track.id),
|
||||||
|
[1, 2],
|
||||||
|
);
|
||||||
|
assert.equal(resolved.defaultReferenceTrackId, 2);
|
||||||
|
assert.equal(resolved.defaultTargetTrackId, 1);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('triggerSubsyncFromConfig reports failures to OSD', async () => {
|
test('triggerSubsyncFromConfig reports failures to OSD', async () => {
|
||||||
@@ -217,15 +277,157 @@ test('triggerSubsyncFromConfig reports failures to OSD', async () => {
|
|||||||
assert.ok(osd.some((line) => line.startsWith('Subsync failed: MPV not connected')));
|
assert.ok(osd.some((line) => line.startsWith('Subsync failed: MPV not connected')));
|
||||||
});
|
});
|
||||||
|
|
||||||
test('runSubsyncManual requires a source track for alass', async () => {
|
test('runSubsyncManual requires a reference track for alass', async () => {
|
||||||
const result = await runSubsyncManual({ engine: 'alass', sourceTrackId: null }, makeDeps());
|
const result = await runSubsyncManual({ engine: 'alass', referenceTrackId: null }, makeDeps());
|
||||||
|
|
||||||
assert.deepEqual(result, {
|
assert.deepEqual(result, {
|
||||||
ok: false,
|
ok: false,
|
||||||
message: 'Select a subtitle source track for alass',
|
message: 'Select a reference subtitle track for alass',
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('runSubsyncManual rejects alass when reference and target are the same track', async () => {
|
||||||
|
const result = await runSubsyncManual(
|
||||||
|
{ engine: 'alass', referenceTrackId: 2, targetTrackId: 2 },
|
||||||
|
makeDeps(),
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.deepEqual(result, {
|
||||||
|
ok: false,
|
||||||
|
message: 'Reference and out-of-sync subtitles must be different tracks',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('runSubsyncManual rejects an unknown target track', async () => {
|
||||||
|
const result = await runSubsyncManual(
|
||||||
|
{ engine: 'alass', referenceTrackId: 2, targetTrackId: 99 },
|
||||||
|
makeDeps(),
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.deepEqual(result, {
|
||||||
|
ok: false,
|
||||||
|
message: 'Select the out-of-sync subtitle track to retime',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('runSubsyncManual rejects the video reference for remote media', async () => {
|
||||||
|
const result = await runSubsyncManual(
|
||||||
|
{ engine: 'alass', referenceMode: 'video' },
|
||||||
|
makeDeps({
|
||||||
|
getMpvClient: () => ({
|
||||||
|
connected: true,
|
||||||
|
currentAudioStreamIndex: null,
|
||||||
|
send: () => {},
|
||||||
|
requestProperty: async (name: string) => {
|
||||||
|
if (name === 'path') return 'https://jellyfin.example/Videos/movie/stream.mkv';
|
||||||
|
if (name === 'sid') return 1;
|
||||||
|
if (name === 'secondary-sid') return null;
|
||||||
|
if (name === 'track-list') {
|
||||||
|
return [{ id: 1, type: 'sub', selected: true, lang: 'jpn' }];
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.equal(result.ok, false);
|
||||||
|
assert.match(result.message, /cannot use a stream URL as reference/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('openSubsyncManualPicker defaults the reference to the secondary subtitle track', async () => {
|
||||||
|
let payload: SubsyncManualPayload | null = null;
|
||||||
|
|
||||||
|
await triggerSubsyncFromConfig(
|
||||||
|
makeDeps({
|
||||||
|
getMpvClient: () => ({
|
||||||
|
connected: true,
|
||||||
|
currentAudioStreamIndex: null,
|
||||||
|
send: () => {},
|
||||||
|
requestProperty: async (name: string) => {
|
||||||
|
if (name === 'path') return '/tmp/video.mkv';
|
||||||
|
if (name === 'sid') return 1;
|
||||||
|
if (name === 'secondary-sid') return 3;
|
||||||
|
if (name === 'track-list') {
|
||||||
|
return [
|
||||||
|
{ id: 1, type: 'sub', selected: true, lang: 'jpn' },
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
type: 'sub',
|
||||||
|
selected: false,
|
||||||
|
external: true,
|
||||||
|
lang: 'eng',
|
||||||
|
'external-filename': '/tmp/other.srt',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 3,
|
||||||
|
type: 'sub',
|
||||||
|
selected: true,
|
||||||
|
external: true,
|
||||||
|
lang: 'eng',
|
||||||
|
'external-filename': '/tmp/secondary.srt',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
openManualPicker: (nextPayload) => {
|
||||||
|
payload = nextPayload;
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.ok(payload);
|
||||||
|
const resolved = payload as SubsyncManualPayload;
|
||||||
|
assert.deepEqual(
|
||||||
|
resolved.subtitleTracks.map((track) => track.id),
|
||||||
|
[1, 2, 3],
|
||||||
|
);
|
||||||
|
assert.equal(resolved.defaultReferenceTrackId, 3);
|
||||||
|
assert.equal(resolved.defaultTargetTrackId, 1);
|
||||||
|
assert.equal(resolved.videoReferenceAvailable, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('openSubsyncManualPicker never defaults to a reference missing from the track list', async () => {
|
||||||
|
let payload: SubsyncManualPayload | null = null;
|
||||||
|
|
||||||
|
await triggerSubsyncFromConfig(
|
||||||
|
makeDeps({
|
||||||
|
getMpvClient: () => ({
|
||||||
|
connected: true,
|
||||||
|
currentAudioStreamIndex: null,
|
||||||
|
send: () => {},
|
||||||
|
requestProperty: async (name: string) => {
|
||||||
|
if (name === 'path') return '/tmp/video.mkv';
|
||||||
|
if (name === 'sid') return 1;
|
||||||
|
if (name === 'secondary-sid') return 2;
|
||||||
|
if (name === 'track-list') {
|
||||||
|
return [
|
||||||
|
{ id: 1, type: 'sub', selected: true, lang: 'jpn' },
|
||||||
|
// Secondary track with no usable file path: filtered out of the picker.
|
||||||
|
{ id: 2, type: 'sub', selected: true, external: true, 'external-filename': '' },
|
||||||
|
{ id: 3, type: 'sub', selected: false, lang: 'eng' },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
openManualPicker: (nextPayload) => {
|
||||||
|
payload = nextPayload;
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.ok(payload);
|
||||||
|
const resolved = payload as SubsyncManualPayload;
|
||||||
|
assert.deepEqual(
|
||||||
|
resolved.subtitleTracks.map((track) => track.id),
|
||||||
|
[1, 3],
|
||||||
|
);
|
||||||
|
assert.equal(resolved.defaultReferenceTrackId, 3);
|
||||||
|
});
|
||||||
|
|
||||||
test('triggerSubsyncFromConfig does not validate sync tool paths before manual selection', async () => {
|
test('triggerSubsyncFromConfig does not validate sync tool paths before manual selection', async () => {
|
||||||
const osd: string[] = [];
|
const osd: string[] = [];
|
||||||
const inProgress: boolean[] = [];
|
const inProgress: boolean[] = [];
|
||||||
@@ -242,7 +444,7 @@ test('triggerSubsyncFromConfig does not validate sync tool paths before manual s
|
|||||||
inProgress.push(value);
|
inProgress.push(value);
|
||||||
},
|
},
|
||||||
openManualPicker: (payload) => {
|
openManualPicker: (payload) => {
|
||||||
payloadTrackCount = payload.sourceTracks.length;
|
payloadTrackCount = payload.subtitleTracks.length;
|
||||||
},
|
},
|
||||||
showMpvOsd: (text) => {
|
showMpvOsd: (text) => {
|
||||||
osd.push(text);
|
osd.push(text);
|
||||||
@@ -251,8 +453,8 @@ test('triggerSubsyncFromConfig does not validate sync tool paths before manual s
|
|||||||
);
|
);
|
||||||
|
|
||||||
assert.deepEqual(inProgress, [false]);
|
assert.deepEqual(inProgress, [false]);
|
||||||
assert.equal(payloadTrackCount, 1);
|
assert.equal(payloadTrackCount, 2);
|
||||||
assert.deepEqual(osd, ['Subsync: choose engine and source']);
|
assert.deepEqual(osd, ['Subsync: choose engine and subtitles']);
|
||||||
});
|
});
|
||||||
|
|
||||||
function writeExecutableScript(filePath: string, content: string): void {
|
function writeExecutableScript(filePath: string, content: string): void {
|
||||||
@@ -333,7 +535,7 @@ test('runSubsyncManual constructs ffsubsync command and returns success', async
|
|||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
const result = await runSubsyncManual({ engine: 'ffsubsync', sourceTrackId: null }, deps);
|
const result = await runSubsyncManual({ engine: 'ffsubsync' }, deps);
|
||||||
|
|
||||||
assert.equal(result.ok, true);
|
assert.equal(result.ok, true);
|
||||||
assert.equal(result.message, 'Subtitle synchronized with ffsubsync');
|
assert.equal(result.message, 'Subtitle synchronized with ffsubsync');
|
||||||
@@ -346,7 +548,7 @@ test('runSubsyncManual constructs ffsubsync command and returns success', async
|
|||||||
const ffOutputFlagIndex = ffArgs.indexOf('-o');
|
const ffOutputFlagIndex = ffArgs.indexOf('-o');
|
||||||
assert.equal(ffOutputFlagIndex >= 0, true);
|
assert.equal(ffOutputFlagIndex >= 0, true);
|
||||||
assert.equal(ffArgs[ffOutputFlagIndex + 1], toShellPath(primaryPath));
|
assert.equal(ffArgs[ffOutputFlagIndex + 1], toShellPath(primaryPath));
|
||||||
assert.equal(sentCommands[0]?.[0], 'sub_add');
|
assert.equal(sentCommands[0]?.[0], 'sub-add');
|
||||||
assert.deepEqual(sentCommands[1], ['set_property', 'sub-delay', 0]);
|
assert.deepEqual(sentCommands[1], ['set_property', 'sub-delay', 0]);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -399,7 +601,7 @@ test('runSubsyncManual writes deterministic _retimed filename when replace is fa
|
|||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
const result = await runSubsyncManual({ engine: 'ffsubsync', sourceTrackId: null }, deps);
|
const result = await runSubsyncManual({ engine: 'ffsubsync' }, deps);
|
||||||
|
|
||||||
assert.equal(result.ok, true);
|
assert.equal(result.ok, true);
|
||||||
const ffArgs = fs.readFileSync(ffsubsyncLogPath, 'utf8').trim().split('\n');
|
const ffArgs = fs.readFileSync(ffsubsyncLogPath, 'utf8').trim().split('\n');
|
||||||
@@ -453,7 +655,7 @@ test('runSubsyncManual reports ffsubsync command failures with details', async (
|
|||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
const result = await runSubsyncManual({ engine: 'ffsubsync', sourceTrackId: null }, deps);
|
const result = await runSubsyncManual({ engine: 'ffsubsync' }, deps);
|
||||||
|
|
||||||
assert.equal(result.ok, false);
|
assert.equal(result.ok, false);
|
||||||
assert.equal(result.message.startsWith('ffsubsync synchronization failed'), true);
|
assert.equal(result.message.startsWith('ffsubsync synchronization failed'), true);
|
||||||
@@ -518,7 +720,7 @@ test('runSubsyncManual constructs alass command and returns failure on non-zero
|
|||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
const result = await runSubsyncManual({ engine: 'alass', sourceTrackId: 2 }, deps);
|
const result = await runSubsyncManual({ engine: 'alass', referenceTrackId: 2 }, deps);
|
||||||
|
|
||||||
assert.equal(result.ok, false);
|
assert.equal(result.ok, false);
|
||||||
assert.equal(typeof result.message, 'string');
|
assert.equal(typeof result.message, 'string');
|
||||||
@@ -528,6 +730,179 @@ test('runSubsyncManual constructs alass command and returns failure on non-zero
|
|||||||
assert.equal(alassArgs[1], toShellPath(primaryPath));
|
assert.equal(alassArgs[1], toShellPath(primaryPath));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function makeAlassSelectionDeps(tmpDir: string): {
|
||||||
|
deps: TriggerSubsyncFromConfigDeps;
|
||||||
|
alassLogPath: string;
|
||||||
|
videoPath: string;
|
||||||
|
primaryPath: string;
|
||||||
|
sourcePath: string;
|
||||||
|
sentCommands: Array<Array<string | number>>;
|
||||||
|
} {
|
||||||
|
const alassLogPath = path.join(tmpDir, 'alass-args.log');
|
||||||
|
const alassPath = path.join(tmpDir, 'alass.sh');
|
||||||
|
const ffmpegPath = path.join(tmpDir, 'ffmpeg.sh');
|
||||||
|
const ffsubsyncPath = path.join(tmpDir, 'ffsubsync.sh');
|
||||||
|
const videoPath = path.join(tmpDir, 'video.mkv');
|
||||||
|
const primaryPath = path.join(tmpDir, 'primary.srt');
|
||||||
|
const sourcePath = path.join(tmpDir, 'source.srt');
|
||||||
|
|
||||||
|
fs.writeFileSync(videoPath, 'video');
|
||||||
|
fs.writeFileSync(primaryPath, 'sub');
|
||||||
|
fs.writeFileSync(sourcePath, 'sub2');
|
||||||
|
writeExecutableScript(ffmpegPath, '#!/bin/sh\nexit 0\n');
|
||||||
|
writeExecutableScript(ffsubsyncPath, '#!/bin/sh\nexit 0\n');
|
||||||
|
writeExecutableScript(
|
||||||
|
alassPath,
|
||||||
|
`#!/bin/sh\n: > "${toShellPath(alassLogPath)}"\nfor arg in "$@"; do printf '%s\\n' "$arg" >> "${toShellPath(alassLogPath)}"; done\n: > "$3"\nexit 0\n`,
|
||||||
|
);
|
||||||
|
|
||||||
|
const trackList: Array<Record<string, unknown>> = [
|
||||||
|
{ id: 1, type: 'sub', selected: true, external: true, 'external-filename': primaryPath },
|
||||||
|
{ id: 2, type: 'sub', selected: true, external: true, 'external-filename': sourcePath },
|
||||||
|
];
|
||||||
|
const sentCommands: Array<Array<string | number>> = [];
|
||||||
|
const deps = makeDeps({
|
||||||
|
getMpvClient: () => ({
|
||||||
|
connected: true,
|
||||||
|
currentAudioStreamIndex: null,
|
||||||
|
send: (payload) => {
|
||||||
|
sentCommands.push(payload.command);
|
||||||
|
if (payload.command[0] === 'sub-add' || payload.command[0] === 'sub_add') {
|
||||||
|
trackList.push({
|
||||||
|
id: trackList.length + 1,
|
||||||
|
type: 'sub',
|
||||||
|
selected: false,
|
||||||
|
external: true,
|
||||||
|
'external-filename': payload.command[1],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
requestProperty: async (name: string) => {
|
||||||
|
if (name === 'path') return videoPath;
|
||||||
|
if (name === 'sid') return 1;
|
||||||
|
if (name === 'secondary-sid') return 2;
|
||||||
|
if (name === 'track-list') return trackList;
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
getResolvedConfig: () => ({
|
||||||
|
alassPath,
|
||||||
|
ffsubsyncPath,
|
||||||
|
ffmpegPath,
|
||||||
|
replace: false,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
return { deps, alassLogPath, videoPath, primaryPath, sourcePath, sentCommands };
|
||||||
|
}
|
||||||
|
|
||||||
|
test('runSubsyncManual uses the video file as alass reference when requested', async () => {
|
||||||
|
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'subsync-alass-video-ref-'));
|
||||||
|
const { deps, alassLogPath, videoPath, primaryPath } = makeAlassSelectionDeps(tmpDir);
|
||||||
|
|
||||||
|
const result = await runSubsyncManual({ engine: 'alass', referenceMode: 'video' }, deps);
|
||||||
|
|
||||||
|
assert.equal(result.ok, true);
|
||||||
|
const alassArgs = fs.readFileSync(alassLogPath, 'utf8').trim().split('\n');
|
||||||
|
assert.equal(alassArgs[0], toShellPath(videoPath));
|
||||||
|
assert.equal(alassArgs[1], toShellPath(primaryPath));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('runSubsyncManual retimes the selected target track instead of the primary', async () => {
|
||||||
|
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'subsync-alass-target-'));
|
||||||
|
const { deps, alassLogPath, primaryPath, sourcePath, sentCommands } =
|
||||||
|
makeAlassSelectionDeps(tmpDir);
|
||||||
|
|
||||||
|
const result = await runSubsyncManual(
|
||||||
|
{ engine: 'alass', referenceTrackId: 1, targetTrackId: 2 },
|
||||||
|
deps,
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.equal(result.ok, true);
|
||||||
|
const alassArgs = fs.readFileSync(alassLogPath, 'utf8').trim().split('\n');
|
||||||
|
assert.equal(alassArgs[0], toShellPath(primaryPath));
|
||||||
|
assert.equal(alassArgs[1], toShellPath(sourcePath));
|
||||||
|
assert.equal(sentCommands[0]?.[0], 'sub-add');
|
||||||
|
assert.equal(sentCommands[0]?.[2], 'auto');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('runSubsyncManual keeps a retimed secondary track in the secondary slot', async () => {
|
||||||
|
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'subsync-alass-secondary-slot-'));
|
||||||
|
const alassPath = path.join(tmpDir, 'alass.sh');
|
||||||
|
const ffmpegPath = path.join(tmpDir, 'ffmpeg.sh');
|
||||||
|
const ffsubsyncPath = path.join(tmpDir, 'ffsubsync.sh');
|
||||||
|
const videoPath = path.join(tmpDir, 'video.mkv');
|
||||||
|
const primaryPath = path.join(tmpDir, 'ja.srt');
|
||||||
|
const secondaryPath = path.join(tmpDir, 'en.srt');
|
||||||
|
const retimedPath = path.join(tmpDir, 'en_retimed.srt');
|
||||||
|
|
||||||
|
fs.writeFileSync(videoPath, 'video');
|
||||||
|
fs.writeFileSync(primaryPath, 'ja');
|
||||||
|
fs.writeFileSync(secondaryPath, 'en');
|
||||||
|
writeExecutableScript(ffmpegPath, '#!/bin/sh\nexit 0\n');
|
||||||
|
writeExecutableScript(ffsubsyncPath, '#!/bin/sh\nexit 0\n');
|
||||||
|
writeExecutableScript(alassPath, '#!/bin/sh\n: > "$3"\nexit 0\n');
|
||||||
|
|
||||||
|
const trackList: Array<Record<string, unknown>> = [
|
||||||
|
{ id: 1, type: 'sub', selected: true, external: true, 'external-filename': primaryPath },
|
||||||
|
{ id: 2, type: 'sub', selected: true, external: true, 'external-filename': secondaryPath },
|
||||||
|
];
|
||||||
|
const sentCommands: Array<Array<string | number>> = [];
|
||||||
|
const deps = makeDeps({
|
||||||
|
getMpvClient: () => ({
|
||||||
|
connected: true,
|
||||||
|
currentAudioStreamIndex: null,
|
||||||
|
send: (payload) => {
|
||||||
|
sentCommands.push(payload.command);
|
||||||
|
if (payload.command[0] === 'sub-add') {
|
||||||
|
trackList.push({
|
||||||
|
id: 3,
|
||||||
|
type: 'sub',
|
||||||
|
selected: false,
|
||||||
|
external: true,
|
||||||
|
'external-filename': payload.command[1],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
requestProperty: async (name: string) => {
|
||||||
|
if (name === 'path') return videoPath;
|
||||||
|
if (name === 'sid') return 1;
|
||||||
|
if (name === 'secondary-sid') return 2;
|
||||||
|
if (name === 'track-list') return trackList;
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
getResolvedConfig: () => ({
|
||||||
|
alassPath,
|
||||||
|
ffsubsyncPath,
|
||||||
|
ffmpegPath,
|
||||||
|
replace: false,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await runSubsyncManual(
|
||||||
|
{ engine: 'alass', referenceTrackId: 1, targetTrackId: 2 },
|
||||||
|
deps,
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.equal(result.ok, true);
|
||||||
|
assert.deepEqual(sentCommands[0], ['sub-add', retimedPath, 'auto']);
|
||||||
|
assert.deepEqual(sentCommands[1], ['set_property', 'secondary-sub-delay', 0]);
|
||||||
|
assert.deepEqual(sentCommands[2], ['set_property', 'secondary-sid', 3]);
|
||||||
|
assert.equal(
|
||||||
|
sentCommands.some((command) => command[1] === 'sub-delay'),
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
sentCommands.some((command) => command[1] === 'sid'),
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
sentCommands.some((command) => command[1] === 'sid'),
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
test('runSubsyncManual keeps internal alass source file alive until sync finishes', async () => {
|
test('runSubsyncManual keeps internal alass source file alive until sync finishes', async () => {
|
||||||
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'subsync-alass-internal-source-'));
|
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'subsync-alass-internal-source-'));
|
||||||
const alassPath = path.join(tmpDir, 'alass.sh');
|
const alassPath = path.join(tmpDir, 'alass.sh');
|
||||||
@@ -589,11 +964,11 @@ test('runSubsyncManual keeps internal alass source file alive until sync finishe
|
|||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
const result = await runSubsyncManual({ engine: 'alass', sourceTrackId: 2 }, deps);
|
const result = await runSubsyncManual({ engine: 'alass', referenceTrackId: 2 }, deps);
|
||||||
|
|
||||||
assert.equal(result.ok, true);
|
assert.equal(result.ok, true);
|
||||||
assert.equal(result.message, 'Subtitle synchronized with alass');
|
assert.equal(result.message, 'Subtitle synchronized with alass');
|
||||||
assert.equal(sentCommands[0]?.[0], 'sub_add');
|
assert.equal(sentCommands[0]?.[0], 'sub-add');
|
||||||
assert.deepEqual(sentCommands[1], ['set_property', 'sub-delay', 0]);
|
assert.deepEqual(sentCommands[1], ['set_property', 'sub-delay', 0]);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -645,7 +1020,7 @@ test('runSubsyncManual resolves string sid values from mpv stream properties', a
|
|||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
const result = await runSubsyncManual({ engine: 'ffsubsync', sourceTrackId: null }, deps);
|
const result = await runSubsyncManual({ engine: 'ffsubsync' }, deps);
|
||||||
|
|
||||||
assert.equal(result.ok, true);
|
assert.equal(result.ok, true);
|
||||||
assert.equal(result.message, 'Subtitle synchronized with ffsubsync');
|
assert.equal(result.message, 'Subtitle synchronized with ffsubsync');
|
||||||
|
|||||||
+195
-34
@@ -21,6 +21,11 @@ interface FileExtractionResult {
|
|||||||
temporary: boolean;
|
temporary: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type SubtitleSlot = 'primary' | 'secondary';
|
||||||
|
|
||||||
|
const SYNCED_TRACK_LOOKUP_ATTEMPTS = 5;
|
||||||
|
const SYNCED_TRACK_LOOKUP_RETRY_MS = 100;
|
||||||
|
|
||||||
function summarizeCommandFailure(command: string, result: CommandResult): string {
|
function summarizeCommandFailure(command: string, result: CommandResult): string {
|
||||||
const parts = [
|
const parts = [
|
||||||
`code=${result.code ?? 'n/a'}`,
|
`code=${result.code ?? 'n/a'}`,
|
||||||
@@ -90,16 +95,28 @@ function getSourceTrackIdentity(track: MpvTrack): string {
|
|||||||
return 'unknown';
|
return 'unknown';
|
||||||
}
|
}
|
||||||
|
|
||||||
function dedupeSourceTracks(tracks: MpvTrack[]): MpvTrack[] {
|
function isPinned(track: MpvTrack, pinnedIds: Set<number>): boolean {
|
||||||
const deduped = new Map<string, MpvTrack>();
|
return typeof track.id === 'number' && pinnedIds.has(track.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pinned tracks (the active primary/secondary) always survive, even when two of
|
||||||
|
// them point at the same file; only unpinned duplicates are collapsed.
|
||||||
|
function dedupeSubtitleTracks(tracks: MpvTrack[], pinnedIds: Set<number>): MpvTrack[] {
|
||||||
|
const pinnedIdentities = new Set(
|
||||||
|
tracks.filter((track) => isPinned(track, pinnedIds)).map(getSourceTrackIdentity),
|
||||||
|
);
|
||||||
|
const winners = new Map<string, MpvTrack>();
|
||||||
for (const track of tracks) {
|
for (const track of tracks) {
|
||||||
|
if (isPinned(track, pinnedIds)) continue;
|
||||||
const identity = getSourceTrackIdentity(track);
|
const identity = getSourceTrackIdentity(track);
|
||||||
const existing = deduped.get(identity);
|
if (pinnedIdentities.has(identity)) continue;
|
||||||
|
const existing = winners.get(identity);
|
||||||
if (!existing || (track.selected && !existing.selected)) {
|
if (!existing || (track.selected && !existing.selected)) {
|
||||||
deduped.set(identity, track);
|
winners.set(identity, track);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return [...deduped.values()];
|
const kept = new Set(winners.values());
|
||||||
|
return tracks.filter((track) => isPinned(track, pinnedIds) || kept.has(track));
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TriggerSubsyncFromConfigDeps extends SubsyncCoreDeps {
|
export interface TriggerSubsyncFromConfigDeps extends SubsyncCoreDeps {
|
||||||
@@ -142,20 +159,21 @@ async function gatherSubsyncContext(client: MpvClientLike): Promise<SubsyncConte
|
|||||||
}
|
}
|
||||||
|
|
||||||
const secondaryTrack = subtitleTracks.find((track) => track.id === secondarySid) ?? null;
|
const secondaryTrack = subtitleTracks.find((track) => track.id === secondarySid) ?? null;
|
||||||
const sourceTracks = subtitleTracks
|
const usableTracks = subtitleTracks.filter((track) => {
|
||||||
.filter((track) => track.id !== sid)
|
if (typeof track.id !== 'number') return false;
|
||||||
.filter((track) => {
|
|
||||||
if (!track.external) return true;
|
if (!track.external) return true;
|
||||||
const filename = track['external-filename'];
|
const filename = track['external-filename'];
|
||||||
return typeof filename === 'string' && filename.length > 0;
|
return typeof filename === 'string' && filename.length > 0;
|
||||||
});
|
});
|
||||||
const uniqueSourceTracks = dedupeSourceTracks(sourceTracks);
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
videoPath,
|
videoPath,
|
||||||
primaryTrack,
|
primaryTrack,
|
||||||
secondaryTrack,
|
secondaryTrack,
|
||||||
sourceTracks: uniqueSourceTracks,
|
subtitleTracks: dedupeSubtitleTracks(
|
||||||
|
usableTracks,
|
||||||
|
new Set([sid, secondarySid].filter((id): id is number => typeof id === 'number')),
|
||||||
|
),
|
||||||
audioStreamIndex: client.currentAudioStreamIndex,
|
audioStreamIndex: client.currentAudioStreamIndex,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -271,41 +289,104 @@ async function runFfsubsyncSync(
|
|||||||
return runCommand(ffsubsyncPath, args);
|
return runCommand(ffsubsyncPath, args);
|
||||||
}
|
}
|
||||||
|
|
||||||
function loadSyncedSubtitle(client: MpvClientLike, pathToLoad: string): void {
|
function delay(ms: number): Promise<void> {
|
||||||
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
|
}
|
||||||
|
|
||||||
|
// mpv may echo the path back with different separators, and Windows paths are
|
||||||
|
// case-insensitive, so compare normalized forms instead of raw strings.
|
||||||
|
function normalizeSubtitlePathForCompare(value: string): string {
|
||||||
|
const normalized = value.replace(/\\/g, '/');
|
||||||
|
return process.platform === 'win32' ? normalized.toLowerCase() : normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function findAddedSubtitleTrackId(
|
||||||
|
client: MpvClientLike,
|
||||||
|
pathToLoad: string,
|
||||||
|
): Promise<number | null> {
|
||||||
|
const wanted = normalizeSubtitlePathForCompare(pathToLoad);
|
||||||
|
// sub-add is queued, so the track may not appear in the first track-list reply.
|
||||||
|
for (let attempt = 0; attempt < SYNCED_TRACK_LOOKUP_ATTEMPTS; attempt += 1) {
|
||||||
|
let tracks: MpvTrack[] = [];
|
||||||
|
try {
|
||||||
|
const trackListRaw = await client.requestProperty('track-list');
|
||||||
|
tracks = Array.isArray(trackListRaw) ? normalizeTrackIds(trackListRaw as MpvTrack[]) : [];
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
// Re-adding a file mpv already knows appends a duplicate entry; the newest
|
||||||
|
// one holds the retimed content, so prefer the last match.
|
||||||
|
const matches = tracks.filter((track) => {
|
||||||
|
if (track.type !== 'sub') return false;
|
||||||
|
const filename = track['external-filename'];
|
||||||
|
return typeof filename === 'string' && normalizeSubtitlePathForCompare(filename) === wanted;
|
||||||
|
});
|
||||||
|
const added = matches[matches.length - 1];
|
||||||
|
if (added && typeof added.id === 'number') {
|
||||||
|
return added.id;
|
||||||
|
}
|
||||||
|
if (attempt < SYNCED_TRACK_LOOKUP_ATTEMPTS - 1) {
|
||||||
|
await delay(SYNCED_TRACK_LOOKUP_RETRY_MS);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadSyncedSubtitle(
|
||||||
|
client: MpvClientLike,
|
||||||
|
pathToLoad: string,
|
||||||
|
slot: SubtitleSlot,
|
||||||
|
): Promise<void> {
|
||||||
if (!client.connected) {
|
if (!client.connected) {
|
||||||
throw new Error('MPV disconnected while loading subtitle');
|
throw new Error('MPV disconnected while loading subtitle');
|
||||||
}
|
}
|
||||||
client.send({ command: ['sub_add', pathToLoad] });
|
|
||||||
|
if (slot === 'secondary') {
|
||||||
|
// Keep the primary track untouched: load without selecting, then point
|
||||||
|
// secondary-sid at the freshly added track.
|
||||||
|
client.send({ command: ['sub-add', pathToLoad, 'auto'] });
|
||||||
|
client.send({ command: ['set_property', 'secondary-sub-delay', 0] });
|
||||||
|
const addedTrackId = await findAddedSubtitleTrackId(client, pathToLoad);
|
||||||
|
if (addedTrackId === null) {
|
||||||
|
throw new Error('Synchronized subtitle did not appear in the mpv track list');
|
||||||
|
}
|
||||||
|
client.send({ command: ['set_property', 'secondary-sid', addedTrackId] });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
client.send({ command: ['sub-add', pathToLoad] });
|
||||||
client.send({ command: ['set_property', 'sub-delay', 0] });
|
client.send({ command: ['set_property', 'sub-delay', 0] });
|
||||||
}
|
}
|
||||||
|
|
||||||
async function subsyncToReference(
|
async function subsyncToReference(
|
||||||
engine: 'alass' | 'ffsubsync',
|
engine: 'alass' | 'ffsubsync',
|
||||||
referenceFilePath: string,
|
referenceFilePath: string,
|
||||||
|
targetTrack: MpvTrack,
|
||||||
context: SubsyncContext,
|
context: SubsyncContext,
|
||||||
resolved: SubsyncResolvedConfig,
|
resolved: SubsyncResolvedConfig,
|
||||||
client: MpvClientLike,
|
client: MpvClientLike,
|
||||||
|
slot: SubtitleSlot,
|
||||||
): Promise<SubsyncResult> {
|
): Promise<SubsyncResult> {
|
||||||
const ffmpegPath = ensureExecutablePath(resolved.ffmpegPath, 'ffmpeg');
|
const ffmpegPath = ensureExecutablePath(resolved.ffmpegPath, 'ffmpeg');
|
||||||
const primaryExtraction = await extractSubtitleTrackToFile(
|
const targetExtraction = await extractSubtitleTrackToFile(
|
||||||
ffmpegPath,
|
ffmpegPath,
|
||||||
context.videoPath,
|
context.videoPath,
|
||||||
context.primaryTrack,
|
targetTrack,
|
||||||
);
|
);
|
||||||
const replacePrimary = resolved.replace !== false && !primaryExtraction.temporary;
|
const replaceTarget = resolved.replace !== false && !targetExtraction.temporary;
|
||||||
const outputPath = buildRetimedPath(primaryExtraction.path, replacePrimary);
|
const outputPath = buildRetimedPath(targetExtraction.path, replaceTarget);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
let result: CommandResult;
|
let result: CommandResult;
|
||||||
if (engine === 'alass') {
|
if (engine === 'alass') {
|
||||||
const alassPath = ensureExecutablePath(resolved.alassPath, 'alass');
|
const alassPath = ensureExecutablePath(resolved.alassPath, 'alass');
|
||||||
result = await runAlassSync(alassPath, referenceFilePath, primaryExtraction.path, outputPath);
|
result = await runAlassSync(alassPath, referenceFilePath, targetExtraction.path, outputPath);
|
||||||
} else {
|
} else {
|
||||||
const ffsubsyncPath = ensureExecutablePath(resolved.ffsubsyncPath, 'ffsubsync');
|
const ffsubsyncPath = ensureExecutablePath(resolved.ffsubsyncPath, 'ffsubsync');
|
||||||
result = await runFfsubsyncSync(
|
result = await runFfsubsyncSync(
|
||||||
ffsubsyncPath,
|
ffsubsyncPath,
|
||||||
context.videoPath,
|
context.videoPath,
|
||||||
primaryExtraction.path,
|
targetExtraction.path,
|
||||||
outputPath,
|
outputPath,
|
||||||
context.audioStreamIndex,
|
context.audioStreamIndex,
|
||||||
);
|
);
|
||||||
@@ -319,13 +400,13 @@ async function subsyncToReference(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
loadSyncedSubtitle(client, outputPath);
|
await loadSyncedSubtitle(client, outputPath, slot);
|
||||||
return {
|
return {
|
||||||
ok: true,
|
ok: true,
|
||||||
message: `Subtitle synchronized with ${engine}`,
|
message: `Subtitle synchronized with ${engine}`,
|
||||||
};
|
};
|
||||||
} finally {
|
} finally {
|
||||||
cleanupTemporaryFile(primaryExtraction);
|
cleanupTemporaryFile(targetExtraction);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -337,6 +418,25 @@ function validateFfsubsyncReference(videoPath: string): void {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function resolveTargetTrack(
|
||||||
|
request: SubsyncManualRunRequest,
|
||||||
|
context: SubsyncContext,
|
||||||
|
): MpvTrack | null {
|
||||||
|
if (request.targetTrackId === undefined || request.targetTrackId === null) {
|
||||||
|
return context.primaryTrack;
|
||||||
|
}
|
||||||
|
return getTrackById(context.subtitleTracks, request.targetTrackId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Retiming the secondary track must not steal the primary slot: the synced file
|
||||||
|
// goes back where the out-of-sync one was.
|
||||||
|
function resolveTargetSlot(targetTrack: MpvTrack, context: SubsyncContext): SubtitleSlot {
|
||||||
|
if (typeof targetTrack.id !== 'number') return 'primary';
|
||||||
|
if (targetTrack.id === context.primaryTrack.id) return 'primary';
|
||||||
|
if (context.secondaryTrack && targetTrack.id === context.secondaryTrack.id) return 'secondary';
|
||||||
|
return 'primary';
|
||||||
|
}
|
||||||
|
|
||||||
export async function runSubsyncManual(
|
export async function runSubsyncManual(
|
||||||
request: SubsyncManualRunRequest,
|
request: SubsyncManualRunRequest,
|
||||||
deps: SubsyncCoreDeps,
|
deps: SubsyncCoreDeps,
|
||||||
@@ -345,6 +445,12 @@ export async function runSubsyncManual(
|
|||||||
const context = await gatherSubsyncContext(client);
|
const context = await gatherSubsyncContext(client);
|
||||||
const resolved = deps.getResolvedConfig();
|
const resolved = deps.getResolvedConfig();
|
||||||
|
|
||||||
|
const targetTrack = resolveTargetTrack(request, context);
|
||||||
|
if (!targetTrack) {
|
||||||
|
return { ok: false, message: 'Select the out-of-sync subtitle track to retime' };
|
||||||
|
}
|
||||||
|
const targetSlot = resolveTargetSlot(targetTrack, context);
|
||||||
|
|
||||||
if (request.engine === 'ffsubsync') {
|
if (request.engine === 'ffsubsync') {
|
||||||
try {
|
try {
|
||||||
validateFfsubsyncReference(context.videoPath);
|
validateFfsubsyncReference(context.videoPath);
|
||||||
@@ -354,22 +460,64 @@ export async function runSubsyncManual(
|
|||||||
message: `ffsubsync synchronization failed: ${(error as Error).message}`,
|
message: `ffsubsync synchronization failed: ${(error as Error).message}`,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
return subsyncToReference('ffsubsync', context.videoPath, context, resolved, client);
|
return subsyncToReference(
|
||||||
|
'ffsubsync',
|
||||||
|
context.videoPath,
|
||||||
|
targetTrack,
|
||||||
|
context,
|
||||||
|
resolved,
|
||||||
|
client,
|
||||||
|
targetSlot,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const sourceTrack = getTrackById(context.sourceTracks, request.sourceTrackId ?? null);
|
if (request.referenceMode === 'video') {
|
||||||
if (!sourceTrack) {
|
if (isRemoteMediaPath(context.videoPath)) {
|
||||||
return { ok: false, message: 'Select a subtitle source track for alass' };
|
return {
|
||||||
|
ok: false,
|
||||||
|
message:
|
||||||
|
'alass cannot use a stream URL as reference. Pick a reference subtitle track instead.',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return subsyncToReference(
|
||||||
|
'alass',
|
||||||
|
context.videoPath,
|
||||||
|
targetTrack,
|
||||||
|
context,
|
||||||
|
resolved,
|
||||||
|
client,
|
||||||
|
targetSlot,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const referenceTrack = getTrackById(context.subtitleTracks, request.referenceTrackId ?? null);
|
||||||
|
if (!referenceTrack) {
|
||||||
|
return { ok: false, message: 'Select a reference subtitle track for alass' };
|
||||||
|
}
|
||||||
|
if (referenceTrack.id === targetTrack.id) {
|
||||||
|
return { ok: false, message: 'Reference and out-of-sync subtitles must be different tracks' };
|
||||||
}
|
}
|
||||||
|
|
||||||
const ffmpegPath = ensureExecutablePath(resolved.ffmpegPath, 'ffmpeg');
|
const ffmpegPath = ensureExecutablePath(resolved.ffmpegPath, 'ffmpeg');
|
||||||
let sourceExtraction: FileExtractionResult | null = null;
|
let referenceExtraction: FileExtractionResult | null = null;
|
||||||
try {
|
try {
|
||||||
sourceExtraction = await extractSubtitleTrackToFile(ffmpegPath, context.videoPath, sourceTrack);
|
referenceExtraction = await extractSubtitleTrackToFile(
|
||||||
return await subsyncToReference('alass', sourceExtraction.path, context, resolved, client);
|
ffmpegPath,
|
||||||
|
context.videoPath,
|
||||||
|
referenceTrack,
|
||||||
|
);
|
||||||
|
return await subsyncToReference(
|
||||||
|
'alass',
|
||||||
|
referenceExtraction.path,
|
||||||
|
targetTrack,
|
||||||
|
context,
|
||||||
|
resolved,
|
||||||
|
client,
|
||||||
|
targetSlot,
|
||||||
|
);
|
||||||
} finally {
|
} finally {
|
||||||
if (sourceExtraction) {
|
if (referenceExtraction) {
|
||||||
cleanupTemporaryFile(sourceExtraction);
|
cleanupTemporaryFile(referenceExtraction);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -377,14 +525,27 @@ export async function runSubsyncManual(
|
|||||||
export async function openSubsyncManualPicker(deps: TriggerSubsyncFromConfigDeps): Promise<void> {
|
export async function openSubsyncManualPicker(deps: TriggerSubsyncFromConfigDeps): Promise<void> {
|
||||||
const client = getMpvClientForSubsync(deps);
|
const client = getMpvClientForSubsync(deps);
|
||||||
const context = await gatherSubsyncContext(client);
|
const context = await gatherSubsyncContext(client);
|
||||||
const payload: SubsyncManualPayload = {
|
const subtitleTracks = context.subtitleTracks
|
||||||
ffsubsyncAvailable: !isRemoteMediaPath(context.videoPath),
|
|
||||||
sourceTracks: context.sourceTracks
|
|
||||||
.filter((track) => typeof track.id === 'number')
|
.filter((track) => typeof track.id === 'number')
|
||||||
.map((track) => ({
|
.map((track) => ({
|
||||||
id: track.id as number,
|
id: track.id as number,
|
||||||
label: formatTrackLabel(track),
|
label: formatTrackLabel(track),
|
||||||
})),
|
}));
|
||||||
|
const primaryTrackId =
|
||||||
|
typeof context.primaryTrack.id === 'number' ? context.primaryTrack.id : null;
|
||||||
|
const secondaryTrackId =
|
||||||
|
typeof context.secondaryTrack?.id === 'number' ? context.secondaryTrack.id : null;
|
||||||
|
const payload: SubsyncManualPayload = {
|
||||||
|
subtitleTracks,
|
||||||
|
// The secondary track can be filtered or deduped out of the emitted list,
|
||||||
|
// so only default to it when the picker actually offers it.
|
||||||
|
defaultReferenceTrackId:
|
||||||
|
subtitleTracks.find((track) => track.id === secondaryTrackId)?.id ??
|
||||||
|
subtitleTracks.find((track) => track.id !== primaryTrackId)?.id ??
|
||||||
|
null,
|
||||||
|
defaultTargetTrackId: primaryTrackId,
|
||||||
|
videoReferenceAvailable: !isRemoteMediaPath(context.videoPath),
|
||||||
|
ffsubsyncAvailable: !isRemoteMediaPath(context.videoPath),
|
||||||
};
|
};
|
||||||
deps.openManualPicker(payload);
|
deps.openManualPicker(payload);
|
||||||
}
|
}
|
||||||
@@ -397,7 +558,7 @@ export async function triggerSubsyncFromConfig(deps: TriggerSubsyncFromConfigDep
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
await openSubsyncManualPicker(deps);
|
await openSubsyncManualPicker(deps);
|
||||||
deps.showMpvOsd('Subsync: choose engine and source');
|
deps.showMpvOsd('Subsync: choose engine and subtitles');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
deps.showMpvOsd(`Subsync failed: ${(error as Error).message}`);
|
deps.showMpvOsd(`Subsync failed: ${(error as Error).message}`);
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -74,7 +74,6 @@ test('prefetch service tokenizes priority window cues and caches them', async ()
|
|||||||
preCacheTokenization: (text, data) => {
|
preCacheTokenization: (text, data) => {
|
||||||
cached.set(text, data);
|
cached.set(text, data);
|
||||||
},
|
},
|
||||||
isCacheFull: () => false,
|
|
||||||
priorityWindowSize: 3,
|
priorityWindowSize: 3,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -91,32 +90,38 @@ test('prefetch service tokenizes priority window cues and caches them', async ()
|
|||||||
assert.ok(cached.has('line-2'));
|
assert.ok(cached.has('line-2'));
|
||||||
});
|
});
|
||||||
|
|
||||||
test('prefetch service stops when cache is full', async () => {
|
test('prefetch service warms every cue even when the cache evicts along the way', async () => {
|
||||||
const cues = makeCues(20);
|
const cues = makeCues(20);
|
||||||
let tokenizeCalls = 0;
|
const tokenized: string[] = [];
|
||||||
let cacheSize = 0;
|
// Stand-in for the LRU: only the last 5 entries survive, so later cues evict earlier ones.
|
||||||
|
const cache = new Set<string>();
|
||||||
|
|
||||||
const service = createSubtitlePrefetchService({
|
const service = createSubtitlePrefetchService({
|
||||||
cues,
|
cues,
|
||||||
tokenizeSubtitle: async (text) => {
|
tokenizeSubtitle: async (text) => {
|
||||||
tokenizeCalls += 1;
|
tokenized.push(text);
|
||||||
return { text, tokens: [] };
|
return { text, tokens: [] };
|
||||||
},
|
},
|
||||||
preCacheTokenization: () => {
|
preCacheTokenization: (text) => {
|
||||||
cacheSize += 1;
|
cache.add(text);
|
||||||
|
while (cache.size > 5) {
|
||||||
|
const oldest = cache.values().next().value;
|
||||||
|
if (oldest === undefined) break;
|
||||||
|
cache.delete(oldest);
|
||||||
|
}
|
||||||
},
|
},
|
||||||
isCacheFull: () => cacheSize >= 5,
|
hasCachedTokenization: (text) => cache.has(text),
|
||||||
priorityWindowSize: 3,
|
priorityWindowSize: 3,
|
||||||
});
|
});
|
||||||
|
|
||||||
service.start(0);
|
service.start(0);
|
||||||
for (let i = 0; i < 30; i += 1) {
|
for (let i = 0; i < 60; i += 1) {
|
||||||
await flushMicrotasks();
|
await flushMicrotasks();
|
||||||
}
|
}
|
||||||
service.stop();
|
service.stop();
|
||||||
|
|
||||||
// Should have stopped at 5 (cache full), not tokenized all 20
|
assert.equal(tokenized.length, 20, `Expected all 20 cues warmed, got ${tokenized.length}`);
|
||||||
assert.ok(tokenizeCalls <= 6, `Expected <= 6 tokenize calls, got ${tokenizeCalls}`);
|
assert.equal(new Set(tokenized).size, 20, 'Each cue is tokenized at most once per run');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('prefetch service can be stopped mid-flight', async () => {
|
test('prefetch service can be stopped mid-flight', async () => {
|
||||||
@@ -130,7 +135,6 @@ test('prefetch service can be stopped mid-flight', async () => {
|
|||||||
return { text, tokens: [] };
|
return { text, tokens: [] };
|
||||||
},
|
},
|
||||||
preCacheTokenization: () => {},
|
preCacheTokenization: () => {},
|
||||||
isCacheFull: () => false,
|
|
||||||
priorityWindowSize: 3,
|
priorityWindowSize: 3,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -159,7 +163,6 @@ test('prefetch service onSeek re-prioritizes from new position', async () => {
|
|||||||
preCacheTokenization: (text) => {
|
preCacheTokenization: (text) => {
|
||||||
cachedTexts.push(text);
|
cachedTexts.push(text);
|
||||||
},
|
},
|
||||||
isCacheFull: () => false,
|
|
||||||
priorityWindowSize: 3,
|
priorityWindowSize: 3,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -183,7 +186,7 @@ test('prefetch service onSeek re-prioritizes from new position', async () => {
|
|||||||
assert.ok(hasPostSeekCue, 'Should have cached cues after seek position');
|
assert.ok(hasPostSeekCue, 'Should have cached cues after seek position');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('prefetch service still warms the priority window when cache is full', async () => {
|
test('prefetch service warms the priority window ahead of the rest of the file', async () => {
|
||||||
const cues = makeCues(20);
|
const cues = makeCues(20);
|
||||||
const cachedTexts: string[] = [];
|
const cachedTexts: string[] = [];
|
||||||
|
|
||||||
@@ -193,7 +196,6 @@ test('prefetch service still warms the priority window when cache is full', asyn
|
|||||||
preCacheTokenization: (text) => {
|
preCacheTokenization: (text) => {
|
||||||
cachedTexts.push(text);
|
cachedTexts.push(text);
|
||||||
},
|
},
|
||||||
isCacheFull: () => true,
|
|
||||||
priorityWindowSize: 3,
|
priorityWindowSize: 3,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -217,7 +219,6 @@ test('prefetch service pause/resume halts and continues tokenization', async ()
|
|||||||
return { text, tokens: [] };
|
return { text, tokens: [] };
|
||||||
},
|
},
|
||||||
preCacheTokenization: () => {},
|
preCacheTokenization: () => {},
|
||||||
isCacheFull: () => false,
|
|
||||||
priorityWindowSize: 3,
|
priorityWindowSize: 3,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -255,7 +256,6 @@ test('prefetch service skips cues already present in tokenization cache', async
|
|||||||
},
|
},
|
||||||
preCacheTokenization: () => {},
|
preCacheTokenization: () => {},
|
||||||
hasCachedTokenization: (text) => text === 'line-0' || text === 'line-1',
|
hasCachedTokenization: (text) => text === 'line-0' || text === 'line-1',
|
||||||
isCacheFull: () => false,
|
|
||||||
priorityWindowSize: 3,
|
priorityWindowSize: 3,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -285,7 +285,6 @@ test('prefetch service deduplicates repeated cue text within a run', async () =>
|
|||||||
return { text, tokens: [] };
|
return { text, tokens: [] };
|
||||||
},
|
},
|
||||||
preCacheTokenization: () => {},
|
preCacheTokenization: () => {},
|
||||||
isCacheFull: () => false,
|
|
||||||
priorityWindowSize: 3,
|
priorityWindowSize: 3,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ export interface SubtitlePrefetchServiceDeps {
|
|||||||
tokenizeSubtitle: (text: string) => Promise<SubtitleData | null>;
|
tokenizeSubtitle: (text: string) => Promise<SubtitleData | null>;
|
||||||
preCacheTokenization: (text: string, data: SubtitleData) => void;
|
preCacheTokenization: (text: string, data: SubtitleData) => void;
|
||||||
hasCachedTokenization?: (text: string) => boolean;
|
hasCachedTokenization?: (text: string) => boolean;
|
||||||
isCacheFull: () => boolean;
|
|
||||||
priorityWindowSize?: number;
|
priorityWindowSize?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -57,11 +56,14 @@ export function createSubtitlePrefetchService(
|
|||||||
let paused = false;
|
let paused = false;
|
||||||
let currentRunId = 0;
|
let currentRunId = 0;
|
||||||
|
|
||||||
|
// A run is a single bounded pass over one file's cues, deduped by `warmedKeys` and by
|
||||||
|
// `hasCachedTokenization`, so the worst case is one tokenization per cue. The cache is
|
||||||
|
// an LRU and bounds its own memory, so a full cache is not a reason to stop warming;
|
||||||
|
// stopping there used to leave the tail of longer media permanently uncached.
|
||||||
async function tokenizeCueList(
|
async function tokenizeCueList(
|
||||||
cuesToProcess: SubtitleCue[],
|
cuesToProcess: SubtitleCue[],
|
||||||
runId: number,
|
runId: number,
|
||||||
warmedKeys: Set<string>,
|
warmedKeys: Set<string>,
|
||||||
options: { allowWhenCacheFull?: boolean } = {},
|
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
for (const cue of cuesToProcess) {
|
for (const cue of cuesToProcess) {
|
||||||
if (stopped || runId !== currentRunId) {
|
if (stopped || runId !== currentRunId) {
|
||||||
@@ -77,10 +79,6 @@ export function createSubtitlePrefetchService(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!options.allowWhenCacheFull && deps.isCacheFull()) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const cacheKey = normalizeSubtitleCacheKey(cue.text);
|
const cacheKey = normalizeSubtitleCacheKey(cue.text);
|
||||||
if (!cacheKey || warmedKeys.has(cacheKey) || deps.hasCachedTokenization?.(cue.text)) {
|
if (!cacheKey || warmedKeys.has(cacheKey) || deps.hasCachedTokenization?.(cue.text)) {
|
||||||
if (cacheKey) {
|
if (cacheKey) {
|
||||||
@@ -110,7 +108,7 @@ export function createSubtitlePrefetchService(
|
|||||||
|
|
||||||
// Phase 1: Priority window
|
// Phase 1: Priority window
|
||||||
const priorityCues = computePriorityWindow(cues, currentTimeSeconds, windowSize);
|
const priorityCues = computePriorityWindow(cues, currentTimeSeconds, windowSize);
|
||||||
await tokenizeCueList(priorityCues, runId, warmedKeys, { allowWhenCacheFull: true });
|
await tokenizeCueList(priorityCues, runId, warmedKeys);
|
||||||
|
|
||||||
if (stopped || runId !== currentRunId) {
|
if (stopped || runId !== currentRunId) {
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -0,0 +1,159 @@
|
|||||||
|
import test from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import type { SubtitleData } from '../../types';
|
||||||
|
import { createSubtitleProcessingController } from './subtitle-processing-controller';
|
||||||
|
|
||||||
|
function flushMicrotasks(): Promise<void> {
|
||||||
|
return new Promise((resolve) => setTimeout(resolve, 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
test('new subtitle emits plain immediately without parallel tokenization or a stale overwrite', async () => {
|
||||||
|
const emitted: SubtitleData[] = [];
|
||||||
|
const resolvers = new Map<string, (value: SubtitleData | null) => void>();
|
||||||
|
const controller = createSubtitleProcessingController({
|
||||||
|
tokenizeSubtitle: async (text) =>
|
||||||
|
await new Promise<SubtitleData | null>((resolve) => {
|
||||||
|
resolvers.set(text, resolve);
|
||||||
|
}),
|
||||||
|
emitSubtitle: (payload) => emitted.push(payload),
|
||||||
|
});
|
||||||
|
|
||||||
|
controller.onSubtitleChange('first');
|
||||||
|
controller.onSubtitleChange('second');
|
||||||
|
|
||||||
|
assert.deepEqual(emitted, [
|
||||||
|
{ text: 'first', tokens: null },
|
||||||
|
{ text: 'second', tokens: null },
|
||||||
|
]);
|
||||||
|
assert.equal(resolvers.has('second'), false);
|
||||||
|
|
||||||
|
const resolveFirst = resolvers.get('first');
|
||||||
|
assert.ok(resolveFirst);
|
||||||
|
resolveFirst({ text: 'first', tokens: [] });
|
||||||
|
await flushMicrotasks();
|
||||||
|
assert.deepEqual(emitted, [
|
||||||
|
{ text: 'first', tokens: null },
|
||||||
|
{ text: 'second', tokens: null },
|
||||||
|
]);
|
||||||
|
assert.equal(resolvers.has('second'), true);
|
||||||
|
|
||||||
|
const resolveSecond = resolvers.get('second');
|
||||||
|
assert.ok(resolveSecond);
|
||||||
|
resolveSecond({ text: 'second', tokens: [] });
|
||||||
|
await flushMicrotasks();
|
||||||
|
assert.deepEqual(emitted, [
|
||||||
|
{ text: 'first', tokens: null },
|
||||||
|
{ text: 'second', tokens: null },
|
||||||
|
{ text: 'second', tokens: [] },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('subtitle clears immediately while previous tokenization remains pending', async () => {
|
||||||
|
const emitted: SubtitleData[] = [];
|
||||||
|
let resolveTokenization: ((value: SubtitleData | null) => void) | undefined;
|
||||||
|
const controller = createSubtitleProcessingController({
|
||||||
|
tokenizeSubtitle: async () =>
|
||||||
|
await new Promise<SubtitleData | null>((resolve) => {
|
||||||
|
resolveTokenization = resolve;
|
||||||
|
}),
|
||||||
|
emitSubtitle: (payload) => emitted.push(payload),
|
||||||
|
});
|
||||||
|
|
||||||
|
controller.onSubtitleChange('first');
|
||||||
|
controller.onSubtitleChange('');
|
||||||
|
|
||||||
|
assert.deepEqual(emitted, [
|
||||||
|
{ text: 'first', tokens: null },
|
||||||
|
{ text: '', tokens: null },
|
||||||
|
]);
|
||||||
|
|
||||||
|
assert.ok(resolveTokenization);
|
||||||
|
resolveTokenization({ text: 'first', tokens: [] });
|
||||||
|
await flushMicrotasks();
|
||||||
|
assert.deepEqual(emitted, [
|
||||||
|
{ text: 'first', tokens: null },
|
||||||
|
{ text: '', tokens: null },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('returning to an uncached completed line emits it while another line is pending', async () => {
|
||||||
|
const emitted: SubtitleData[] = [];
|
||||||
|
let resolvePending: ((value: SubtitleData | null) => void) | undefined;
|
||||||
|
const controller = createSubtitleProcessingController({
|
||||||
|
tokenizeSubtitle: async (text) => {
|
||||||
|
if (text === 'A') {
|
||||||
|
return { text, tokens: [] };
|
||||||
|
}
|
||||||
|
return await new Promise<SubtitleData | null>((resolve) => {
|
||||||
|
resolvePending = resolve;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
emitSubtitle: (payload) => emitted.push(payload),
|
||||||
|
});
|
||||||
|
|
||||||
|
controller.onSubtitleChange('A');
|
||||||
|
await flushMicrotasks();
|
||||||
|
controller.invalidateTokenizationCache();
|
||||||
|
controller.onSubtitleChange('B');
|
||||||
|
controller.onSubtitleChange('A');
|
||||||
|
|
||||||
|
assert.deepEqual(emitted.at(-1), { text: 'A', tokens: null });
|
||||||
|
assert.ok(resolvePending);
|
||||||
|
resolvePending({ text: 'B', tokens: [] });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ABA subtitle changes reuse the matching first tokenization only after A is current again', async () => {
|
||||||
|
const emitted: SubtitleData[] = [];
|
||||||
|
const tokenizeCalls: string[] = [];
|
||||||
|
const resolvers: Array<(value: SubtitleData | null) => void> = [];
|
||||||
|
const controller = createSubtitleProcessingController({
|
||||||
|
tokenizeSubtitle: async (text) => {
|
||||||
|
tokenizeCalls.push(text);
|
||||||
|
return await new Promise<SubtitleData | null>((resolve) => {
|
||||||
|
resolvers.push(resolve);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
emitSubtitle: (payload) => emitted.push(payload),
|
||||||
|
});
|
||||||
|
|
||||||
|
controller.onSubtitleChange('A');
|
||||||
|
controller.onSubtitleChange('B');
|
||||||
|
controller.onSubtitleChange('A');
|
||||||
|
const resolveFirst = resolvers[0];
|
||||||
|
assert.ok(resolveFirst);
|
||||||
|
resolveFirst({ text: 'A', tokens: [{ value: 1 } as never] });
|
||||||
|
await flushMicrotasks();
|
||||||
|
|
||||||
|
assert.deepEqual(tokenizeCalls, ['A']);
|
||||||
|
assert.deepEqual(emitted, [
|
||||||
|
{ text: 'A', tokens: null },
|
||||||
|
{ text: 'B', tokens: null },
|
||||||
|
{ text: 'A', tokens: null },
|
||||||
|
{ text: 'A', tokens: [{ value: 1 } as never] },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('cached next subtitle does not downgrade to plain while processing is busy', async () => {
|
||||||
|
const emitted: SubtitleData[] = [];
|
||||||
|
let resolveTokenization: ((value: SubtitleData | null) => void) | undefined;
|
||||||
|
const controller = createSubtitleProcessingController({
|
||||||
|
tokenizeSubtitle: async (text) =>
|
||||||
|
await new Promise<SubtitleData | null>((resolve) => {
|
||||||
|
resolveTokenization = () => resolve({ text, tokens: [] });
|
||||||
|
}),
|
||||||
|
emitSubtitle: (payload) => emitted.push(payload),
|
||||||
|
});
|
||||||
|
|
||||||
|
controller.preCacheTokenization('cached', { text: 'cached', tokens: [] });
|
||||||
|
controller.onSubtitleChange('pending');
|
||||||
|
controller.onSubtitleChange('cached');
|
||||||
|
|
||||||
|
assert.deepEqual(emitted, [{ text: 'pending', tokens: null }]);
|
||||||
|
assert.ok(resolveTokenization);
|
||||||
|
resolveTokenization({ text: 'pending', tokens: [] });
|
||||||
|
await flushMicrotasks();
|
||||||
|
assert.deepEqual(emitted, [
|
||||||
|
{ text: 'pending', tokens: null },
|
||||||
|
{ text: 'cached', tokens: [] },
|
||||||
|
]);
|
||||||
|
});
|
||||||
@@ -7,18 +7,138 @@ function flushMicrotasks(): Promise<void> {
|
|||||||
return new Promise((resolve) => setTimeout(resolve, 0));
|
return new Promise((resolve) => setTimeout(resolve, 0));
|
||||||
}
|
}
|
||||||
|
|
||||||
test('subtitle processing emits tokenized payload when tokenization succeeds', async () => {
|
test('subtitle processing emits plain payload immediately on cache miss, then tokenized payload', async () => {
|
||||||
const emitted: SubtitleData[] = [];
|
const emitted: SubtitleData[] = [];
|
||||||
const controller = createSubtitleProcessingController({
|
const controller = createSubtitleProcessingController({
|
||||||
tokenizeSubtitle: async (text) => ({ text, tokens: [] }),
|
tokenizeSubtitle: async (text) => ({ text, tokens: [] }),
|
||||||
emitSubtitle: (payload) => emitted.push(payload),
|
emitSubtitle: (payload) => emitted.push(payload),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
controller.onSubtitleChange('字幕');
|
||||||
|
assert.deepEqual(emitted, [{ text: '字幕', tokens: null }]);
|
||||||
|
await flushMicrotasks();
|
||||||
|
assert.deepEqual(emitted, [
|
||||||
|
{ text: '字幕', tokens: null },
|
||||||
|
{ text: '字幕', tokens: [] },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('cache invalidation during pending tokenization does not re-emit the plain payload', async () => {
|
||||||
|
const emitted: SubtitleData[] = [];
|
||||||
|
const resolvers: Array<(value: SubtitleData | null) => void> = [];
|
||||||
|
const controller = createSubtitleProcessingController({
|
||||||
|
tokenizeSubtitle: async (text) =>
|
||||||
|
await new Promise<SubtitleData | null>((resolve) => {
|
||||||
|
resolvers.push(() => resolve({ text, tokens: [{ value: resolvers.length } as never] }));
|
||||||
|
}),
|
||||||
|
emitSubtitle: (payload) => emitted.push(payload),
|
||||||
|
});
|
||||||
|
|
||||||
|
controller.onSubtitleChange('行');
|
||||||
|
assert.deepEqual(emitted, [{ text: '行', tokens: null }]);
|
||||||
|
|
||||||
|
controller.invalidateTokenizationCache();
|
||||||
|
resolvers[0]?.({ text: '行', tokens: [] });
|
||||||
|
await flushMicrotasks();
|
||||||
|
// Retry for the new generation is now pending; still no duplicate plain emit.
|
||||||
|
assert.deepEqual(emitted, [{ text: '行', tokens: null }]);
|
||||||
|
|
||||||
|
resolvers[1]?.({ text: '行', tokens: [] });
|
||||||
|
await flushMicrotasks();
|
||||||
|
assert.deepEqual(emitted, [
|
||||||
|
{ text: '行', tokens: null },
|
||||||
|
{ text: '行', tokens: [{ value: 2 } as never] },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('failed refresh does not downgrade an already emitted tokenized subtitle', async () => {
|
||||||
|
const emitted: SubtitleData[] = [];
|
||||||
|
let tokenizeCalls = 0;
|
||||||
|
const controller = createSubtitleProcessingController({
|
||||||
|
tokenizeSubtitle: async (text) => {
|
||||||
|
tokenizeCalls += 1;
|
||||||
|
if (tokenizeCalls > 1) {
|
||||||
|
throw new Error('tokenizer gone');
|
||||||
|
}
|
||||||
|
return { text, tokens: [] };
|
||||||
|
},
|
||||||
|
emitSubtitle: (payload) => emitted.push(payload),
|
||||||
|
});
|
||||||
|
|
||||||
|
controller.onSubtitleChange('行');
|
||||||
|
await flushMicrotasks();
|
||||||
|
controller.invalidateTokenizationCache();
|
||||||
|
controller.refreshCurrentSubtitle();
|
||||||
|
await flushMicrotasks();
|
||||||
|
|
||||||
|
assert.equal(tokenizeCalls, 2);
|
||||||
|
assert.deepEqual(emitted, [
|
||||||
|
{ text: '行', tokens: null },
|
||||||
|
{ text: '行', tokens: [] },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('null-tokenization refresh does not downgrade an already emitted tokenized subtitle', async () => {
|
||||||
|
const emitted: SubtitleData[] = [];
|
||||||
|
let tokenizeCalls = 0;
|
||||||
|
const controller = createSubtitleProcessingController({
|
||||||
|
tokenizeSubtitle: async (text) => {
|
||||||
|
tokenizeCalls += 1;
|
||||||
|
return tokenizeCalls > 1 ? null : { text, tokens: [] };
|
||||||
|
},
|
||||||
|
emitSubtitle: (payload) => emitted.push(payload),
|
||||||
|
});
|
||||||
|
|
||||||
|
controller.onSubtitleChange('行');
|
||||||
|
await flushMicrotasks();
|
||||||
|
controller.invalidateTokenizationCache();
|
||||||
|
controller.refreshCurrentSubtitle();
|
||||||
|
await flushMicrotasks();
|
||||||
|
|
||||||
|
assert.equal(tokenizeCalls, 2);
|
||||||
|
assert.deepEqual(emitted, [
|
||||||
|
{ text: '行', tokens: null },
|
||||||
|
{ text: '行', tokens: [] },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('subtitle processing does not emit plain payload for cached lines', async () => {
|
||||||
|
const emitted: SubtitleData[] = [];
|
||||||
|
const controller = createSubtitleProcessingController({
|
||||||
|
tokenizeSubtitle: async (text) => ({ text, tokens: [] }),
|
||||||
|
emitSubtitle: (payload) => emitted.push(payload),
|
||||||
|
});
|
||||||
|
|
||||||
|
controller.preCacheTokenization('字幕', { text: '字幕', tokens: [] });
|
||||||
controller.onSubtitleChange('字幕');
|
controller.onSubtitleChange('字幕');
|
||||||
await flushMicrotasks();
|
await flushMicrotasks();
|
||||||
assert.deepEqual(emitted, [{ text: '字幕', tokens: [] }]);
|
assert.deepEqual(emitted, [{ text: '字幕', tokens: [] }]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('subtitle processing shows plain line while tokenization is still pending', async () => {
|
||||||
|
const emitted: SubtitleData[] = [];
|
||||||
|
let resolveTokenization: ((value: SubtitleData | null) => void) | undefined;
|
||||||
|
const controller = createSubtitleProcessingController({
|
||||||
|
tokenizeSubtitle: async (text) =>
|
||||||
|
await new Promise<SubtitleData | null>((resolve) => {
|
||||||
|
resolveTokenization = () => resolve({ text, tokens: [] });
|
||||||
|
}),
|
||||||
|
emitSubtitle: (payload) => emitted.push(payload),
|
||||||
|
});
|
||||||
|
|
||||||
|
controller.onSubtitleChange('遅い行');
|
||||||
|
await flushMicrotasks();
|
||||||
|
assert.deepEqual(emitted, [{ text: '遅い行', tokens: null }]);
|
||||||
|
|
||||||
|
assert.ok(resolveTokenization);
|
||||||
|
resolveTokenization({ text: '遅い行', tokens: [] });
|
||||||
|
await flushMicrotasks();
|
||||||
|
assert.deepEqual(emitted, [
|
||||||
|
{ text: '遅い行', tokens: null },
|
||||||
|
{ text: '遅い行', tokens: [] },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
test('subtitle processing drops stale tokenization and delivers latest subtitle only once', async () => {
|
test('subtitle processing drops stale tokenization and delivers latest subtitle only once', async () => {
|
||||||
const emitted: SubtitleData[] = [];
|
const emitted: SubtitleData[] = [];
|
||||||
let firstResolve: ((value: SubtitleData | null) => void) | undefined;
|
let firstResolve: ((value: SubtitleData | null) => void) | undefined;
|
||||||
@@ -41,7 +161,11 @@ test('subtitle processing drops stale tokenization and delivers latest subtitle
|
|||||||
await flushMicrotasks();
|
await flushMicrotasks();
|
||||||
await flushMicrotasks();
|
await flushMicrotasks();
|
||||||
|
|
||||||
assert.deepEqual(emitted, [{ text: 'second', tokens: [] }]);
|
assert.deepEqual(emitted, [
|
||||||
|
{ text: 'first', tokens: null },
|
||||||
|
{ text: 'second', tokens: null },
|
||||||
|
{ text: 'second', tokens: [] },
|
||||||
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('subtitle processing skips duplicate subtitle emission', async () => {
|
test('subtitle processing skips duplicate subtitle emission', async () => {
|
||||||
@@ -60,7 +184,10 @@ test('subtitle processing skips duplicate subtitle emission', async () => {
|
|||||||
controller.onSubtitleChange('same');
|
controller.onSubtitleChange('same');
|
||||||
await flushMicrotasks();
|
await flushMicrotasks();
|
||||||
|
|
||||||
assert.equal(emitted.length, 1);
|
assert.deepEqual(emitted, [
|
||||||
|
{ text: 'same', tokens: null },
|
||||||
|
{ text: 'same', tokens: [] },
|
||||||
|
]);
|
||||||
assert.equal(tokenizeCalls, 1);
|
assert.equal(tokenizeCalls, 1);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -84,7 +211,9 @@ test('subtitle processing reuses cached tokenization for repeated subtitle text'
|
|||||||
|
|
||||||
assert.equal(tokenizeCalls, 2);
|
assert.equal(tokenizeCalls, 2);
|
||||||
assert.deepEqual(emitted, [
|
assert.deepEqual(emitted, [
|
||||||
|
{ text: 'first', tokens: null },
|
||||||
{ text: 'first', tokens: [] },
|
{ text: 'first', tokens: [] },
|
||||||
|
{ text: 'second', tokens: null },
|
||||||
{ text: 'second', tokens: [] },
|
{ text: 'second', tokens: [] },
|
||||||
{ text: 'first', tokens: [] },
|
{ text: 'first', tokens: [] },
|
||||||
]);
|
]);
|
||||||
@@ -100,7 +229,48 @@ test('subtitle processing falls back to plain subtitle when tokenization returns
|
|||||||
controller.onSubtitleChange('fallback');
|
controller.onSubtitleChange('fallback');
|
||||||
await flushMicrotasks();
|
await flushMicrotasks();
|
||||||
|
|
||||||
|
assert.deepEqual(
|
||||||
|
emitted,
|
||||||
|
[{ text: 'fallback', tokens: null }],
|
||||||
|
'plain payload should not be re-emitted when tokenization yields nothing new',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('null tokenization is not cached and a later cue retries tokenization', async () => {
|
||||||
|
const emitted: SubtitleData[] = [];
|
||||||
|
const callsByText = new Map<string, number>();
|
||||||
|
let failNext = true;
|
||||||
|
const controller = createSubtitleProcessingController({
|
||||||
|
tokenizeSubtitle: async (text) => {
|
||||||
|
callsByText.set(text, (callsByText.get(text) ?? 0) + 1);
|
||||||
|
if (text === 'fallback' && failNext) {
|
||||||
|
failNext = false;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return { text, tokens: [] };
|
||||||
|
},
|
||||||
|
emitSubtitle: (payload) => emitted.push(payload),
|
||||||
|
});
|
||||||
|
|
||||||
|
controller.onSubtitleChange('fallback');
|
||||||
|
await flushMicrotasks();
|
||||||
|
|
||||||
|
assert.equal(callsByText.get('fallback'), 1);
|
||||||
|
assert.equal(
|
||||||
|
controller.hasCachedSubtitle('fallback'),
|
||||||
|
false,
|
||||||
|
'plain fallback must not be cached when tokenization yields nothing',
|
||||||
|
);
|
||||||
assert.deepEqual(emitted, [{ text: 'fallback', tokens: null }]);
|
assert.deepEqual(emitted, [{ text: 'fallback', tokens: null }]);
|
||||||
|
|
||||||
|
controller.onSubtitleChange('other');
|
||||||
|
await flushMicrotasks();
|
||||||
|
controller.onSubtitleChange('fallback');
|
||||||
|
await flushMicrotasks();
|
||||||
|
|
||||||
|
assert.equal(callsByText.get('fallback'), 2, 'later cue should retry tokenization');
|
||||||
|
assert.equal(controller.hasCachedSubtitle('fallback'), true);
|
||||||
|
assert.deepEqual(emitted.at(-1), { text: 'fallback', tokens: [] });
|
||||||
});
|
});
|
||||||
|
|
||||||
test('subtitle processing ignores duplicate current subtitle refresh without cache invalidation', async () => {
|
test('subtitle processing ignores duplicate current subtitle refresh without cache invalidation', async () => {
|
||||||
@@ -120,7 +290,10 @@ test('subtitle processing ignores duplicate current subtitle refresh without cac
|
|||||||
await flushMicrotasks();
|
await flushMicrotasks();
|
||||||
|
|
||||||
assert.equal(tokenizeCalls, 1);
|
assert.equal(tokenizeCalls, 1);
|
||||||
assert.deepEqual(emitted, [{ text: 'same', tokens: [] }]);
|
assert.deepEqual(emitted, [
|
||||||
|
{ text: 'same', tokens: null },
|
||||||
|
{ text: 'same', tokens: [] },
|
||||||
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('subtitle processing coalesces refresh requests while current subtitle is processing', async () => {
|
test('subtitle processing coalesces refresh requests while current subtitle is processing', async () => {
|
||||||
@@ -146,7 +319,10 @@ test('subtitle processing coalesces refresh requests while current subtitle is p
|
|||||||
await flushMicrotasks();
|
await flushMicrotasks();
|
||||||
|
|
||||||
assert.equal(tokenizeCalls, 1);
|
assert.equal(tokenizeCalls, 1);
|
||||||
assert.deepEqual(emitted, [{ text: 'same', tokens: [] }]);
|
assert.deepEqual(emitted, [
|
||||||
|
{ text: 'same', tokens: null },
|
||||||
|
{ text: 'same', tokens: [] },
|
||||||
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('subtitle processing refresh re-tokenizes after cache invalidation', async () => {
|
test('subtitle processing refresh re-tokenizes after cache invalidation', async () => {
|
||||||
@@ -168,6 +344,7 @@ test('subtitle processing refresh re-tokenizes after cache invalidation', async
|
|||||||
|
|
||||||
assert.equal(tokenizeCalls, 2);
|
assert.equal(tokenizeCalls, 2);
|
||||||
assert.deepEqual(emitted, [
|
assert.deepEqual(emitted, [
|
||||||
|
{ text: 'same', tokens: null },
|
||||||
{ text: 'same', tokens: [{ value: 1 } as never] },
|
{ text: 'same', tokens: [{ value: 1 } as never] },
|
||||||
{ text: 'same', tokens: [{ value: 2 } as never] },
|
{ text: 'same', tokens: [{ value: 2 } as never] },
|
||||||
]);
|
]);
|
||||||
@@ -183,7 +360,10 @@ test('subtitle processing refresh can use explicit text override', async () => {
|
|||||||
controller.refreshCurrentSubtitle('initial');
|
controller.refreshCurrentSubtitle('initial');
|
||||||
await flushMicrotasks();
|
await flushMicrotasks();
|
||||||
|
|
||||||
assert.deepEqual(emitted, [{ text: 'initial', tokens: [] }]);
|
assert.deepEqual(emitted, [
|
||||||
|
{ text: 'initial', tokens: null },
|
||||||
|
{ text: 'initial', tokens: [] },
|
||||||
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('subtitle processing cache invalidation only affects future subtitle events', async () => {
|
test('subtitle processing cache invalidation only affects future subtitle events', async () => {
|
||||||
@@ -205,10 +385,10 @@ test('subtitle processing cache invalidation only affects future subtitle events
|
|||||||
await flushMicrotasks();
|
await flushMicrotasks();
|
||||||
|
|
||||||
assert.equal(callsByText.get('same'), 1);
|
assert.equal(callsByText.get('same'), 1);
|
||||||
assert.equal(emitted.length, 3);
|
assert.equal(emitted.length, 5);
|
||||||
|
|
||||||
controller.invalidateTokenizationCache();
|
controller.invalidateTokenizationCache();
|
||||||
assert.equal(emitted.length, 3);
|
assert.equal(emitted.length, 5);
|
||||||
|
|
||||||
controller.onSubtitleChange('different');
|
controller.onSubtitleChange('different');
|
||||||
await flushMicrotasks();
|
await flushMicrotasks();
|
||||||
@@ -308,25 +488,54 @@ test('hasCachedSubtitle checks prefetched entries without consuming them', async
|
|||||||
assert.equal(controller.hasCachedSubtitle('猫\nです'), false);
|
assert.equal(controller.hasCachedSubtitle('猫\nです'), false);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('isCacheFull returns false when cache is below limit', () => {
|
test('cache keeps every entry while below the limit', () => {
|
||||||
const controller = createSubtitleProcessingController({
|
const controller = createSubtitleProcessingController({
|
||||||
tokenizeSubtitle: async (text) => ({ text, tokens: null }),
|
tokenizeSubtitle: async (text) => ({ text, tokens: [] }),
|
||||||
emitSubtitle: () => {},
|
emitSubtitle: () => {},
|
||||||
|
cacheLimit: 8,
|
||||||
});
|
});
|
||||||
|
|
||||||
assert.equal(controller.isCacheFull(), false);
|
for (let i = 0; i < 8; i += 1) {
|
||||||
|
controller.preCacheTokenization(`line-${i}`, { text: `line-${i}`, tokens: [] });
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.deepEqual(
|
||||||
|
Array.from({ length: 8 }, (_, i) => controller.hasCachedSubtitle(`line-${i}`)),
|
||||||
|
Array.from({ length: 8 }, () => true),
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('isCacheFull returns true when cache reaches limit', async () => {
|
test('cache evicts least recently used entries once the limit is reached', () => {
|
||||||
|
const controller = createSubtitleProcessingController({
|
||||||
|
tokenizeSubtitle: async (text) => ({ text, tokens: [] }),
|
||||||
|
emitSubtitle: () => {},
|
||||||
|
cacheLimit: 3,
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const line of ['a', 'b', 'c']) {
|
||||||
|
controller.preCacheTokenization(line, { text: line, tokens: [] });
|
||||||
|
}
|
||||||
|
// Touching 'a' makes 'b' the eviction candidate.
|
||||||
|
controller.consumeCachedSubtitle('a');
|
||||||
|
controller.preCacheTokenization('d', { text: 'd', tokens: [] });
|
||||||
|
|
||||||
|
assert.equal(controller.hasCachedSubtitle('b'), false);
|
||||||
|
assert.deepEqual(
|
||||||
|
['a', 'c', 'd'].map((line) => controller.hasCachedSubtitle(line)),
|
||||||
|
[true, true, true],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('default cache limit covers a full-length title without evicting', () => {
|
||||||
const controller = createSubtitleProcessingController({
|
const controller = createSubtitleProcessingController({
|
||||||
tokenizeSubtitle: async (text) => ({ text, tokens: [] }),
|
tokenizeSubtitle: async (text) => ({ text, tokens: [] }),
|
||||||
emitSubtitle: () => {},
|
emitSubtitle: () => {},
|
||||||
});
|
});
|
||||||
|
|
||||||
// Fill cache to the 256 limit
|
for (let i = 0; i < 2000; i += 1) {
|
||||||
for (let i = 0; i < 256; i += 1) {
|
|
||||||
controller.preCacheTokenization(`line-${i}`, { text: `line-${i}`, tokens: [] });
|
controller.preCacheTokenization(`line-${i}`, { text: `line-${i}`, tokens: [] });
|
||||||
}
|
}
|
||||||
|
|
||||||
assert.equal(controller.isCacheFull(), true);
|
assert.equal(controller.hasCachedSubtitle('line-0'), true);
|
||||||
|
assert.equal(controller.hasCachedSubtitle('line-1999'), true);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -5,8 +5,17 @@ export interface SubtitleProcessingControllerDeps {
|
|||||||
emitSubtitle: (payload: SubtitleData) => void;
|
emitSubtitle: (payload: SubtitleData) => void;
|
||||||
logDebug?: (message: string) => void;
|
logDebug?: (message: string) => void;
|
||||||
now?: () => number;
|
now?: () => number;
|
||||||
|
cacheLimit?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pure memory bound on the LRU, not a coverage limit: prefetching runs to the end of a
|
||||||
|
* file regardless of cache pressure. Sized to hold a feature-length title (a 24-minute
|
||||||
|
* episode runs 300-400 lines, a 2-hour film ~2000) plus room for lines that repeat across
|
||||||
|
* episodes of a series, so openings and endings stay warm between titles.
|
||||||
|
*/
|
||||||
|
export const DEFAULT_SUBTITLE_TOKENIZATION_CACHE_LIMIT = 2500;
|
||||||
|
|
||||||
export interface SubtitleProcessingController {
|
export interface SubtitleProcessingController {
|
||||||
onSubtitleChange: (text: string) => void;
|
onSubtitleChange: (text: string) => void;
|
||||||
refreshCurrentSubtitle: (textOverride?: string) => void;
|
refreshCurrentSubtitle: (textOverride?: string) => void;
|
||||||
@@ -14,7 +23,6 @@ export interface SubtitleProcessingController {
|
|||||||
preCacheTokenization: (text: string, data: SubtitleData) => void;
|
preCacheTokenization: (text: string, data: SubtitleData) => void;
|
||||||
consumeCachedSubtitle: (text: string) => SubtitleData | null;
|
consumeCachedSubtitle: (text: string) => SubtitleData | null;
|
||||||
hasCachedSubtitle: (text: string) => boolean;
|
hasCachedSubtitle: (text: string) => boolean;
|
||||||
isCacheFull: () => boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function normalizeSubtitleCacheKey(text: string): string {
|
export function normalizeSubtitleCacheKey(text: string): string {
|
||||||
@@ -24,9 +32,15 @@ export function normalizeSubtitleCacheKey(text: string): string {
|
|||||||
export function createSubtitleProcessingController(
|
export function createSubtitleProcessingController(
|
||||||
deps: SubtitleProcessingControllerDeps,
|
deps: SubtitleProcessingControllerDeps,
|
||||||
): SubtitleProcessingController {
|
): SubtitleProcessingController {
|
||||||
const SUBTITLE_TOKENIZATION_CACHE_LIMIT = 256;
|
const SUBTITLE_TOKENIZATION_CACHE_LIMIT =
|
||||||
|
deps.cacheLimit && deps.cacheLimit > 0
|
||||||
|
? deps.cacheLimit
|
||||||
|
: DEFAULT_SUBTITLE_TOKENIZATION_CACHE_LIMIT;
|
||||||
let latestText = '';
|
let latestText = '';
|
||||||
let lastEmittedText = '';
|
let lastEmittedText = '';
|
||||||
|
// Tracks the latest provisional plain emit across rapid changes and loop retries
|
||||||
|
// so the same line is never shown plain twice.
|
||||||
|
let lastPlainEmittedText: string | null = null;
|
||||||
let cacheGeneration = 0;
|
let cacheGeneration = 0;
|
||||||
let lastEmittedGeneration = 0;
|
let lastEmittedGeneration = 0;
|
||||||
let processing = false;
|
let processing = false;
|
||||||
@@ -70,9 +84,12 @@ export function createSubtitleProcessingController(
|
|||||||
const startedAtMs = now();
|
const startedAtMs = now();
|
||||||
|
|
||||||
if (!text.trim()) {
|
if (!text.trim()) {
|
||||||
|
if (lastPlainEmittedText !== text) {
|
||||||
deps.emitSubtitle({ text, tokens: null });
|
deps.emitSubtitle({ text, tokens: null });
|
||||||
|
}
|
||||||
lastEmittedText = text;
|
lastEmittedText = text;
|
||||||
lastEmittedGeneration = generation;
|
lastEmittedGeneration = generation;
|
||||||
|
lastPlainEmittedText = null;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -82,11 +99,25 @@ export function createSubtitleProcessingController(
|
|||||||
if (cachedTokenized) {
|
if (cachedTokenized) {
|
||||||
output = cachedTokenized;
|
output = cachedTokenized;
|
||||||
} else {
|
} else {
|
||||||
|
// Cache miss: show the plain line on time; the tokenized payload
|
||||||
|
// upgrades it once ready. Skipped on refreshes of an already
|
||||||
|
// emitted line so downstream consumers never see a downgrade.
|
||||||
|
if (text !== lastEmittedText && text !== lastPlainEmittedText) {
|
||||||
|
deps.emitSubtitle({ text, tokens: null });
|
||||||
|
lastPlainEmittedText = text;
|
||||||
|
}
|
||||||
const tokenized = await deps.tokenizeSubtitle(text);
|
const tokenized = await deps.tokenizeSubtitle(text);
|
||||||
|
// A null result is a transient tokenizer failure, not a verdict on
|
||||||
|
// the line: caching the plain fallback would pin it untokenized for
|
||||||
|
// every later occurrence.
|
||||||
if (tokenized) {
|
if (tokenized) {
|
||||||
output = tokenized;
|
output = tokenized;
|
||||||
|
// A result computed before an invalidation must not repopulate the
|
||||||
|
// fresh cache, or the retry below would serve the stale entry.
|
||||||
|
if (generation === cacheGeneration) {
|
||||||
|
setCachedTokenization(text, tokenized);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
setCachedTokenization(text, output);
|
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
deps.logDebug?.(`Subtitle tokenization failed: ${(error as Error).message}`);
|
deps.logDebug?.(`Subtitle tokenization failed: ${(error as Error).message}`);
|
||||||
@@ -107,9 +138,16 @@ export function createSubtitleProcessingController(
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// An untokenized result adds nothing when this line was already shown,
|
||||||
|
// either provisionally or as an earlier full emit (failed refresh) —
|
||||||
|
// emitting it would duplicate or downgrade what is on screen.
|
||||||
|
const plainAlreadyShown = lastPlainEmittedText === text || lastEmittedText === text;
|
||||||
|
if (!(output.tokens === null && output.text === text && plainAlreadyShown)) {
|
||||||
deps.emitSubtitle(output);
|
deps.emitSubtitle(output);
|
||||||
|
}
|
||||||
lastEmittedText = text;
|
lastEmittedText = text;
|
||||||
lastEmittedGeneration = generation;
|
lastEmittedGeneration = generation;
|
||||||
|
lastPlainEmittedText = null;
|
||||||
deps.logDebug?.(
|
deps.logDebug?.(
|
||||||
`Subtitle tokenization delivered; elapsed=${now() - startedAtMs}ms, staleDrops=${staleDropCount}`,
|
`Subtitle tokenization delivered; elapsed=${now() - startedAtMs}ms, staleDrops=${staleDropCount}`,
|
||||||
);
|
);
|
||||||
@@ -136,6 +174,14 @@ export function createSubtitleProcessingController(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
latestText = text;
|
latestText = text;
|
||||||
|
if (
|
||||||
|
processing &&
|
||||||
|
text !== lastPlainEmittedText &&
|
||||||
|
!tokenizationCache.has(normalizeSubtitleCacheKey(text))
|
||||||
|
) {
|
||||||
|
deps.emitSubtitle({ text, tokens: null });
|
||||||
|
lastPlainEmittedText = text;
|
||||||
|
}
|
||||||
processLatest();
|
processLatest();
|
||||||
},
|
},
|
||||||
refreshCurrentSubtitle: (textOverride?: string) => {
|
refreshCurrentSubtitle: (textOverride?: string) => {
|
||||||
@@ -169,13 +215,11 @@ export function createSubtitleProcessingController(
|
|||||||
latestText = text;
|
latestText = text;
|
||||||
lastEmittedText = text;
|
lastEmittedText = text;
|
||||||
lastEmittedGeneration = cacheGeneration;
|
lastEmittedGeneration = cacheGeneration;
|
||||||
|
lastPlainEmittedText = null;
|
||||||
return cached;
|
return cached;
|
||||||
},
|
},
|
||||||
hasCachedSubtitle: (text: string) => {
|
hasCachedSubtitle: (text: string) => {
|
||||||
return tokenizationCache.has(normalizeSubtitleCacheKey(text));
|
return tokenizationCache.has(normalizeSubtitleCacheKey(text));
|
||||||
},
|
},
|
||||||
isCacheFull: () => {
|
|
||||||
return tokenizationCache.size >= SUBTITLE_TOKENIZATION_CACHE_LIMIT;
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import test from 'node:test';
|
import test from 'node:test';
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import {
|
import {
|
||||||
|
isSubtitleAnnotationUpgrade,
|
||||||
serializeInitialSubtitleWebsocketMessage,
|
serializeInitialSubtitleWebsocketMessage,
|
||||||
serializeSubtitleMarkup,
|
serializeSubtitleMarkup,
|
||||||
serializeSubtitleWebsocketMessage,
|
serializeSubtitleWebsocketMessage,
|
||||||
@@ -13,6 +14,40 @@ const frequencyOptions = {
|
|||||||
mode: 'banded' as const,
|
mode: 'banded' as const,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
test('annotation upgrade requires matching text and cue timing', () => {
|
||||||
|
const current: SubtitleData = {
|
||||||
|
text: '字幕',
|
||||||
|
tokens: null,
|
||||||
|
startTime: 10,
|
||||||
|
endTime: 12,
|
||||||
|
};
|
||||||
|
|
||||||
|
assert.equal(
|
||||||
|
isSubtitleAnnotationUpgrade(current, {
|
||||||
|
...current,
|
||||||
|
tokens: [],
|
||||||
|
}),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
isSubtitleAnnotationUpgrade(current, {
|
||||||
|
...current,
|
||||||
|
tokens: [],
|
||||||
|
startTime: 11,
|
||||||
|
}),
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
isSubtitleAnnotationUpgrade(current, {
|
||||||
|
...current,
|
||||||
|
text: '次の字幕',
|
||||||
|
tokens: [],
|
||||||
|
}),
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
assert.equal(isSubtitleAnnotationUpgrade(current, current), false);
|
||||||
|
});
|
||||||
|
|
||||||
test('serializeSubtitleMarkup escapes plain text and preserves line breaks', () => {
|
test('serializeSubtitleMarkup escapes plain text and preserves line breaks', () => {
|
||||||
const payload: SubtitleData = {
|
const payload: SubtitleData = {
|
||||||
text: 'a < b\nx & y',
|
text: 'a < b\nx & y',
|
||||||
|
|||||||
@@ -20,6 +20,20 @@ export type SubtitleWebsocketFrequencyOptions = {
|
|||||||
|
|
||||||
export type SubtitleWebsocketPayloadMode = 'plain' | 'annotated';
|
export type SubtitleWebsocketPayloadMode = 'plain' | 'annotated';
|
||||||
|
|
||||||
|
export function isSubtitleAnnotationUpgrade(
|
||||||
|
current: SubtitleData | null,
|
||||||
|
next: SubtitleData,
|
||||||
|
): boolean {
|
||||||
|
return (
|
||||||
|
current !== null &&
|
||||||
|
current.tokens === null &&
|
||||||
|
next.tokens !== null &&
|
||||||
|
current.text === next.text &&
|
||||||
|
current.startTime === next.startTime &&
|
||||||
|
current.endTime === next.endTime
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
type SubtitleWebsocketMessageOptions = {
|
type SubtitleWebsocketMessageOptions = {
|
||||||
payloadMode?: SubtitleWebsocketPayloadMode;
|
payloadMode?: SubtitleWebsocketPayloadMode;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import * as os from 'node:os';
|
|||||||
import * as path from 'node:path';
|
import * as path from 'node:path';
|
||||||
|
|
||||||
import type { YoutubeMediaCacheMode } from '../../../types/integrations';
|
import type { YoutubeMediaCacheMode } from '../../../types/integrations';
|
||||||
import { getYoutubeYtDlpCommand } from './ytdlp-command';
|
import { getYoutubeYtDlpCommand, YTDLP_SINGLE_VIDEO_ARG } from './ytdlp-command';
|
||||||
|
|
||||||
type MediaCacheSessionState = 'running' | 'ready' | 'failed';
|
type MediaCacheSessionState = 'running' | 'ready' | 'failed';
|
||||||
|
|
||||||
@@ -88,7 +88,7 @@ function normalizeMaxHeight(maxHeight: number | undefined): number {
|
|||||||
|
|
||||||
function createYtDlpArgs(url: string, outputTemplate: string, maxHeight?: number): string[] {
|
function createYtDlpArgs(url: string, outputTemplate: string, maxHeight?: number): string[] {
|
||||||
return [
|
return [
|
||||||
'--no-playlist',
|
YTDLP_SINGLE_VIDEO_ARG,
|
||||||
'--no-warnings',
|
'--no-warnings',
|
||||||
'--force-ipv4',
|
'--force-ipv4',
|
||||||
'--retries',
|
'--retries',
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { spawn } from 'node:child_process';
|
import { spawn } from 'node:child_process';
|
||||||
import type { YoutubeVideoMetadata } from '../immersion-tracker/types';
|
import type { YoutubeVideoMetadata } from '../immersion-tracker/types';
|
||||||
import { getYoutubeYtDlpCommand } from './ytdlp-command';
|
import { getYoutubeYtDlpCommand, YTDLP_SINGLE_VIDEO_ARG } from './ytdlp-command';
|
||||||
|
|
||||||
const YOUTUBE_METADATA_PROBE_TIMEOUT_MS = 15_000;
|
const YOUTUBE_METADATA_PROBE_TIMEOUT_MS = 15_000;
|
||||||
|
|
||||||
@@ -85,15 +85,23 @@ function pickChannelThumbnail(thumbnails: YtDlpThumbnail[] | undefined): string
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function probeYoutubeVideoMetadata(
|
export function buildYoutubeMetadataProbeArgs(targetUrl: string): string[] {
|
||||||
targetUrl: string,
|
return [
|
||||||
): Promise<YoutubeVideoMetadata | null> {
|
YTDLP_SINGLE_VIDEO_ARG,
|
||||||
const { stdout } = await runCapture(getYoutubeYtDlpCommand(), [
|
|
||||||
'--dump-single-json',
|
'--dump-single-json',
|
||||||
'--no-warnings',
|
'--no-warnings',
|
||||||
'--skip-download',
|
'--skip-download',
|
||||||
targetUrl,
|
targetUrl,
|
||||||
]);
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function probeYoutubeVideoMetadata(
|
||||||
|
targetUrl: string,
|
||||||
|
): Promise<YoutubeVideoMetadata | null> {
|
||||||
|
const { stdout } = await runCapture(
|
||||||
|
getYoutubeYtDlpCommand(),
|
||||||
|
buildYoutubeMetadataProbeArgs(targetUrl),
|
||||||
|
);
|
||||||
let info: YtDlpYoutubeMetadata;
|
let info: YtDlpYoutubeMetadata;
|
||||||
try {
|
try {
|
||||||
info = JSON.parse(stdout) as YtDlpYoutubeMetadata;
|
info = JSON.parse(stdout) as YtDlpYoutubeMetadata;
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { spawn } from 'node:child_process';
|
import { spawn } from 'node:child_process';
|
||||||
import { getYoutubeYtDlpCommand } from './ytdlp-command';
|
import { getYoutubeYtDlpCommand, YTDLP_SINGLE_VIDEO_ARG } from './ytdlp-command';
|
||||||
|
|
||||||
const YOUTUBE_PLAYBACK_RESOLVE_TIMEOUT_MS = 15_000;
|
const YOUTUBE_PLAYBACK_RESOLVE_TIMEOUT_MS = 15_000;
|
||||||
const DEFAULT_PLAYBACK_FORMAT = 'b';
|
const DEFAULT_PLAYBACK_FORMAT = 'b';
|
||||||
@@ -85,17 +85,18 @@ function runCapture(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function buildYoutubePlaybackResolveArgs(targetUrl: string, format: string): string[] {
|
||||||
|
return [YTDLP_SINGLE_VIDEO_ARG, '--get-url', '--no-warnings', '-f', format, targetUrl];
|
||||||
|
}
|
||||||
|
|
||||||
export async function resolveYoutubePlaybackUrl(
|
export async function resolveYoutubePlaybackUrl(
|
||||||
targetUrl: string,
|
targetUrl: string,
|
||||||
format = DEFAULT_PLAYBACK_FORMAT,
|
format = DEFAULT_PLAYBACK_FORMAT,
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
const { stdout } = await runCapture(getYoutubeYtDlpCommand(), [
|
const { stdout } = await runCapture(
|
||||||
'--get-url',
|
getYoutubeYtDlpCommand(),
|
||||||
'--no-warnings',
|
buildYoutubePlaybackResolveArgs(targetUrl, format),
|
||||||
'-f',
|
);
|
||||||
format,
|
|
||||||
targetUrl,
|
|
||||||
]);
|
|
||||||
const playbackUrl =
|
const playbackUrl =
|
||||||
stdout
|
stdout
|
||||||
.split(/\r?\n/)
|
.split(/\r?\n/)
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import fs from 'node:fs';
|
|||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import { spawn } from 'node:child_process';
|
import { spawn } from 'node:child_process';
|
||||||
import type { YoutubeTrackOption } from './track-probe';
|
import type { YoutubeTrackOption } from './track-probe';
|
||||||
import { getYoutubeYtDlpCommand } from './ytdlp-command';
|
import { getYoutubeYtDlpCommand, YTDLP_SINGLE_VIDEO_ARG } from './ytdlp-command';
|
||||||
import {
|
import {
|
||||||
convertYoutubeTimedTextToVtt,
|
convertYoutubeTimedTextToVtt,
|
||||||
isYoutubeTimedTextExtension,
|
isYoutubeTimedTextExtension,
|
||||||
@@ -126,14 +126,14 @@ function pickLatestSubtitleFileForLanguage(
|
|||||||
return candidates[0] ?? null;
|
return candidates[0] ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildDownloadArgs(input: {
|
export function buildDownloadArgs(input: {
|
||||||
targetUrl: string;
|
targetUrl: string;
|
||||||
outputTemplate: string;
|
outputTemplate: string;
|
||||||
sourceLanguages: string[];
|
sourceLanguages: string[];
|
||||||
includeAutoSubs: boolean;
|
includeAutoSubs: boolean;
|
||||||
includeManualSubs: boolean;
|
includeManualSubs: boolean;
|
||||||
}): string[] {
|
}): string[] {
|
||||||
const args = ['--skip-download', '--no-warnings'];
|
const args = [YTDLP_SINGLE_VIDEO_ARG, '--skip-download', '--no-warnings'];
|
||||||
if (input.includeAutoSubs) {
|
if (input.includeAutoSubs) {
|
||||||
args.push('--write-auto-subs');
|
args.push('--write-auto-subs');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { spawn } from 'node:child_process';
|
import { spawn } from 'node:child_process';
|
||||||
import type { YoutubeTrackOption } from '../../../types';
|
import type { YoutubeTrackOption } from '../../../types';
|
||||||
import { formatYoutubeTrackLabel, normalizeYoutubeLangCode, type YoutubeTrackKind } from './labels';
|
import { formatYoutubeTrackLabel, normalizeYoutubeLangCode, type YoutubeTrackKind } from './labels';
|
||||||
import { getYoutubeYtDlpCommand } from './ytdlp-command';
|
import { getYoutubeYtDlpCommand, YTDLP_SINGLE_VIDEO_ARG } from './ytdlp-command';
|
||||||
|
|
||||||
const YOUTUBE_TRACK_PROBE_TIMEOUT_MS = 15_000;
|
const YOUTUBE_TRACK_PROBE_TIMEOUT_MS = 15_000;
|
||||||
|
|
||||||
@@ -111,12 +111,15 @@ function toTracks(entries: Record<string, YtDlpSubtitleEntry> | undefined, kind:
|
|||||||
|
|
||||||
export type { YoutubeTrackOption };
|
export type { YoutubeTrackOption };
|
||||||
|
|
||||||
|
export function buildYoutubeTrackProbeArgs(targetUrl: string): string[] {
|
||||||
|
return [YTDLP_SINGLE_VIDEO_ARG, '--dump-single-json', '--no-warnings', targetUrl];
|
||||||
|
}
|
||||||
|
|
||||||
export async function probeYoutubeTracks(targetUrl: string): Promise<YoutubeTrackProbeResult> {
|
export async function probeYoutubeTracks(targetUrl: string): Promise<YoutubeTrackProbeResult> {
|
||||||
const { stdout } = await runCapture(getYoutubeYtDlpCommand(), [
|
const { stdout } = await runCapture(
|
||||||
'--dump-single-json',
|
getYoutubeYtDlpCommand(),
|
||||||
'--no-warnings',
|
buildYoutubeTrackProbeArgs(targetUrl),
|
||||||
targetUrl,
|
);
|
||||||
]);
|
|
||||||
const trimmedStdout = stdout.trim();
|
const trimmedStdout = stdout.trim();
|
||||||
if (!trimmedStdout) {
|
if (!trimmedStdout) {
|
||||||
throw new Error('yt-dlp returned empty output while probing subtitle tracks');
|
throw new Error('yt-dlp returned empty output while probing subtitle tracks');
|
||||||
|
|||||||
@@ -4,6 +4,13 @@ import path from 'node:path';
|
|||||||
const DEFAULT_YTDLP_COMMAND = 'yt-dlp';
|
const DEFAULT_YTDLP_COMMAND = 'yt-dlp';
|
||||||
const WINDOWS_YTDLP_COMMANDS = ['yt-dlp.cmd', 'yt-dlp.exe', 'yt-dlp'];
|
const WINDOWS_YTDLP_COMMANDS = ['yt-dlp.cmd', 'yt-dlp.exe', 'yt-dlp'];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* yt-dlp expands `list=`/`index=` URL params into the whole playlist unless told not to, which
|
||||||
|
* makes single-video extraction hang (e.g. a full Watch Later list) until our timeouts fire.
|
||||||
|
* Every yt-dlp invocation targeting one video must include this.
|
||||||
|
*/
|
||||||
|
export const YTDLP_SINGLE_VIDEO_ARG = '--no-playlist';
|
||||||
|
|
||||||
function resolveFromPath(commandName: string): string | null {
|
function resolveFromPath(commandName: string): string | null {
|
||||||
if (!process.env.PATH) {
|
if (!process.env.PATH) {
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
import { buildYoutubeMetadataProbeArgs } from './metadata-probe';
|
||||||
|
import { buildYoutubePlaybackResolveArgs } from './playback-resolve';
|
||||||
|
import { buildDownloadArgs } from './track-download';
|
||||||
|
import { buildYoutubeTrackProbeArgs } from './track-probe';
|
||||||
|
import { YTDLP_SINGLE_VIDEO_ARG } from './ytdlp-command';
|
||||||
|
|
||||||
|
// Regression guard for issue #179: a `list=`/`index=` URL made yt-dlp enumerate the whole
|
||||||
|
// playlist (e.g. Watch Later) and blow past our 15s timeouts on every single-video call.
|
||||||
|
const PLAYLIST_URL = 'https://www.youtube.com/watch?v=LKfWC6CgFng&list=WL&index=3';
|
||||||
|
|
||||||
|
const cases: Array<{ name: string; args: string[] }> = [
|
||||||
|
{ name: 'track probe', args: buildYoutubeTrackProbeArgs(PLAYLIST_URL) },
|
||||||
|
{ name: 'metadata probe', args: buildYoutubeMetadataProbeArgs(PLAYLIST_URL) },
|
||||||
|
{ name: 'playback resolve', args: buildYoutubePlaybackResolveArgs(PLAYLIST_URL, 'b') },
|
||||||
|
{
|
||||||
|
name: 'subtitle download',
|
||||||
|
args: buildDownloadArgs({
|
||||||
|
targetUrl: PLAYLIST_URL,
|
||||||
|
outputTemplate: '/tmp/out.%(ext)s',
|
||||||
|
sourceLanguages: ['ja'],
|
||||||
|
includeAutoSubs: true,
|
||||||
|
includeManualSubs: false,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
test('YTDLP_SINGLE_VIDEO_ARG is the yt-dlp flag that disables playlist expansion', () => {
|
||||||
|
assert.equal(YTDLP_SINGLE_VIDEO_ARG, '--no-playlist');
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const { name, args } of cases) {
|
||||||
|
test(`${name} passes --no-playlist for playlist-scoped URLs`, () => {
|
||||||
|
assert.ok(args.includes('--no-playlist'), `${name} args: ${args.join(' ')}`);
|
||||||
|
assert.equal(args.at(-1), PLAYLIST_URL);
|
||||||
|
});
|
||||||
|
}
|
||||||
+16
-2
@@ -295,6 +295,7 @@ import {
|
|||||||
importYomitanDictionaryFromZip,
|
importYomitanDictionaryFromZip,
|
||||||
initializeOverlayAnkiIntegration as initializeOverlayAnkiIntegrationCore,
|
initializeOverlayAnkiIntegration as initializeOverlayAnkiIntegrationCore,
|
||||||
initializeOverlayRuntime as initializeOverlayRuntimeCore,
|
initializeOverlayRuntime as initializeOverlayRuntimeCore,
|
||||||
|
isSubtitleAnnotationUpgrade,
|
||||||
isOverlayWindowContentReady,
|
isOverlayWindowContentReady,
|
||||||
jellyfinTicksToSecondsRuntime,
|
jellyfinTicksToSecondsRuntime,
|
||||||
listJellyfinItemsRuntime,
|
listJellyfinItemsRuntime,
|
||||||
@@ -1817,6 +1818,8 @@ function withCurrentSubtitleTiming(payload: SubtitleData): SubtitleData {
|
|||||||
}
|
}
|
||||||
function emitSubtitlePayload(payload: SubtitleData): void {
|
function emitSubtitlePayload(payload: SubtitleData): void {
|
||||||
const timedPayload = withCurrentSubtitleTiming(payload);
|
const timedPayload = withCurrentSubtitleTiming(payload);
|
||||||
|
const currentSubtitleData = appState.currentSubtitleData;
|
||||||
|
const isAnnotationUpgrade = isSubtitleAnnotationUpgrade(currentSubtitleData, timedPayload);
|
||||||
const frequencyDictionary = configService.getConfig().subtitleStyle.frequencyDictionary;
|
const frequencyDictionary = configService.getConfig().subtitleStyle.frequencyDictionary;
|
||||||
const frequencyOptions = {
|
const frequencyOptions = {
|
||||||
enabled: frequencyDictionary.enabled,
|
enabled: frequencyDictionary.enabled,
|
||||||
@@ -1825,7 +1828,9 @@ function emitSubtitlePayload(payload: SubtitleData): void {
|
|||||||
};
|
};
|
||||||
appState.currentSubtitleData = timedPayload;
|
appState.currentSubtitleData = timedPayload;
|
||||||
overlayManager.broadcastToOverlayWindows('subtitle:set', timedPayload);
|
overlayManager.broadcastToOverlayWindows('subtitle:set', timedPayload);
|
||||||
|
if (!isAnnotationUpgrade) {
|
||||||
subtitleWsService.broadcast(timedPayload, frequencyOptions);
|
subtitleWsService.broadcast(timedPayload, frequencyOptions);
|
||||||
|
}
|
||||||
annotationSubtitleWsService.broadcast(timedPayload, frequencyOptions);
|
annotationSubtitleWsService.broadcast(timedPayload, frequencyOptions);
|
||||||
autoplayReadyGate.maybeSignalPluginAutoplayReady(timedPayload, { forceWhilePaused: true });
|
autoplayReadyGate.maybeSignalPluginAutoplayReady(timedPayload, { forceWhilePaused: true });
|
||||||
subtitlePrefetchService?.resume();
|
subtitlePrefetchService?.resume();
|
||||||
@@ -1955,7 +1960,6 @@ const subtitlePrefetchInitController = createSubtitlePrefetchInitController({
|
|||||||
subtitleProcessingController.preCacheTokenization(text, data);
|
subtitleProcessingController.preCacheTokenization(text, data);
|
||||||
},
|
},
|
||||||
hasCachedTokenization: (text) => subtitleProcessingController.hasCachedSubtitle(text),
|
hasCachedTokenization: (text) => subtitleProcessingController.hasCachedSubtitle(text),
|
||||||
isCacheFull: () => subtitleProcessingController.isCacheFull(),
|
|
||||||
logInfo: (message) => logger.info(message),
|
logInfo: (message) => logger.info(message),
|
||||||
logWarn: (message) => logger.warn(message),
|
logWarn: (message) => logger.warn(message),
|
||||||
onParsedSubtitleCuesChanged: (cues, sourceKey) => {
|
onParsedSubtitleCuesChanged: (cues, sourceKey) => {
|
||||||
@@ -1982,15 +1986,22 @@ const resolveActiveSubtitleSidebarSourceHandler = createResolveActiveSubtitleSid
|
|||||||
getFfmpegPath: () => configService.getConfig().subsync.ffmpeg_path.trim() || 'ffmpeg',
|
getFfmpegPath: () => configService.getConfig().subsync.ffmpeg_path.trim() || 'ffmpeg',
|
||||||
extractInternalSubtitleTrack: (ffmpegPath, videoPath, track) =>
|
extractInternalSubtitleTrack: (ffmpegPath, videoPath, track) =>
|
||||||
extractInternalSubtitleTrackToTempFile(ffmpegPath, videoPath, track),
|
extractInternalSubtitleTrackToTempFile(ffmpegPath, videoPath, track),
|
||||||
|
logDebug: (message) => logger.debug(message),
|
||||||
});
|
});
|
||||||
|
|
||||||
const refreshSubtitlePrefetchFromActiveTrackHandler =
|
const refreshSubtitlePrefetchFromActiveTrackHandler =
|
||||||
createRefreshSubtitlePrefetchFromActiveTrackHandler({
|
createRefreshSubtitlePrefetchFromActiveTrackHandler({
|
||||||
getMpvClient: () => appState.mpvClient,
|
getMpvClient: () => appState.mpvClient,
|
||||||
getLastObservedTimePos: () => lastObservedTimePos,
|
getLastObservedTimePos: () => lastObservedTimePos,
|
||||||
shouldKeepExistingCuesOnMissingSource: (videoPath) => isYoutubeMediaPath(videoPath),
|
// Remote media has no extractable on-disk track to fall back to, so a transient
|
||||||
|
// resolve miss (sid briefly 'no', a cycle onto an embedded stream track) would
|
||||||
|
// otherwise drop a working cue list for the rest of the episode.
|
||||||
|
shouldKeepExistingCuesOnMissingSource: (videoPath) =>
|
||||||
|
isYoutubeMediaPath(videoPath) || isRemoteMediaPath(videoPath),
|
||||||
subtitlePrefetchInitController,
|
subtitlePrefetchInitController,
|
||||||
resolveActiveSubtitleSidebarSource: (input) => resolveActiveSubtitleSidebarSourceHandler(input),
|
resolveActiveSubtitleSidebarSource: (input) => resolveActiveSubtitleSidebarSourceHandler(input),
|
||||||
|
logDebug: (message) => logger.debug(message),
|
||||||
|
logWarn: (message) => logger.warn(message),
|
||||||
});
|
});
|
||||||
|
|
||||||
const subtitlePrefetchRuntime = {
|
const subtitlePrefetchRuntime = {
|
||||||
@@ -2994,6 +3005,8 @@ const {
|
|||||||
streamIndex,
|
streamIndex,
|
||||||
delaySeconds,
|
delaySeconds,
|
||||||
}),
|
}),
|
||||||
|
initSubtitlePrefetch: (sourcePath) =>
|
||||||
|
subtitlePrefetchRuntime.refreshSubtitleSidebarFromSource(sourcePath),
|
||||||
logDebug: (message, error) => {
|
logDebug: (message, error) => {
|
||||||
logger.debug(message, error);
|
logger.debug(message, error);
|
||||||
},
|
},
|
||||||
@@ -4366,6 +4379,7 @@ const {
|
|||||||
refreshDiscordPresence: () => {
|
refreshDiscordPresence: () => {
|
||||||
discordPresenceRuntime.publishDiscordPresence();
|
discordPresenceRuntime.publishDiscordPresence();
|
||||||
},
|
},
|
||||||
|
logSubtitleProcessingDebug: (message: string) => logger.debug(message),
|
||||||
ensureImmersionTrackerInitialized: () => {
|
ensureImmersionTrackerInitialized: () => {
|
||||||
ensureImmersionTrackerStarted();
|
ensureImmersionTrackerStarted();
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -176,6 +176,29 @@ test('subtitle sidebar media path tag is assigned after prefetch succeeds', () =
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('remote media keeps parsed cues when the active subtitle source cannot be resolved', () => {
|
||||||
|
const source = readMainSource();
|
||||||
|
const actionBlock = source.match(
|
||||||
|
/createRefreshSubtitlePrefetchFromActiveTrackHandler\(\{(?<body>[\s\S]*?)\n \}\);/,
|
||||||
|
)?.groups?.body;
|
||||||
|
|
||||||
|
assert.ok(actionBlock);
|
||||||
|
assert.match(actionBlock, /isYoutubeMediaPath\(videoPath\) \|\| isRemoteMediaPath\(videoPath\)/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('jellyfin subtitle preload seeds the tokenization prefetch directly', () => {
|
||||||
|
const source = readMainSource();
|
||||||
|
const actionBlock = source.match(
|
||||||
|
/preloadJellyfinExternalSubtitlesMainDeps:\s*\{(?<body>[\s\S]*?)\n \},/,
|
||||||
|
)?.groups?.body;
|
||||||
|
|
||||||
|
assert.ok(actionBlock);
|
||||||
|
assert.match(
|
||||||
|
actionBlock,
|
||||||
|
/initSubtitlePrefetch: \(sourcePath\) =>\s*subtitlePrefetchRuntime\.refreshSubtitleSidebarFromSource\(sourcePath\),/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
test('update overlay notification action triggers install flow', () => {
|
test('update overlay notification action triggers install flow', () => {
|
||||||
const source = readMainSource();
|
const source = readMainSource();
|
||||||
const runtimeSource = readSource('src/main/runtime/overlay-notifications-runtime.ts');
|
const runtimeSource = readSource('src/main/runtime/overlay-notifications-runtime.ts');
|
||||||
@@ -590,6 +613,35 @@ test('subtitle broadcasts share one frequency options snapshot per emitted paylo
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('annotation upgrades skip the duplicate basic websocket event', () => {
|
||||||
|
const source = readMainSource();
|
||||||
|
const emitBlock = source.match(
|
||||||
|
/function emitSubtitlePayload\(payload: SubtitleData\): void \{(?<body>[\s\S]*?)\n\}/,
|
||||||
|
)?.groups?.body;
|
||||||
|
|
||||||
|
assert.ok(emitBlock);
|
||||||
|
assert.match(
|
||||||
|
emitBlock,
|
||||||
|
/const isAnnotationUpgrade = isSubtitleAnnotationUpgrade\(currentSubtitleData, timedPayload\);/,
|
||||||
|
);
|
||||||
|
assert.match(
|
||||||
|
emitBlock,
|
||||||
|
/if \(!isAnnotationUpgrade\) \{\s+subtitleWsService\.broadcast\(timedPayload, frequencyOptions\);\s+\}/,
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
(emitBlock.match(/overlayManager\.broadcastToOverlayWindows\('subtitle:set'/g) ?? []).length,
|
||||||
|
1,
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
(
|
||||||
|
emitBlock.match(
|
||||||
|
/annotationSubtitleWsService\.broadcast\(timedPayload, frequencyOptions\)/g,
|
||||||
|
) ?? []
|
||||||
|
).length,
|
||||||
|
1,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
test('websocket frequency options callbacks each read one configuration snapshot', () => {
|
test('websocket frequency options callbacks each read one configuration snapshot', () => {
|
||||||
const source = readMainSource();
|
const source = readMainSource();
|
||||||
const subtitleBlock = source.match(
|
const subtitleBlock = source.match(
|
||||||
|
|||||||
@@ -326,7 +326,13 @@ test('handleOverlayModalClosed hides modal window only after all pending modals
|
|||||||
});
|
});
|
||||||
runtime.sendToActiveOverlayWindow(
|
runtime.sendToActiveOverlayWindow(
|
||||||
'subsync:open-manual',
|
'subsync:open-manual',
|
||||||
{ ffsubsyncAvailable: true, sourceTracks: [] },
|
{
|
||||||
|
ffsubsyncAvailable: true,
|
||||||
|
videoReferenceAvailable: true,
|
||||||
|
subtitleTracks: [],
|
||||||
|
defaultReferenceTrackId: null,
|
||||||
|
defaultTargetTrackId: null,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
restoreOnModalClose: 'subsync',
|
restoreOnModalClose: 'subsync',
|
||||||
},
|
},
|
||||||
@@ -560,7 +566,13 @@ test('modal runtime notifies callers when modal input state becomes active/inact
|
|||||||
});
|
});
|
||||||
runtime.sendToActiveOverlayWindow(
|
runtime.sendToActiveOverlayWindow(
|
||||||
'subsync:open-manual',
|
'subsync:open-manual',
|
||||||
{ ffsubsyncAvailable: true, sourceTracks: [] },
|
{
|
||||||
|
ffsubsyncAvailable: true,
|
||||||
|
videoReferenceAvailable: true,
|
||||||
|
subtitleTracks: [],
|
||||||
|
defaultReferenceTrackId: null,
|
||||||
|
defaultTargetTrackId: null,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
restoreOnModalClose: 'subsync',
|
restoreOnModalClose: 'subsync',
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { composeIpcRuntimeHandlers } from './ipc-runtime-composer';
|
|||||||
|
|
||||||
test('composeIpcRuntimeHandlers returns callable IPC handlers and registration bridge', async () => {
|
test('composeIpcRuntimeHandlers returns callable IPC handlers and registration bridge', async () => {
|
||||||
let registered = false;
|
let registered = false;
|
||||||
let receivedSourceTrackId: number | null | undefined;
|
let receivedReferenceTrackId: number | null | undefined;
|
||||||
|
|
||||||
const composed = composeIpcRuntimeHandlers({
|
const composed = composeIpcRuntimeHandlers({
|
||||||
mpvCommandMainDeps: {
|
mpvCommandMainDeps: {
|
||||||
@@ -25,7 +25,7 @@ test('composeIpcRuntimeHandlers returns callable IPC handlers and registration b
|
|||||||
},
|
},
|
||||||
handleMpvCommandFromIpcRuntime: () => {},
|
handleMpvCommandFromIpcRuntime: () => {},
|
||||||
runSubsyncManualFromIpc: async (request) => {
|
runSubsyncManualFromIpc: async (request) => {
|
||||||
receivedSourceTrackId = request.sourceTrackId;
|
receivedReferenceTrackId = request.referenceTrackId;
|
||||||
return {
|
return {
|
||||||
ok: true,
|
ok: true,
|
||||||
message: 'ok',
|
message: 'ok',
|
||||||
@@ -124,10 +124,10 @@ test('composeIpcRuntimeHandlers returns callable IPC handlers and registration b
|
|||||||
|
|
||||||
const result = await composed.runSubsyncManualFromIpc({
|
const result = await composed.runSubsyncManualFromIpc({
|
||||||
engine: 'alass',
|
engine: 'alass',
|
||||||
sourceTrackId: 7,
|
referenceTrackId: 7,
|
||||||
});
|
});
|
||||||
assert.deepEqual(result, { ok: true, message: 'ok' });
|
assert.deepEqual(result, { ok: true, message: 'ok' });
|
||||||
assert.equal(receivedSourceTrackId, 7);
|
assert.equal(receivedReferenceTrackId, 7);
|
||||||
|
|
||||||
composed.registerIpcRuntimeHandlers();
|
composed.registerIpcRuntimeHandlers();
|
||||||
assert.equal(registered, true);
|
assert.equal(registered, true);
|
||||||
|
|||||||
@@ -73,6 +73,7 @@ test('createConfigHotReloadAppliedHandler applies safe Anki, annotation, and log
|
|||||||
config.ankiConnect.fields.miscInfo = 'MiscInfoCustom';
|
config.ankiConnect.fields.miscInfo = 'MiscInfoCustom';
|
||||||
config.ankiConnect.isLapis.sentenceCardModel = 'Sentence Card Custom';
|
config.ankiConnect.isLapis.sentenceCardModel = 'Sentence Card Custom';
|
||||||
config.ankiConnect.isKiku.fieldGrouping = 'manual';
|
config.ankiConnect.isKiku.fieldGrouping = 'manual';
|
||||||
|
config.ankiConnect.lapisKiku.wordCardKind = 'click';
|
||||||
config.logging.level = 'debug';
|
config.logging.level = 'debug';
|
||||||
config.logging.rotation = 14;
|
config.logging.rotation = 14;
|
||||||
config.logging.files.mpv = true;
|
config.logging.files.mpv = true;
|
||||||
@@ -114,6 +115,7 @@ test('createConfigHotReloadAppliedHandler applies safe Anki, annotation, and log
|
|||||||
'ankiConnect.fields.miscInfo',
|
'ankiConnect.fields.miscInfo',
|
||||||
'ankiConnect.isLapis.sentenceCardModel',
|
'ankiConnect.isLapis.sentenceCardModel',
|
||||||
'ankiConnect.isKiku.fieldGrouping',
|
'ankiConnect.isKiku.fieldGrouping',
|
||||||
|
'ankiConnect.lapisKiku.wordCardKind',
|
||||||
'logging.level',
|
'logging.level',
|
||||||
'logging.rotation',
|
'logging.rotation',
|
||||||
'logging.files.mpv',
|
'logging.files.mpv',
|
||||||
@@ -138,6 +140,7 @@ test('createConfigHotReloadAppliedHandler applies safe Anki, annotation, and log
|
|||||||
},
|
},
|
||||||
isLapis: { sentenceCardModel: 'Sentence Card Custom' },
|
isLapis: { sentenceCardModel: 'Sentence Card Custom' },
|
||||||
isKiku: { fieldGrouping: 'manual' },
|
isKiku: { fieldGrouping: 'manual' },
|
||||||
|
lapisKiku: { wordCardKind: 'click' },
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
assert.ok(calls.includes('invalidate:tokens'));
|
assert.ok(calls.includes('invalidate:tokens'));
|
||||||
|
|||||||
@@ -134,6 +134,9 @@ function buildAnkiRuntimeConfigPatch(
|
|||||||
if (diff.hotReloadFields.includes('ankiConnect.isKiku.fieldGrouping')) {
|
if (diff.hotReloadFields.includes('ankiConnect.isKiku.fieldGrouping')) {
|
||||||
patch.isKiku = { fieldGrouping: config.ankiConnect.isKiku.fieldGrouping };
|
patch.isKiku = { fieldGrouping: config.ankiConnect.isKiku.fieldGrouping };
|
||||||
}
|
}
|
||||||
|
if (diff.hotReloadFields.includes('ankiConnect.lapisKiku.wordCardKind')) {
|
||||||
|
patch.lapisKiku = { wordCardKind: config.ankiConnect.lapisKiku.wordCardKind };
|
||||||
|
}
|
||||||
|
|
||||||
return Object.keys(patch).length > 0 ? patch : null;
|
return Object.keys(patch).length > 0 ? patch : null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import assert from 'node:assert/strict';
|
|||||||
import test from 'node:test';
|
import test from 'node:test';
|
||||||
import {
|
import {
|
||||||
getPlaybackFeedbackNotificationOptions,
|
getPlaybackFeedbackNotificationOptions,
|
||||||
|
getSubsyncStatusNotificationOptions,
|
||||||
getYoutubeFlowStatusNotificationOptions,
|
getYoutubeFlowStatusNotificationOptions,
|
||||||
notifyConfiguredStatus,
|
notifyConfiguredStatus,
|
||||||
} from './configured-status-notification';
|
} from './configured-status-notification';
|
||||||
@@ -10,7 +11,7 @@ import { createOverlayNotificationDelivery } from './overlay-notification-delive
|
|||||||
test('notifyConfiguredStatus routes both to overlay and system without osd', () => {
|
test('notifyConfiguredStatus routes both to overlay and system without osd', () => {
|
||||||
const calls: string[] = [];
|
const calls: string[] = [];
|
||||||
|
|
||||||
notifyConfiguredStatus('Subsync: choose engine and source', {
|
notifyConfiguredStatus('Subsync: choose engine and subtitles', {
|
||||||
getNotificationType: () => 'both',
|
getNotificationType: () => 'both',
|
||||||
showOsd: (message) => {
|
showOsd: (message) => {
|
||||||
calls.push(`osd:${message}`);
|
calls.push(`osd:${message}`);
|
||||||
@@ -24,8 +25,8 @@ test('notifyConfiguredStatus routes both to overlay and system without osd', ()
|
|||||||
});
|
});
|
||||||
|
|
||||||
assert.deepEqual(calls, [
|
assert.deepEqual(calls, [
|
||||||
'overlay::SubMiner:Subsync: choose engine and source:info:auto',
|
'overlay::SubMiner:Subsync: choose engine and subtitles:info:auto',
|
||||||
'desktop:SubMiner:Subsync: choose engine and source',
|
'desktop:SubMiner:Subsync: choose engine and subtitles',
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -158,6 +159,64 @@ test('notifyConfiguredStatus can suppress desktop delivery for progress ticks',
|
|||||||
assert.deepEqual(calls, ['overlay:subsync-status:Subsync:Subsync: syncing |:progress:pin']);
|
assert.deepEqual(calls, ['overlay:subsync-status:Subsync:Subsync: syncing |:progress:pin']);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('subsync progress keeps the osd spinner frame but strips it from the overlay card', () => {
|
||||||
|
const calls: string[] = [];
|
||||||
|
|
||||||
|
for (const frame of ['|', '/', '-', '\\']) {
|
||||||
|
const message = `Subsync: syncing ${frame}`;
|
||||||
|
notifyConfiguredStatus(
|
||||||
|
message,
|
||||||
|
{
|
||||||
|
getNotificationType: () => 'both',
|
||||||
|
showOsd: (osdMessage) => {
|
||||||
|
calls.push(`osd:${osdMessage}`);
|
||||||
|
},
|
||||||
|
showOverlayNotification: (payload) =>
|
||||||
|
calls.push(
|
||||||
|
`overlay:${payload.body}:${payload.variant}:${payload.persistent ? 'pin' : 'auto'}`,
|
||||||
|
),
|
||||||
|
showDesktopNotification: (title, options) =>
|
||||||
|
calls.push(`desktop:${title}:${options.body ?? ''}`),
|
||||||
|
},
|
||||||
|
getSubsyncStatusNotificationOptions(message),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.deepEqual(calls, [
|
||||||
|
'overlay:Subsync: syncing:progress:pin',
|
||||||
|
'overlay:Subsync: syncing:progress:pin',
|
||||||
|
'overlay:Subsync: syncing:progress:pin',
|
||||||
|
'overlay:Subsync: syncing:progress:pin',
|
||||||
|
]);
|
||||||
|
|
||||||
|
calls.length = 0;
|
||||||
|
notifyConfiguredStatus(
|
||||||
|
'Subsync: syncing /',
|
||||||
|
{
|
||||||
|
getNotificationType: () => 'osd',
|
||||||
|
showOsd: (osdMessage) => {
|
||||||
|
calls.push(`osd:${osdMessage}`);
|
||||||
|
},
|
||||||
|
showOverlayNotification: (payload) => calls.push(`overlay:${payload.body}`),
|
||||||
|
showDesktopNotification: (title, options) =>
|
||||||
|
calls.push(`desktop:${title}:${options.body ?? ''}`),
|
||||||
|
},
|
||||||
|
getSubsyncStatusNotificationOptions('Subsync: syncing /'),
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.deepEqual(calls, ['osd:Subsync: syncing /']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('subsync result notifications keep their message intact', () => {
|
||||||
|
assert.equal(
|
||||||
|
getSubsyncStatusNotificationOptions('Subtitle synchronized with ffsubsync').overlayBody,
|
||||||
|
'Subtitle synchronized with ffsubsync',
|
||||||
|
);
|
||||||
|
const failure = getSubsyncStatusNotificationOptions('ffsubsync synchronization failed: boom');
|
||||||
|
assert.equal(failure.variant, 'error');
|
||||||
|
assert.equal(failure.overlayBody, 'ffsubsync synchronization failed: boom');
|
||||||
|
});
|
||||||
|
|
||||||
test('notifyConfiguredStatus routes feedback through overlay without desktop delivery', () => {
|
test('notifyConfiguredStatus routes feedback through overlay without desktop delivery', () => {
|
||||||
const calls: string[] = [];
|
const calls: string[] = [];
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ export interface ConfiguredStatusNotificationDeps {
|
|||||||
|
|
||||||
export interface ConfiguredStatusNotificationOptions {
|
export interface ConfiguredStatusNotificationOptions {
|
||||||
id?: string;
|
id?: string;
|
||||||
|
/** Overrides the overlay card body (the OSD/desktop paths keep the raw message). */
|
||||||
|
overlayBody?: string;
|
||||||
title?: string;
|
title?: string;
|
||||||
variant?: OverlayNotificationPayload['variant'];
|
variant?: OverlayNotificationPayload['variant'];
|
||||||
persistent?: boolean;
|
persistent?: boolean;
|
||||||
@@ -31,6 +33,23 @@ export function getPlaybackFeedbackNotificationOptions(
|
|||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getSubsyncStatusNotificationOptions(
|
||||||
|
message: string,
|
||||||
|
): ConfiguredStatusNotificationOptions {
|
||||||
|
const syncing = message.startsWith('Subsync: syncing');
|
||||||
|
const failed = message.toLowerCase().includes('failed');
|
||||||
|
return {
|
||||||
|
id: 'subsync-status',
|
||||||
|
title: 'Subsync',
|
||||||
|
// The overlay card renders its own animated spinner, so drop the ASCII
|
||||||
|
// spinner frame that the OSD path still needs.
|
||||||
|
overlayBody: syncing ? message.replace(/\s+[|/\-\\]$/, '') : message,
|
||||||
|
variant: failed ? 'error' : syncing ? 'progress' : 'info',
|
||||||
|
persistent: syncing,
|
||||||
|
desktop: !syncing,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export function getYoutubeFlowStatusNotificationOptions(
|
export function getYoutubeFlowStatusNotificationOptions(
|
||||||
message: string,
|
message: string,
|
||||||
): ConfiguredStatusNotificationOptions {
|
): ConfiguredStatusNotificationOptions {
|
||||||
@@ -74,7 +93,7 @@ export function notifyConfiguredStatus(
|
|||||||
deps.showOverlayNotification({
|
deps.showOverlayNotification({
|
||||||
id: options.id,
|
id: options.id,
|
||||||
title: options.title ?? 'SubMiner',
|
title: options.title ?? 'SubMiner',
|
||||||
body: message,
|
body: options.overlayBody ?? message,
|
||||||
variant: options.variant ?? 'info',
|
variant: options.variant ?? 'info',
|
||||||
persistent: options.persistent ?? false,
|
persistent: options.persistent ?? false,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -28,6 +28,9 @@ export function createBuildPreloadJellyfinExternalSubtitlesMainDepsHandler(
|
|||||||
? (itemId, streamIndex, delaySeconds) =>
|
? (itemId, streamIndex, delaySeconds) =>
|
||||||
deps.saveSubtitleDelay!(itemId, streamIndex, delaySeconds)
|
deps.saveSubtitleDelay!(itemId, streamIndex, delaySeconds)
|
||||||
: undefined,
|
: undefined,
|
||||||
|
initSubtitlePrefetch: deps.initSubtitlePrefetch
|
||||||
|
? (sourcePath) => deps.initSubtitlePrefetch!(sourcePath)
|
||||||
|
: undefined,
|
||||||
logDebug: (message: string, error: unknown) => deps.logDebug(message, error),
|
logDebug: (message: string, error: unknown) => deps.logDebug(message, error),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -40,6 +40,9 @@ function makeDeps(overrides: {
|
|||||||
>[0]['setActiveSubtitleDelayKey'];
|
>[0]['setActiveSubtitleDelayKey'];
|
||||||
loadSubtitleSourceText?: (source: string) => Promise<string>;
|
loadSubtitleSourceText?: (source: string) => Promise<string>;
|
||||||
saveSubtitleDelay?: (itemId: string, streamIndex: number, delaySeconds: number) => void;
|
saveSubtitleDelay?: (itemId: string, streamIndex: number, delaySeconds: number) => void;
|
||||||
|
initSubtitlePrefetch?: Parameters<
|
||||||
|
typeof createPreloadJellyfinExternalSubtitlesHandler
|
||||||
|
>[0]['initSubtitlePrefetch'];
|
||||||
logDebug?: Parameters<typeof createPreloadJellyfinExternalSubtitlesHandler>[0]['logDebug'];
|
logDebug?: Parameters<typeof createPreloadJellyfinExternalSubtitlesHandler>[0]['logDebug'];
|
||||||
}) {
|
}) {
|
||||||
return {
|
return {
|
||||||
@@ -58,6 +61,7 @@ function makeDeps(overrides: {
|
|||||||
setActiveSubtitleDelayKey: overrides.setActiveSubtitleDelayKey,
|
setActiveSubtitleDelayKey: overrides.setActiveSubtitleDelayKey,
|
||||||
loadSubtitleSourceText: overrides.loadSubtitleSourceText,
|
loadSubtitleSourceText: overrides.loadSubtitleSourceText,
|
||||||
saveSubtitleDelay: overrides.saveSubtitleDelay,
|
saveSubtitleDelay: overrides.saveSubtitleDelay,
|
||||||
|
initSubtitlePrefetch: overrides.initSubtitlePrefetch,
|
||||||
logDebug: overrides.logDebug ?? (() => {}),
|
logDebug: overrides.logDebug ?? (() => {}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -134,6 +138,92 @@ test('preload jellyfin subtitles caches external tracks locally and chooses japa
|
|||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('preload jellyfin subtitles starts prefetch for the selected japanese track', async () => {
|
||||||
|
const prefetched: string[] = [];
|
||||||
|
const preload = createPreloadJellyfinExternalSubtitlesHandler(
|
||||||
|
makeDeps({
|
||||||
|
listJellyfinSubtitleTracks: async () => [
|
||||||
|
{ index: 0, language: 'jpn', title: 'Japanese', deliveryUrl: 'https://sub/a.srt' },
|
||||||
|
{ index: 1, language: 'eng', title: 'English', deliveryUrl: 'https://sub/b.srt' },
|
||||||
|
],
|
||||||
|
getMpvClient: () => ({
|
||||||
|
requestProperty: async () => [
|
||||||
|
{
|
||||||
|
type: 'sub',
|
||||||
|
id: 5,
|
||||||
|
lang: 'jpn',
|
||||||
|
title: 'Japanese',
|
||||||
|
external: true,
|
||||||
|
'external-filename': '/tmp/subminer-jellyfin-subtitles/0.srt',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'sub',
|
||||||
|
id: 6,
|
||||||
|
lang: 'eng',
|
||||||
|
title: 'English',
|
||||||
|
external: true,
|
||||||
|
'external-filename': '/tmp/subminer-jellyfin-subtitles/1.srt',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
cacheSubtitleTrack: async (track) => ({
|
||||||
|
path: `/tmp/subminer-jellyfin-subtitles/${track.index}.srt`,
|
||||||
|
cleanupDir: '/tmp/subminer-jellyfin-subtitles',
|
||||||
|
}),
|
||||||
|
initSubtitlePrefetch: (sourcePath) => {
|
||||||
|
prefetched.push(sourcePath);
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
await preload({ session, clientInfo, itemId: 'item-1' });
|
||||||
|
await new Promise((resolve) => setImmediate(resolve));
|
||||||
|
|
||||||
|
assert.deepEqual(prefetched, ['/tmp/subminer-jellyfin-subtitles/0.srt']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('preload jellyfin subtitles survives prefetch start failures', async () => {
|
||||||
|
const logs: string[] = [];
|
||||||
|
const commands: Array<Array<string | number>> = [];
|
||||||
|
const preload = createPreloadJellyfinExternalSubtitlesHandler(
|
||||||
|
makeDeps({
|
||||||
|
listJellyfinSubtitleTracks: async () => [
|
||||||
|
{ index: 0, language: 'jpn', title: 'Japanese', deliveryUrl: 'https://sub/a.srt' },
|
||||||
|
],
|
||||||
|
getMpvClient: () => ({
|
||||||
|
requestProperty: async () => [
|
||||||
|
{
|
||||||
|
type: 'sub',
|
||||||
|
id: 5,
|
||||||
|
lang: 'jpn',
|
||||||
|
title: 'Japanese',
|
||||||
|
external: true,
|
||||||
|
'external-filename': '/tmp/subminer-jellyfin-subtitles/0.srt',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
sendMpvCommand: (command) => commands.push(command),
|
||||||
|
cacheSubtitleTrack: async (track) => ({
|
||||||
|
path: `/tmp/subminer-jellyfin-subtitles/${track.index}.srt`,
|
||||||
|
cleanupDir: '/tmp/subminer-jellyfin-subtitles',
|
||||||
|
}),
|
||||||
|
initSubtitlePrefetch: async () => {
|
||||||
|
throw new Error('parse failed');
|
||||||
|
},
|
||||||
|
logDebug: (message) => logs.push(message),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
await preload({ session, clientInfo, itemId: 'item-1' });
|
||||||
|
await new Promise((resolve) => setImmediate(resolve));
|
||||||
|
|
||||||
|
assert.deepEqual(logs, ['Failed to start subtitle prefetch for Jellyfin subtitle']);
|
||||||
|
assert.ok(
|
||||||
|
commands.some((command) => command[0] === 'set_property' && command[1] === 'sid'),
|
||||||
|
'subtitle selection still happens when prefetch start fails',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
test('preload jellyfin subtitles stages tracks without temporary subtitle selection', async () => {
|
test('preload jellyfin subtitles stages tracks without temporary subtitle selection', async () => {
|
||||||
const commands: Array<Array<string | number>> = [];
|
const commands: Array<Array<string | number>> = [];
|
||||||
const preload = createPreloadJellyfinExternalSubtitlesHandler(
|
const preload = createPreloadJellyfinExternalSubtitlesHandler(
|
||||||
|
|||||||
@@ -320,6 +320,7 @@ export function createPreloadJellyfinExternalSubtitlesHandler(deps: {
|
|||||||
setActiveSubtitleDelayKey?: (key: JellyfinSubtitleDelayKey | null) => void;
|
setActiveSubtitleDelayKey?: (key: JellyfinSubtitleDelayKey | null) => void;
|
||||||
loadSubtitleSourceText?: (source: string) => Promise<string>;
|
loadSubtitleSourceText?: (source: string) => Promise<string>;
|
||||||
saveSubtitleDelay?: (itemId: string, streamIndex: number, delaySeconds: number) => boolean | void;
|
saveSubtitleDelay?: (itemId: string, streamIndex: number, delaySeconds: number) => boolean | void;
|
||||||
|
initSubtitlePrefetch?: (sourcePath: string) => void | Promise<void>;
|
||||||
logDebug: (message: string, error: unknown) => void;
|
logDebug: (message: string, error: unknown) => void;
|
||||||
}): PreloadJellyfinExternalSubtitlesHandler {
|
}): PreloadJellyfinExternalSubtitlesHandler {
|
||||||
const activeCacheDirs = new Set<string>();
|
const activeCacheDirs = new Set<string>();
|
||||||
@@ -329,6 +330,18 @@ export function createPreloadJellyfinExternalSubtitlesHandler(deps: {
|
|||||||
deps.sendMpvCommand(['set_property', 'sub-delay', 0]);
|
deps.sendMpvCommand(['set_property', 'sub-delay', 0]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// mpv's sid property-change is the only thing that normally starts prefetching, so a
|
||||||
|
// coalesced or missed event leaves the whole episode uncached. The downloaded path is
|
||||||
|
// known here, so seed the pipeline directly instead of waiting on the observer.
|
||||||
|
function startSubtitlePrefetchForCachedTrack(sourcePath: string): void {
|
||||||
|
if (!deps.initSubtitlePrefetch) return;
|
||||||
|
void Promise.resolve()
|
||||||
|
.then(() => deps.initSubtitlePrefetch!(sourcePath))
|
||||||
|
.catch((error) => {
|
||||||
|
deps.logDebug('Failed to start subtitle prefetch for Jellyfin subtitle', error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function cleanupActiveCache(): void {
|
function cleanupActiveCache(): void {
|
||||||
const dirs = [...activeCacheDirs];
|
const dirs = [...activeCacheDirs];
|
||||||
if (dirs.length === 0) return;
|
if (dirs.length === 0) return;
|
||||||
@@ -438,6 +451,7 @@ export function createPreloadJellyfinExternalSubtitlesHandler(deps: {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
deps.sendMpvCommand(['set_property', 'sid', japanesePrimaryId]);
|
deps.sendMpvCommand(['set_property', 'sid', japanesePrimaryId]);
|
||||||
|
startSubtitlePrefetchForCachedTrack(selectedCachedTrack.path);
|
||||||
} else {
|
} else {
|
||||||
deps.setActiveSubtitleDelayKey?.(null);
|
deps.setActiveSubtitleDelayKey?.(null);
|
||||||
resetManagedSubtitleDelay();
|
resetManagedSubtitleDelay();
|
||||||
|
|||||||
@@ -336,8 +336,8 @@ test('tick only writes interaction state on change', () => {
|
|||||||
state.active = active;
|
state.active = active;
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
tickLinuxOverlayPointerInteraction(deps); // off→on
|
tickLinuxOverlayPointerInteraction(deps, 'linux'); // off→on
|
||||||
tickLinuxOverlayPointerInteraction(deps); // no change
|
tickLinuxOverlayPointerInteraction(deps, 'linux'); // no change
|
||||||
assert.deepEqual(calls, [true]);
|
assert.deepEqual(calls, [true]);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -352,7 +352,7 @@ test('tick reapplies an unchanged inactive state when the window passthrough sta
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
tickLinuxOverlayPointerInteraction(deps);
|
tickLinuxOverlayPointerInteraction(deps, 'linux');
|
||||||
assert.deepEqual(calls, [false]);
|
assert.deepEqual(calls, [false]);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -363,7 +363,7 @@ test('tick does not flip state when suspended (returns null)', () => {
|
|||||||
shouldSuspend: () => true,
|
shouldSuspend: () => true,
|
||||||
setInteractionActive: (active) => calls.push(active),
|
setInteractionActive: (active) => calls.push(active),
|
||||||
});
|
});
|
||||||
tickLinuxOverlayPointerInteraction(deps);
|
tickLinuxOverlayPointerInteraction(deps, 'linux');
|
||||||
assert.deepEqual(calls, []);
|
assert.deepEqual(calls, []);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -379,10 +379,26 @@ test('tick clears active hover while a separate SubMiner window suppresses overl
|
|||||||
});
|
});
|
||||||
|
|
||||||
state.active = true;
|
state.active = true;
|
||||||
tickLinuxOverlayPointerInteraction(deps);
|
tickLinuxOverlayPointerInteraction(deps, 'linux');
|
||||||
assert.deepEqual(calls, [false]);
|
assert.deepEqual(calls, [false]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('tick never clears interaction state on macOS/Windows, where renderer hover owns it', () => {
|
||||||
|
for (const platform of ['darwin', 'win32'] as const) {
|
||||||
|
const calls: boolean[] = [];
|
||||||
|
// Pointer is off the measured subtitle rect (e.g. sitting on a Yomitan popup) while the
|
||||||
|
// renderer has marked the overlay interactive.
|
||||||
|
const { deps } = makeDeps({
|
||||||
|
getCursorScreenPoint: () => ({ x: 200, y: 200 }),
|
||||||
|
getInteractionActive: () => true,
|
||||||
|
setInteractionActive: (active) => calls.push(active),
|
||||||
|
});
|
||||||
|
|
||||||
|
tickLinuxOverlayPointerInteraction(deps, platform);
|
||||||
|
assert.deepEqual(calls, [], `expected no interaction writes on ${platform}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
test('tick skips cursor-driven mouse-ignore toggles when Linux input shape owns hit rects', () => {
|
test('tick skips cursor-driven mouse-ignore toggles when Linux input shape owns hit rects', () => {
|
||||||
const calls: boolean[] = [];
|
const calls: boolean[] = [];
|
||||||
const { deps } = makeDeps({
|
const { deps } = makeDeps({
|
||||||
@@ -391,7 +407,7 @@ test('tick skips cursor-driven mouse-ignore toggles when Linux input shape owns
|
|||||||
setInteractionActive: (active) => calls.push(active),
|
setInteractionActive: (active) => calls.push(active),
|
||||||
});
|
});
|
||||||
|
|
||||||
tickLinuxOverlayPointerInteraction(deps);
|
tickLinuxOverlayPointerInteraction(deps, 'linux');
|
||||||
assert.deepEqual(calls, []);
|
assert.deepEqual(calls, []);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -270,7 +270,16 @@ export function resolveDesiredOverlayInteractive(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function tickLinuxOverlayPointerInteraction(deps: LinuxOverlayPointerInteractionDeps): void {
|
export function tickLinuxOverlayPointerInteraction(
|
||||||
|
deps: LinuxOverlayPointerInteractionDeps,
|
||||||
|
platform: NodeJS.Platform = process.platform,
|
||||||
|
): void {
|
||||||
|
// Linux-only. Windows/macOS drive interaction state from renderer hover (setIgnoreMouseEvents),
|
||||||
|
// which knows about Yomitan popups and modals that sit off the measured subtitle rects. This
|
||||||
|
// cursor poll only hit-tests those rects, so running it elsewhere would clear interaction state
|
||||||
|
// (and re-enable window passthrough) whenever a measurement lands while the pointer is on a
|
||||||
|
// popup, swallowing popup clicks and scroll.
|
||||||
|
if (platform !== 'linux') return;
|
||||||
if (deps.shouldUseInputShape?.()) return;
|
if (deps.shouldUseInputShape?.()) return;
|
||||||
const desired = resolveDesiredOverlayInteractive(deps);
|
const desired = resolveDesiredOverlayInteractive(deps);
|
||||||
if (desired === null) return;
|
if (desired === null) return;
|
||||||
|
|||||||
@@ -70,6 +70,24 @@ test('subtitle change handler broadcasts cached annotated payload immediately wh
|
|||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('subtitle change handler logs debug when a cached payload is emitted immediately', () => {
|
||||||
|
const debugs: string[] = [];
|
||||||
|
const handler = createHandleMpvSubtitleChangeHandler({
|
||||||
|
setCurrentSubText: () => {},
|
||||||
|
getImmediateSubtitlePayload: (text) => (text ? { text, tokens: [] } : null),
|
||||||
|
broadcastSubtitle: () => {},
|
||||||
|
onSubtitleChange: () => {},
|
||||||
|
refreshDiscordPresence: () => {},
|
||||||
|
logDebug: (message) => debugs.push(message),
|
||||||
|
});
|
||||||
|
|
||||||
|
handler({ text: 'キャッシュ済みの行' });
|
||||||
|
handler({ text: '' });
|
||||||
|
|
||||||
|
assert.equal(debugs.length, 1);
|
||||||
|
assert.match(debugs[0]!, /cached subtitle/);
|
||||||
|
});
|
||||||
|
|
||||||
test('subtitle change handler emits cached annotation after forwarding the subtitle change', () => {
|
test('subtitle change handler emits cached annotation after forwarding the subtitle change', () => {
|
||||||
const calls: string[] = [];
|
const calls: string[] = [];
|
||||||
const handler = createHandleMpvSubtitleChangeHandler({
|
const handler = createHandleMpvSubtitleChangeHandler({
|
||||||
|
|||||||
@@ -20,11 +20,15 @@ export function createHandleMpvSubtitleChangeHandler(deps: {
|
|||||||
broadcastSubtitle: (payload: SubtitleData) => void;
|
broadcastSubtitle: (payload: SubtitleData) => void;
|
||||||
onSubtitleChange: (text: string) => void;
|
onSubtitleChange: (text: string) => void;
|
||||||
refreshDiscordPresence: () => void;
|
refreshDiscordPresence: () => void;
|
||||||
|
logDebug?: (message: string) => void;
|
||||||
}) {
|
}) {
|
||||||
return ({ text }: { text: string }): void => {
|
return ({ text }: { text: string }): void => {
|
||||||
deps.setCurrentSubText(text);
|
deps.setCurrentSubText(text);
|
||||||
const immediatePayload = deps.getImmediateSubtitlePayload?.(text) ?? null;
|
const immediatePayload = deps.getImmediateSubtitlePayload?.(text) ?? null;
|
||||||
if (immediatePayload) {
|
if (immediatePayload) {
|
||||||
|
deps.logDebug?.(
|
||||||
|
`[subtitle-processing] emitted cached subtitle immediately (${text.length} chars)`,
|
||||||
|
);
|
||||||
deps.onSubtitleChange(text);
|
deps.onSubtitleChange(text);
|
||||||
(deps.emitImmediateSubtitle ?? deps.broadcastSubtitle)(immediatePayload);
|
(deps.emitImmediateSubtitle ?? deps.broadcastSubtitle)(immediatePayload);
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -47,6 +47,7 @@ export function createBindMpvMainEventHandlersHandler(deps: {
|
|||||||
emitImmediateSubtitle?: (payload: SubtitleData) => void;
|
emitImmediateSubtitle?: (payload: SubtitleData) => void;
|
||||||
broadcastSubtitle: (payload: SubtitleData) => void;
|
broadcastSubtitle: (payload: SubtitleData) => void;
|
||||||
onSubtitleChange: (text: string) => void;
|
onSubtitleChange: (text: string) => void;
|
||||||
|
logSubtitleProcessingDebug?: (message: string) => void;
|
||||||
refreshDiscordPresence: () => void;
|
refreshDiscordPresence: () => void;
|
||||||
|
|
||||||
setCurrentSubAssText: (text: string) => void;
|
setCurrentSubAssText: (text: string) => void;
|
||||||
@@ -123,6 +124,9 @@ export function createBindMpvMainEventHandlersHandler(deps: {
|
|||||||
: undefined,
|
: undefined,
|
||||||
broadcastSubtitle: (payload) => deps.broadcastSubtitle(payload),
|
broadcastSubtitle: (payload) => deps.broadcastSubtitle(payload),
|
||||||
onSubtitleChange: (text) => deps.onSubtitleChange(text),
|
onSubtitleChange: (text) => deps.onSubtitleChange(text),
|
||||||
|
logDebug: deps.logSubtitleProcessingDebug
|
||||||
|
? (message) => deps.logSubtitleProcessingDebug?.(message)
|
||||||
|
: undefined,
|
||||||
refreshDiscordPresence: () => deps.refreshDiscordPresence(),
|
refreshDiscordPresence: () => deps.refreshDiscordPresence(),
|
||||||
});
|
});
|
||||||
const handleMpvSubtitleAssChange = createHandleMpvSubtitleAssChangeHandler({
|
const handleMpvSubtitleAssChange = createHandleMpvSubtitleAssChangeHandler({
|
||||||
|
|||||||
@@ -54,6 +54,7 @@ export function createBuildBindMpvMainEventHandlersMainDepsHandler(deps: {
|
|||||||
getImmediateSubtitlePayload?: (text: string) => SubtitleData | null;
|
getImmediateSubtitlePayload?: (text: string) => SubtitleData | null;
|
||||||
emitImmediateSubtitle?: (payload: SubtitleData) => void;
|
emitImmediateSubtitle?: (payload: SubtitleData) => void;
|
||||||
onSubtitleChange: (text: string) => void;
|
onSubtitleChange: (text: string) => void;
|
||||||
|
logSubtitleProcessingDebug?: (message: string) => void;
|
||||||
onSubtitleTrackChange?: (sid: number | null) => void;
|
onSubtitleTrackChange?: (sid: number | null) => void;
|
||||||
onSubtitleTrackListChange?: (trackList: unknown[] | null) => void;
|
onSubtitleTrackListChange?: (trackList: unknown[] | null) => void;
|
||||||
updateCurrentMediaPath: (path: string) => void;
|
updateCurrentMediaPath: (path: string) => void;
|
||||||
@@ -155,6 +156,9 @@ export function createBuildBindMpvMainEventHandlersMainDepsHandler(deps: {
|
|||||||
broadcastSubtitle: (payload: SubtitleData) =>
|
broadcastSubtitle: (payload: SubtitleData) =>
|
||||||
deps.broadcastToOverlayWindows('subtitle:set', payload),
|
deps.broadcastToOverlayWindows('subtitle:set', payload),
|
||||||
onSubtitleChange: (text: string) => deps.onSubtitleChange(text),
|
onSubtitleChange: (text: string) => deps.onSubtitleChange(text),
|
||||||
|
logSubtitleProcessingDebug: deps.logSubtitleProcessingDebug
|
||||||
|
? (message: string) => deps.logSubtitleProcessingDebug!(message)
|
||||||
|
: undefined,
|
||||||
onSubtitleTrackChange: deps.onSubtitleTrackChange
|
onSubtitleTrackChange: deps.onSubtitleTrackChange
|
||||||
? (sid: number | null) => deps.onSubtitleTrackChange!(sid)
|
? (sid: number | null) => deps.onSubtitleTrackChange!(sid)
|
||||||
: undefined,
|
: undefined,
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import { withConfiguredOverlayNotificationPosition } from './overlay-notificatio
|
|||||||
import { createOverlayNotificationDelivery } from './overlay-notification-delivery';
|
import { createOverlayNotificationDelivery } from './overlay-notification-delivery';
|
||||||
import {
|
import {
|
||||||
getPlaybackFeedbackNotificationOptions,
|
getPlaybackFeedbackNotificationOptions,
|
||||||
|
getSubsyncStatusNotificationOptions,
|
||||||
getYoutubeFlowStatusNotificationOptions,
|
getYoutubeFlowStatusNotificationOptions,
|
||||||
notifyConfiguredStatus,
|
notifyConfiguredStatus,
|
||||||
type ConfiguredStatusNotificationOptions,
|
type ConfiguredStatusNotificationOptions,
|
||||||
@@ -195,15 +196,7 @@ export function createOverlayNotificationsRuntime(deps: OverlayNotificationsRunt
|
|||||||
}
|
}
|
||||||
|
|
||||||
function showSubsyncStatusNotification(message: string): void {
|
function showSubsyncStatusNotification(message: string): void {
|
||||||
const syncing = message.startsWith('Subsync: syncing');
|
showConfiguredStatusNotification(message, getSubsyncStatusNotificationOptions(message));
|
||||||
const failed = message.toLowerCase().includes('failed');
|
|
||||||
showConfiguredStatusNotification(message, {
|
|
||||||
id: 'subsync-status',
|
|
||||||
title: 'Subsync',
|
|
||||||
variant: failed ? 'error' : syncing ? 'progress' : 'info',
|
|
||||||
persistent: syncing,
|
|
||||||
desktop: !syncing,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function showYoutubeFlowStatusNotification(message: string): void {
|
function showYoutubeFlowStatusNotification(message: string): void {
|
||||||
|
|||||||
@@ -5,7 +5,13 @@ import type { SubsyncManualPayload } from '../../types';
|
|||||||
|
|
||||||
const payload: SubsyncManualPayload = {
|
const payload: SubsyncManualPayload = {
|
||||||
ffsubsyncAvailable: true,
|
ffsubsyncAvailable: true,
|
||||||
sourceTracks: [{ id: 2, label: 'External #2 - eng' }],
|
videoReferenceAvailable: true,
|
||||||
|
subtitleTracks: [
|
||||||
|
{ id: 1, label: 'Internal #1 - jpn (active)' },
|
||||||
|
{ id: 2, label: 'External #2 - eng' },
|
||||||
|
],
|
||||||
|
defaultReferenceTrackId: 2,
|
||||||
|
defaultTargetTrackId: 1,
|
||||||
};
|
};
|
||||||
|
|
||||||
test('subsync manual open prefers dedicated modal window on first attempt', async () => {
|
test('subsync manual open prefers dedicated modal window on first attempt', async () => {
|
||||||
|
|||||||
@@ -54,7 +54,6 @@ test('latest subtitle prefetch init wins over stale async loads', async () => {
|
|||||||
}),
|
}),
|
||||||
tokenizeSubtitle: async () => null,
|
tokenizeSubtitle: async () => null,
|
||||||
preCacheTokenization: () => {},
|
preCacheTokenization: () => {},
|
||||||
isCacheFull: () => false,
|
|
||||||
logInfo: () => {},
|
logInfo: () => {},
|
||||||
logWarn: () => {},
|
logWarn: () => {},
|
||||||
});
|
});
|
||||||
@@ -99,7 +98,6 @@ test('cancelPendingInit prevents an in-flight load from attaching a stale servic
|
|||||||
}),
|
}),
|
||||||
tokenizeSubtitle: async () => null,
|
tokenizeSubtitle: async () => null,
|
||||||
preCacheTokenization: () => {},
|
preCacheTokenization: () => {},
|
||||||
isCacheFull: () => false,
|
|
||||||
logInfo: () => {},
|
logInfo: () => {},
|
||||||
logWarn: () => {},
|
logWarn: () => {},
|
||||||
});
|
});
|
||||||
@@ -137,7 +135,6 @@ test('subtitle prefetch init publishes parsed cues and clears them on cancel', a
|
|||||||
}),
|
}),
|
||||||
tokenizeSubtitle: async () => null,
|
tokenizeSubtitle: async () => null,
|
||||||
preCacheTokenization: () => {},
|
preCacheTokenization: () => {},
|
||||||
isCacheFull: () => false,
|
|
||||||
logInfo: () => {},
|
logInfo: () => {},
|
||||||
logWarn: () => {},
|
logWarn: () => {},
|
||||||
onParsedSubtitleCuesChanged: (cues) => {
|
onParsedSubtitleCuesChanged: (cues) => {
|
||||||
@@ -181,7 +178,6 @@ test('subtitle prefetch init publishes the provided stable source key instead of
|
|||||||
}),
|
}),
|
||||||
tokenizeSubtitle: async () => null,
|
tokenizeSubtitle: async () => null,
|
||||||
preCacheTokenization: () => {},
|
preCacheTokenization: () => {},
|
||||||
isCacheFull: () => false,
|
|
||||||
logInfo: () => {},
|
logInfo: () => {},
|
||||||
logWarn: () => {},
|
logWarn: () => {},
|
||||||
onParsedSubtitleCuesChanged: (_cues, source) => {
|
onParsedSubtitleCuesChanged: (_cues, source) => {
|
||||||
@@ -222,7 +218,6 @@ test('subtitle prefetch init clears parsed cues when initialization fails', asyn
|
|||||||
}),
|
}),
|
||||||
tokenizeSubtitle: async () => null,
|
tokenizeSubtitle: async () => null,
|
||||||
preCacheTokenization: () => {},
|
preCacheTokenization: () => {},
|
||||||
isCacheFull: () => false,
|
|
||||||
logInfo: () => {},
|
logInfo: () => {},
|
||||||
logWarn: () => {},
|
logWarn: () => {},
|
||||||
onParsedSubtitleCuesChanged: (cues) => {
|
onParsedSubtitleCuesChanged: (cues) => {
|
||||||
@@ -234,3 +229,25 @@ test('subtitle prefetch init clears parsed cues when initialization fails', asyn
|
|||||||
|
|
||||||
assert.deepEqual(cueUpdates, [null]);
|
assert.deepEqual(cueUpdates, [null]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('subtitle prefetch init logs a warning when the source parses to zero cues', async () => {
|
||||||
|
const warnings: string[] = [];
|
||||||
|
const controller = createSubtitlePrefetchInitController({
|
||||||
|
getCurrentService: () => null,
|
||||||
|
setCurrentService: () => {},
|
||||||
|
loadSubtitleSourceText: async () => 'not really subtitles',
|
||||||
|
parseSubtitleCues: (): SubtitleCue[] => [],
|
||||||
|
createSubtitlePrefetchService: () => {
|
||||||
|
throw new Error('should not create a service without cues');
|
||||||
|
},
|
||||||
|
tokenizeSubtitle: async () => null,
|
||||||
|
preCacheTokenization: () => {},
|
||||||
|
logInfo: () => {},
|
||||||
|
logWarn: (message) => warnings.push(message),
|
||||||
|
});
|
||||||
|
|
||||||
|
await controller.initSubtitlePrefetch('/tmp/broken.ass', 0);
|
||||||
|
|
||||||
|
assert.equal(warnings.length, 1);
|
||||||
|
assert.match(warnings[0]!, /\[subtitle-prefetch\].*0 cues.*\/tmp\/broken\.ass/);
|
||||||
|
});
|
||||||
|
|||||||
@@ -14,7 +14,6 @@ export interface SubtitlePrefetchInitControllerDeps {
|
|||||||
tokenizeSubtitle: (text: string) => Promise<SubtitleData | null>;
|
tokenizeSubtitle: (text: string) => Promise<SubtitleData | null>;
|
||||||
preCacheTokenization: (text: string, data: SubtitleData) => void;
|
preCacheTokenization: (text: string, data: SubtitleData) => void;
|
||||||
hasCachedTokenization?: (text: string) => boolean;
|
hasCachedTokenization?: (text: string) => boolean;
|
||||||
isCacheFull: () => boolean;
|
|
||||||
logInfo: (message: string) => void;
|
logInfo: (message: string) => void;
|
||||||
logWarn: (message: string) => void;
|
logWarn: (message: string) => void;
|
||||||
onParsedSubtitleCuesChanged?: (cues: SubtitleCue[] | null, sourceKey: string | null) => void;
|
onParsedSubtitleCuesChanged?: (cues: SubtitleCue[] | null, sourceKey: string | null) => void;
|
||||||
@@ -59,6 +58,9 @@ export function createSubtitlePrefetchInitController(
|
|||||||
const cues = deps.parseSubtitleCues(content, sourcePath);
|
const cues = deps.parseSubtitleCues(content, sourcePath);
|
||||||
if (revision !== initRevision || cues.length === 0) {
|
if (revision !== initRevision || cues.length === 0) {
|
||||||
if (revision === initRevision) {
|
if (revision === initRevision) {
|
||||||
|
deps.logWarn(
|
||||||
|
`[subtitle-prefetch] parsed 0 cues from ${sourcePath}; prefetch disabled for this source`,
|
||||||
|
);
|
||||||
deps.onParsedSubtitleCuesChanged?.(null, null);
|
deps.onParsedSubtitleCuesChanged?.(null, null);
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
@@ -69,7 +71,6 @@ export function createSubtitlePrefetchInitController(
|
|||||||
tokenizeSubtitle: (text) => deps.tokenizeSubtitle(text),
|
tokenizeSubtitle: (text) => deps.tokenizeSubtitle(text),
|
||||||
preCacheTokenization: (text, data) => deps.preCacheTokenization(text, data),
|
preCacheTokenization: (text, data) => deps.preCacheTokenization(text, data),
|
||||||
hasCachedTokenization: (text) => deps.hasCachedTokenization?.(text) ?? false,
|
hasCachedTokenization: (text) => deps.hasCachedTokenization?.(text) ?? false,
|
||||||
isCacheFull: () => deps.isCacheFull(),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (revision !== initRevision) {
|
if (revision !== initRevision) {
|
||||||
|
|||||||
@@ -130,3 +130,121 @@ test('subtitle prefetch runtime does not extract internal subtitle tracks from r
|
|||||||
assert.equal(resolved, null);
|
assert.equal(resolved, null);
|
||||||
assert.equal(extracted, false);
|
assert.equal(extracted, false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('subtitle prefetch refresh logs a warning when source resolution throws', async () => {
|
||||||
|
const warnings: string[] = [];
|
||||||
|
const refresh = createRefreshSubtitlePrefetchFromActiveTrackHandler({
|
||||||
|
getMpvClient: () => ({
|
||||||
|
connected: true,
|
||||||
|
requestProperty: async (name) => (name === 'path' ? '/media/video.mkv' : null),
|
||||||
|
}),
|
||||||
|
getLastObservedTimePos: () => 0,
|
||||||
|
subtitlePrefetchInitController: {
|
||||||
|
cancelPendingInit: () => {},
|
||||||
|
initSubtitlePrefetch: async () => {},
|
||||||
|
},
|
||||||
|
resolveActiveSubtitleSidebarSource: async () => {
|
||||||
|
throw new Error('ffmpeg ENOENT');
|
||||||
|
},
|
||||||
|
logWarn: (message) => warnings.push(message),
|
||||||
|
});
|
||||||
|
|
||||||
|
await refresh();
|
||||||
|
|
||||||
|
assert.equal(warnings.length, 1);
|
||||||
|
assert.match(warnings[0]!, /\[subtitle-prefetch\].*ffmpeg ENOENT/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('subtitle prefetch refresh logs debug when mpv client is not connected', async () => {
|
||||||
|
const debugs: string[] = [];
|
||||||
|
const refresh = createRefreshSubtitlePrefetchFromActiveTrackHandler({
|
||||||
|
getMpvClient: () => null,
|
||||||
|
getLastObservedTimePos: () => 0,
|
||||||
|
subtitlePrefetchInitController: {
|
||||||
|
cancelPendingInit: () => {},
|
||||||
|
initSubtitlePrefetch: async () => {},
|
||||||
|
},
|
||||||
|
resolveActiveSubtitleSidebarSource: async () => null,
|
||||||
|
logDebug: (message) => debugs.push(message),
|
||||||
|
});
|
||||||
|
|
||||||
|
await refresh();
|
||||||
|
|
||||||
|
assert.equal(debugs.length, 1);
|
||||||
|
assert.match(debugs[0]!, /\[subtitle-prefetch\].*not connected/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('subtitle prefetch refresh logs debug when no subtitle source resolves', async () => {
|
||||||
|
const debugs: string[] = [];
|
||||||
|
const cancels: number[] = [];
|
||||||
|
const refresh = createRefreshSubtitlePrefetchFromActiveTrackHandler({
|
||||||
|
getMpvClient: () => ({
|
||||||
|
connected: true,
|
||||||
|
requestProperty: async (name) => (name === 'path' ? '/media/video.mkv' : null),
|
||||||
|
}),
|
||||||
|
getLastObservedTimePos: () => 0,
|
||||||
|
subtitlePrefetchInitController: {
|
||||||
|
cancelPendingInit: () => {
|
||||||
|
cancels.push(1);
|
||||||
|
},
|
||||||
|
initSubtitlePrefetch: async () => {},
|
||||||
|
},
|
||||||
|
resolveActiveSubtitleSidebarSource: async () => null,
|
||||||
|
logDebug: (message) => debugs.push(message),
|
||||||
|
});
|
||||||
|
|
||||||
|
await refresh();
|
||||||
|
|
||||||
|
assert.deepEqual(cancels, [1]);
|
||||||
|
assert.equal(debugs.length, 1);
|
||||||
|
assert.match(debugs[0]!, /\[subtitle-prefetch\].*no active subtitle source/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('subtitle source resolver logs debug when internal track extraction is unavailable', async () => {
|
||||||
|
const debugs: string[] = [];
|
||||||
|
const resolveSource = createResolveActiveSubtitleSidebarSourceHandler({
|
||||||
|
getFfmpegPath: () => 'ffmpeg',
|
||||||
|
extractInternalSubtitleTrack: async () => null,
|
||||||
|
logDebug: (message) => debugs.push(message),
|
||||||
|
});
|
||||||
|
|
||||||
|
const resolved = await resolveSource({
|
||||||
|
currentExternalFilenameRaw: null,
|
||||||
|
currentTrackRaw: {
|
||||||
|
type: 'sub',
|
||||||
|
id: 3,
|
||||||
|
'ff-index': 7,
|
||||||
|
codec: 'hdmv_pgs_subtitle',
|
||||||
|
},
|
||||||
|
trackListRaw: [],
|
||||||
|
sidRaw: 3,
|
||||||
|
videoPath: '/media/video.mkv',
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(resolved, null);
|
||||||
|
assert.equal(debugs.length, 1);
|
||||||
|
assert.match(debugs[0]!, /\[subtitle-prefetch\].*extraction.*hdmv_pgs_subtitle/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('subtitle source resolver logs debug when no active subtitle track is selected', async () => {
|
||||||
|
const debugs: string[] = [];
|
||||||
|
const resolveSource = createResolveActiveSubtitleSidebarSourceHandler({
|
||||||
|
getFfmpegPath: () => 'ffmpeg',
|
||||||
|
extractInternalSubtitleTrack: async () => {
|
||||||
|
throw new Error('should not extract without a track');
|
||||||
|
},
|
||||||
|
logDebug: (message) => debugs.push(message),
|
||||||
|
});
|
||||||
|
|
||||||
|
const resolved = await resolveSource({
|
||||||
|
currentExternalFilenameRaw: null,
|
||||||
|
currentTrackRaw: null,
|
||||||
|
trackListRaw: [],
|
||||||
|
sidRaw: null,
|
||||||
|
videoPath: '/media/video.mkv',
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(resolved, null);
|
||||||
|
assert.equal(debugs.length, 1);
|
||||||
|
assert.match(debugs[0]!, /\[subtitle-prefetch\].*no active subtitle track/);
|
||||||
|
});
|
||||||
|
|||||||
@@ -86,6 +86,7 @@ export function createResolveActiveSubtitleSidebarSourceHandler(deps: {
|
|||||||
videoPath: string,
|
videoPath: string,
|
||||||
track: MpvSubtitleTrackLike,
|
track: MpvSubtitleTrackLike,
|
||||||
) => Promise<{ path: string; cleanup: () => Promise<void> } | null>;
|
) => Promise<{ path: string; cleanup: () => Promise<void> } | null>;
|
||||||
|
logDebug?: (message: string) => void;
|
||||||
}) {
|
}) {
|
||||||
return async (input: {
|
return async (input: {
|
||||||
currentExternalFilenameRaw: unknown;
|
currentExternalFilenameRaw: unknown;
|
||||||
@@ -104,6 +105,7 @@ export function createResolveActiveSubtitleSidebarSourceHandler(deps: {
|
|||||||
|
|
||||||
const track = getActiveSubtitleTrack(input.currentTrackRaw, input.trackListRaw, input.sidRaw);
|
const track = getActiveSubtitleTrack(input.currentTrackRaw, input.trackListRaw, input.sidRaw);
|
||||||
if (!track) {
|
if (!track) {
|
||||||
|
deps.logDebug?.('[subtitle-prefetch] no active subtitle track selected yet');
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -114,6 +116,7 @@ export function createResolveActiveSubtitleSidebarSourceHandler(deps: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (isRemoteMediaPath(input.videoPath)) {
|
if (isRemoteMediaPath(input.videoPath)) {
|
||||||
|
deps.logDebug?.('[subtitle-prefetch] skipping internal subtitle extraction for remote media');
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -123,6 +126,9 @@ export function createResolveActiveSubtitleSidebarSourceHandler(deps: {
|
|||||||
track,
|
track,
|
||||||
);
|
);
|
||||||
if (!extracted) {
|
if (!extracted) {
|
||||||
|
deps.logDebug?.(
|
||||||
|
`[subtitle-prefetch] internal subtitle extraction unavailable (codec=${String(track.codec ?? 'unknown')}, ff-index=${String(track['ff-index'] ?? 'unknown')})`,
|
||||||
|
);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -144,10 +150,13 @@ export function createRefreshSubtitlePrefetchFromActiveTrackHandler(deps: {
|
|||||||
resolveActiveSubtitleSidebarSource: (
|
resolveActiveSubtitleSidebarSource: (
|
||||||
input: Parameters<ReturnType<typeof createResolveActiveSubtitleSidebarSourceHandler>>[0],
|
input: Parameters<ReturnType<typeof createResolveActiveSubtitleSidebarSourceHandler>>[0],
|
||||||
) => Promise<ActiveSubtitleSidebarSource | null>;
|
) => Promise<ActiveSubtitleSidebarSource | null>;
|
||||||
|
logDebug?: (message: string) => void;
|
||||||
|
logWarn?: (message: string) => void;
|
||||||
}) {
|
}) {
|
||||||
return async (): Promise<void> => {
|
return async (): Promise<void> => {
|
||||||
const client = deps.getMpvClient();
|
const client = deps.getMpvClient();
|
||||||
if (!client?.connected) {
|
if (!client?.connected) {
|
||||||
|
deps.logDebug?.('[subtitle-prefetch] skipped refresh: mpv client not connected');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -162,6 +171,7 @@ export function createRefreshSubtitlePrefetchFromActiveTrackHandler(deps: {
|
|||||||
]);
|
]);
|
||||||
const videoPath = typeof videoPathRaw === 'string' ? videoPathRaw : '';
|
const videoPath = typeof videoPathRaw === 'string' ? videoPathRaw : '';
|
||||||
if (!videoPath) {
|
if (!videoPath) {
|
||||||
|
deps.logDebug?.('[subtitle-prefetch] skipped refresh: no media path');
|
||||||
deps.subtitlePrefetchInitController.cancelPendingInit();
|
deps.subtitlePrefetchInitController.cancelPendingInit();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -175,8 +185,14 @@ export function createRefreshSubtitlePrefetchFromActiveTrackHandler(deps: {
|
|||||||
});
|
});
|
||||||
if (!resolvedSource) {
|
if (!resolvedSource) {
|
||||||
if (deps.shouldKeepExistingCuesOnMissingSource?.(videoPath) === true) {
|
if (deps.shouldKeepExistingCuesOnMissingSource?.(videoPath) === true) {
|
||||||
|
deps.logDebug?.(
|
||||||
|
'[subtitle-prefetch] no active subtitle source resolved; keeping existing cues',
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
deps.logDebug?.(
|
||||||
|
'[subtitle-prefetch] no active subtitle source resolved; cancelling prefetch',
|
||||||
|
);
|
||||||
deps.subtitlePrefetchInitController.cancelPendingInit();
|
deps.subtitlePrefetchInitController.cancelPendingInit();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -190,8 +206,12 @@ export function createRefreshSubtitlePrefetchFromActiveTrackHandler(deps: {
|
|||||||
} finally {
|
} finally {
|
||||||
await resolvedSource.cleanup?.();
|
await resolvedSource.cleanup?.();
|
||||||
}
|
}
|
||||||
} catch {
|
} catch (error) {
|
||||||
// Skip refresh when the track query fails.
|
deps.logWarn?.(
|
||||||
|
`[subtitle-prefetch] failed to refresh from active track: ${
|
||||||
|
error instanceof Error ? error.message : String(error)
|
||||||
|
}`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -827,6 +827,9 @@ export function createVisibleOverlayInteractionRuntime(deps: VisibleOverlayInter
|
|||||||
};
|
};
|
||||||
|
|
||||||
function tickLinuxOverlayPointerInteractionNow(): void {
|
function tickLinuxOverlayPointerInteractionNow(): void {
|
||||||
|
if (process.platform !== 'linux') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (applyLinuxOverlayInputShapeFromLatestMeasurement()) {
|
if (applyLinuxOverlayInputShapeFromLatestMeasurement()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -359,9 +359,13 @@
|
|||||||
ffsubsync
|
ffsubsync
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<label id="subsyncSourceLabel" class="subsync-field">
|
<label id="subsyncReferenceLabel" class="subsync-field">
|
||||||
<span>Source Subtitle (for alass)</span>
|
<span>Reference (correct timing, for alass)</span>
|
||||||
<select id="subsyncSourceSelect"></select>
|
<select id="subsyncReferenceSelect"></select>
|
||||||
|
</label>
|
||||||
|
<label id="subsyncTargetLabel" class="subsync-field">
|
||||||
|
<span>Out-of-sync Subtitle (gets retimed)</span>
|
||||||
|
<select id="subsyncTargetSelect"></select>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<div id="subsyncStatus" class="runtime-options-status"></div>
|
<div id="subsyncStatus" class="runtime-options-status"></div>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import test from 'node:test';
|
|||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
|
|
||||||
import { createSubsyncModal } from './subsync.js';
|
import { createSubsyncModal } from './subsync.js';
|
||||||
|
import type { SubsyncManualPayload, SubsyncManualRunRequest } from '../../types';
|
||||||
|
|
||||||
type Listener = () => void;
|
type Listener = () => void;
|
||||||
|
|
||||||
@@ -52,18 +53,54 @@ function createDeferred<T>() {
|
|||||||
return { promise, resolve };
|
return { promise, resolve };
|
||||||
}
|
}
|
||||||
|
|
||||||
function createTestHarness(runSubsyncManual: () => Promise<{ ok: boolean; message: string }>) {
|
function createSelectStub() {
|
||||||
|
const options: Array<{ value: string; textContent: string }> = [];
|
||||||
|
const events = createEventTarget();
|
||||||
|
let innerHTML = '';
|
||||||
|
let value = '';
|
||||||
|
|
||||||
|
return {
|
||||||
|
options,
|
||||||
|
disabled: false,
|
||||||
|
addEventListener: events.addEventListener,
|
||||||
|
dispatch: events.dispatch,
|
||||||
|
get innerHTML(): string {
|
||||||
|
return innerHTML;
|
||||||
|
},
|
||||||
|
set innerHTML(next: string) {
|
||||||
|
innerHTML = next;
|
||||||
|
if (next === '') {
|
||||||
|
options.length = 0;
|
||||||
|
value = '';
|
||||||
|
}
|
||||||
|
},
|
||||||
|
get value(): string {
|
||||||
|
return value;
|
||||||
|
},
|
||||||
|
set value(next: string) {
|
||||||
|
value = next;
|
||||||
|
},
|
||||||
|
appendChild(option: { value: string; textContent: string }) {
|
||||||
|
options.push(option);
|
||||||
|
if (!value) value = option.value;
|
||||||
|
return option;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createTestHarness(
|
||||||
|
runSubsyncManual: (request: SubsyncManualRunRequest) => Promise<{ ok: boolean; message: string }>,
|
||||||
|
) {
|
||||||
const overlayClassList = createClassList();
|
const overlayClassList = createClassList();
|
||||||
const modalClassList = createClassList();
|
const modalClassList = createClassList();
|
||||||
const statusClassList = createClassList();
|
const statusClassList = createClassList();
|
||||||
const sourceLabelClassList = createClassList();
|
const referenceLabelClassList = createClassList();
|
||||||
|
const targetLabelClassList = createClassList();
|
||||||
const runButtonEvents = createEventTarget();
|
const runButtonEvents = createEventTarget();
|
||||||
const closeButtonEvents = createEventTarget();
|
const closeButtonEvents = createEventTarget();
|
||||||
const engineAlassEvents = createEventTarget();
|
const engineAlassEvents = createEventTarget();
|
||||||
const engineFfsubsyncEvents = createEventTarget();
|
const engineFfsubsyncEvents = createEventTarget();
|
||||||
|
|
||||||
const sourceOptions: Array<{ value: string; textContent: string }> = [];
|
|
||||||
|
|
||||||
const runButton = {
|
const runButton = {
|
||||||
disabled: false,
|
disabled: false,
|
||||||
addEventListener: runButtonEvents.addEventListener,
|
addEventListener: runButtonEvents.addEventListener,
|
||||||
@@ -77,6 +114,7 @@ function createTestHarness(runSubsyncManual: () => Promise<{ ok: boolean; messag
|
|||||||
|
|
||||||
const subsyncEngineAlass = {
|
const subsyncEngineAlass = {
|
||||||
checked: false,
|
checked: false,
|
||||||
|
disabled: false,
|
||||||
addEventListener: engineAlassEvents.addEventListener,
|
addEventListener: engineAlassEvents.addEventListener,
|
||||||
dispatch: engineAlassEvents.dispatch,
|
dispatch: engineAlassEvents.dispatch,
|
||||||
};
|
};
|
||||||
@@ -88,18 +126,8 @@ function createTestHarness(runSubsyncManual: () => Promise<{ ok: boolean; messag
|
|||||||
dispatch: engineFfsubsyncEvents.dispatch,
|
dispatch: engineFfsubsyncEvents.dispatch,
|
||||||
};
|
};
|
||||||
|
|
||||||
const sourceSelect = {
|
const referenceSelect = createSelectStub();
|
||||||
innerHTML: '',
|
const targetSelect = createSelectStub();
|
||||||
value: '',
|
|
||||||
disabled: false,
|
|
||||||
appendChild: (option: { value: string; textContent: string }) => {
|
|
||||||
sourceOptions.push(option);
|
|
||||||
if (!sourceSelect.value) {
|
|
||||||
sourceSelect.value = option.value;
|
|
||||||
}
|
|
||||||
return option;
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
let notifyClosedCalls = 0;
|
let notifyClosedCalls = 0;
|
||||||
let notifyOpenedCalls = 0;
|
let notifyOpenedCalls = 0;
|
||||||
@@ -139,8 +167,10 @@ function createTestHarness(runSubsyncManual: () => Promise<{ ok: boolean; messag
|
|||||||
subsyncCloseButton: closeButton,
|
subsyncCloseButton: closeButton,
|
||||||
subsyncEngineAlass,
|
subsyncEngineAlass,
|
||||||
subsyncEngineFfsubsync,
|
subsyncEngineFfsubsync,
|
||||||
subsyncSourceLabel: { classList: sourceLabelClassList },
|
subsyncReferenceLabel: { classList: referenceLabelClassList },
|
||||||
subsyncSourceSelect: sourceSelect,
|
subsyncReferenceSelect: referenceSelect,
|
||||||
|
subsyncTargetLabel: { classList: targetLabelClassList },
|
||||||
|
subsyncTargetSelect: targetSelect,
|
||||||
subsyncRunButton: runButton,
|
subsyncRunButton: runButton,
|
||||||
subsyncStatus: {
|
subsyncStatus: {
|
||||||
textContent: '',
|
textContent: '',
|
||||||
@@ -149,7 +179,7 @@ function createTestHarness(runSubsyncManual: () => Promise<{ ok: boolean; messag
|
|||||||
},
|
},
|
||||||
state: {
|
state: {
|
||||||
subsyncModalOpen: false,
|
subsyncModalOpen: false,
|
||||||
subsyncSourceTracks: [],
|
subsyncSubtitleTracks: [],
|
||||||
subsyncSubmitting: false,
|
subsyncSubmitting: false,
|
||||||
isOverSubtitle: false,
|
isOverSubtitle: false,
|
||||||
},
|
},
|
||||||
@@ -166,6 +196,9 @@ function createTestHarness(runSubsyncManual: () => Promise<{ ok: boolean; messag
|
|||||||
ctx,
|
ctx,
|
||||||
modal,
|
modal,
|
||||||
runButton,
|
runButton,
|
||||||
|
referenceSelect,
|
||||||
|
targetSelect,
|
||||||
|
referenceLabelClassList,
|
||||||
statusClassList,
|
statusClassList,
|
||||||
getNotifyClosedCalls: () => notifyClosedCalls,
|
getNotifyClosedCalls: () => notifyClosedCalls,
|
||||||
getNotifyOpenedCalls: () => notifyOpenedCalls,
|
getNotifyOpenedCalls: () => notifyOpenedCalls,
|
||||||
@@ -187,16 +220,28 @@ async function flushMicrotasks(): Promise<void> {
|
|||||||
await Promise.resolve();
|
await Promise.resolve();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const BASE_PAYLOAD: SubsyncManualPayload = {
|
||||||
|
subtitleTracks: [
|
||||||
|
{ id: 1, label: 'External #1 - jpn (active)' },
|
||||||
|
{ id: 2, label: 'External #2 - eng' },
|
||||||
|
],
|
||||||
|
defaultReferenceTrackId: 2,
|
||||||
|
defaultTargetTrackId: 1,
|
||||||
|
videoReferenceAvailable: true,
|
||||||
|
ffsubsyncAvailable: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
function payloadWith(overrides: Partial<SubsyncManualPayload>): SubsyncManualPayload {
|
||||||
|
return { ...BASE_PAYLOAD, ...overrides };
|
||||||
|
}
|
||||||
|
|
||||||
test('manual subsync failure closes during run, then reopens modal with error', async () => {
|
test('manual subsync failure closes during run, then reopens modal with error', async () => {
|
||||||
const deferred = createDeferred<{ ok: boolean; message: string }>();
|
const deferred = createDeferred<{ ok: boolean; message: string }>();
|
||||||
const harness = createTestHarness(async () => deferred.promise);
|
const harness = createTestHarness(async () => deferred.promise);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
harness.modal.wireDomEvents();
|
harness.modal.wireDomEvents();
|
||||||
harness.modal.openSubsyncModal({
|
harness.modal.openSubsyncModal(BASE_PAYLOAD);
|
||||||
sourceTracks: [{ id: 2, label: 'External #2 - eng' }],
|
|
||||||
ffsubsyncAvailable: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
harness.runButton.dispatch('click');
|
harness.runButton.dispatch('click');
|
||||||
await Promise.resolve();
|
await Promise.resolve();
|
||||||
@@ -219,7 +264,8 @@ test('manual subsync failure closes during run, then reopens modal with error',
|
|||||||
assert.equal(harness.statusClassList.contains('error'), true);
|
assert.equal(harness.statusClassList.contains('error'), true);
|
||||||
assert.equal(harness.ctx.dom.subsyncRunButton.disabled, false);
|
assert.equal(harness.ctx.dom.subsyncRunButton.disabled, false);
|
||||||
assert.equal(harness.ctx.dom.subsyncEngineAlass.checked, true);
|
assert.equal(harness.ctx.dom.subsyncEngineAlass.checked, true);
|
||||||
assert.equal(harness.ctx.dom.subsyncSourceSelect.value, '2');
|
assert.equal(harness.referenceSelect.value, '2');
|
||||||
|
assert.equal(harness.targetSelect.value, '1');
|
||||||
assert.equal(harness.getNotifyClosedCalls(), 1);
|
assert.equal(harness.getNotifyClosedCalls(), 1);
|
||||||
assert.equal(harness.getNotifyOpenedCalls(), 1);
|
assert.equal(harness.getNotifyOpenedCalls(), 1);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -231,15 +277,17 @@ test('subsync modal disables ffsubsync when payload marks it unavailable', () =>
|
|||||||
const harness = createTestHarness(async () => ({ ok: true, message: 'ok' }));
|
const harness = createTestHarness(async () => ({ ok: true, message: 'ok' }));
|
||||||
|
|
||||||
try {
|
try {
|
||||||
harness.modal.openSubsyncModal({
|
harness.modal.openSubsyncModal(
|
||||||
sourceTracks: [{ id: 2, label: 'External #2 - eng' }],
|
payloadWith({ ffsubsyncAvailable: false, videoReferenceAvailable: false }),
|
||||||
ffsubsyncAvailable: false,
|
);
|
||||||
});
|
|
||||||
|
|
||||||
assert.equal(harness.ctx.dom.subsyncEngineAlass.checked, true);
|
assert.equal(harness.ctx.dom.subsyncEngineAlass.checked, true);
|
||||||
assert.equal(harness.ctx.dom.subsyncEngineFfsubsync.checked, false);
|
assert.equal(harness.ctx.dom.subsyncEngineFfsubsync.checked, false);
|
||||||
assert.equal(harness.ctx.dom.subsyncEngineFfsubsync.disabled, true);
|
assert.equal(harness.ctx.dom.subsyncEngineFfsubsync.disabled, true);
|
||||||
assert.equal(harness.ctx.dom.subsyncStatus.textContent, 'Choose alass source, then run.');
|
assert.equal(
|
||||||
|
harness.ctx.dom.subsyncStatus.textContent,
|
||||||
|
'Choose the alass reference and out-of-sync subtitle, then run.',
|
||||||
|
);
|
||||||
} finally {
|
} finally {
|
||||||
harness.restoreGlobals();
|
harness.restoreGlobals();
|
||||||
}
|
}
|
||||||
@@ -253,10 +301,14 @@ test('subsync modal ignores enter submission when no sync engine is available',
|
|||||||
});
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
harness.modal.openSubsyncModal({
|
harness.modal.openSubsyncModal(
|
||||||
sourceTracks: [],
|
payloadWith({
|
||||||
|
subtitleTracks: [{ id: 1, label: 'External #1 - jpn (active)' }],
|
||||||
|
defaultReferenceTrackId: null,
|
||||||
|
videoReferenceAvailable: false,
|
||||||
ffsubsyncAvailable: false,
|
ffsubsyncAvailable: false,
|
||||||
});
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
harness.modal.handleSubsyncKeydown({
|
harness.modal.handleSubsyncKeydown({
|
||||||
key: 'Enter',
|
key: 'Enter',
|
||||||
@@ -270,3 +322,102 @@ test('subsync modal ignores enter submission when no sync engine is available',
|
|||||||
harness.restoreGlobals();
|
harness.restoreGlobals();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('subsync modal defaults reference to the secondary track and target to the primary', async () => {
|
||||||
|
let request: SubsyncManualRunRequest | null = null;
|
||||||
|
const harness = createTestHarness(async (nextRequest) => {
|
||||||
|
request = nextRequest;
|
||||||
|
return { ok: true, message: 'ok' };
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
harness.modal.wireDomEvents();
|
||||||
|
harness.modal.openSubsyncModal(BASE_PAYLOAD);
|
||||||
|
|
||||||
|
assert.equal(harness.referenceSelect.value, '2');
|
||||||
|
assert.equal(harness.targetSelect.value, '1');
|
||||||
|
|
||||||
|
harness.runButton.dispatch('click');
|
||||||
|
await flushMicrotasks();
|
||||||
|
|
||||||
|
assert.deepEqual(request, {
|
||||||
|
engine: 'alass',
|
||||||
|
targetTrackId: 1,
|
||||||
|
referenceMode: 'track',
|
||||||
|
referenceTrackId: 2,
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
harness.restoreGlobals();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('subsync modal offers the video file as an alass reference and excludes the target track', () => {
|
||||||
|
const harness = createTestHarness(async () => ({ ok: true, message: 'ok' }));
|
||||||
|
|
||||||
|
try {
|
||||||
|
harness.modal.wireDomEvents();
|
||||||
|
harness.modal.openSubsyncModal(BASE_PAYLOAD);
|
||||||
|
|
||||||
|
assert.deepEqual(
|
||||||
|
harness.referenceSelect.options.map((option) => option.value),
|
||||||
|
['2', 'video'],
|
||||||
|
);
|
||||||
|
|
||||||
|
harness.targetSelect.value = '2';
|
||||||
|
harness.targetSelect.dispatch('change');
|
||||||
|
|
||||||
|
assert.deepEqual(
|
||||||
|
harness.referenceSelect.options.map((option) => option.value),
|
||||||
|
['1', 'video'],
|
||||||
|
);
|
||||||
|
assert.equal(harness.referenceSelect.value, '1');
|
||||||
|
} finally {
|
||||||
|
harness.restoreGlobals();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('subsync modal sends the video reference mode when the video file is selected', async () => {
|
||||||
|
let request: SubsyncManualRunRequest | null = null;
|
||||||
|
const harness = createTestHarness(async (nextRequest) => {
|
||||||
|
request = nextRequest;
|
||||||
|
return { ok: true, message: 'ok' };
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
harness.modal.wireDomEvents();
|
||||||
|
harness.modal.openSubsyncModal(BASE_PAYLOAD);
|
||||||
|
|
||||||
|
harness.referenceSelect.value = 'video';
|
||||||
|
harness.runButton.dispatch('click');
|
||||||
|
await flushMicrotasks();
|
||||||
|
|
||||||
|
assert.deepEqual(request, {
|
||||||
|
engine: 'alass',
|
||||||
|
targetTrackId: 1,
|
||||||
|
referenceMode: 'video',
|
||||||
|
referenceTrackId: null,
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
harness.restoreGlobals();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('subsync modal hides the reference picker for ffsubsync but keeps the target picker', () => {
|
||||||
|
const harness = createTestHarness(async () => ({ ok: true, message: 'ok' }));
|
||||||
|
|
||||||
|
try {
|
||||||
|
harness.modal.wireDomEvents();
|
||||||
|
harness.modal.openSubsyncModal(BASE_PAYLOAD);
|
||||||
|
|
||||||
|
assert.equal(harness.referenceLabelClassList.contains('hidden'), false);
|
||||||
|
|
||||||
|
harness.ctx.dom.subsyncEngineAlass.checked = false;
|
||||||
|
harness.ctx.dom.subsyncEngineFfsubsync.checked = true;
|
||||||
|
harness.ctx.dom.subsyncEngineFfsubsync.dispatch('change');
|
||||||
|
|
||||||
|
assert.equal(harness.referenceLabelClassList.contains('hidden'), true);
|
||||||
|
assert.equal(harness.targetSelect.value, '1');
|
||||||
|
} finally {
|
||||||
|
harness.restoreGlobals();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|||||||
+151
-67
@@ -1,6 +1,14 @@
|
|||||||
import type { SubsyncManualPayload } from '../../types';
|
import type { SubsyncManualPayload, SubsyncManualRunRequest } from '../../types';
|
||||||
import type { ModalStateReader, RendererContext } from '../context';
|
import type { ModalStateReader, RendererContext } from '../context';
|
||||||
|
|
||||||
|
const VIDEO_REFERENCE_VALUE = 'video';
|
||||||
|
|
||||||
|
interface SubsyncSelection {
|
||||||
|
engine: 'alass' | 'ffsubsync';
|
||||||
|
referenceValue: string;
|
||||||
|
targetTrackId: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
export function createSubsyncModal(
|
export function createSubsyncModal(
|
||||||
ctx: RendererContext,
|
ctx: RendererContext,
|
||||||
options: {
|
options: {
|
||||||
@@ -8,27 +16,103 @@ export function createSubsyncModal(
|
|||||||
syncSettingsModalSubtitleSuppression: () => void;
|
syncSettingsModalSubtitleSuppression: () => void;
|
||||||
},
|
},
|
||||||
) {
|
) {
|
||||||
let ffsubsyncAvailable = true;
|
let currentPayload: SubsyncManualPayload | null = null;
|
||||||
|
|
||||||
function setSubsyncStatus(message: string, isError = false): void {
|
function setSubsyncStatus(message: string, isError = false): void {
|
||||||
ctx.dom.subsyncStatus.textContent = message;
|
ctx.dom.subsyncStatus.textContent = message;
|
||||||
ctx.dom.subsyncStatus.classList.toggle('error', isError);
|
ctx.dom.subsyncStatus.classList.toggle('error', isError);
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateSubsyncSourceVisibility(): void {
|
function hasAlassReference(): boolean {
|
||||||
const useAlass = ctx.dom.subsyncEngineAlass.checked;
|
if (!currentPayload) return false;
|
||||||
ctx.dom.subsyncSourceLabel.classList.toggle('hidden', !useAlass);
|
return currentPayload.videoReferenceAvailable || currentPayload.subtitleTracks.length > 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderSubsyncSourceTracks(): void {
|
function updateSubsyncFieldVisibility(): void {
|
||||||
ctx.dom.subsyncSourceSelect.innerHTML = '';
|
const useAlass = ctx.dom.subsyncEngineAlass.checked;
|
||||||
for (const track of ctx.state.subsyncSourceTracks) {
|
ctx.dom.subsyncReferenceLabel.classList.toggle('hidden', !useAlass);
|
||||||
const option = document.createElement('option');
|
ctx.dom.subsyncTargetLabel.classList.toggle(
|
||||||
option.value = String(track.id);
|
'hidden',
|
||||||
option.textContent = track.label;
|
ctx.state.subsyncSubtitleTracks.length === 0,
|
||||||
ctx.dom.subsyncSourceSelect.appendChild(option);
|
);
|
||||||
}
|
}
|
||||||
ctx.dom.subsyncSourceSelect.disabled = ctx.state.subsyncSourceTracks.length === 0;
|
|
||||||
|
function appendOption(select: HTMLSelectElement, value: string, label: string): void {
|
||||||
|
const option = document.createElement('option');
|
||||||
|
option.value = value;
|
||||||
|
option.textContent = label;
|
||||||
|
select.appendChild(option);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSelectedTargetTrackId(): number | null {
|
||||||
|
const raw = Number.parseInt(ctx.dom.subsyncTargetSelect.value, 10);
|
||||||
|
return Number.isFinite(raw) ? raw : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderTargetTracks(preferredTrackId: number | null): void {
|
||||||
|
const select = ctx.dom.subsyncTargetSelect;
|
||||||
|
select.innerHTML = '';
|
||||||
|
select.value = '';
|
||||||
|
for (const track of ctx.state.subsyncSubtitleTracks) {
|
||||||
|
appendOption(select, String(track.id), track.label);
|
||||||
|
}
|
||||||
|
select.disabled = ctx.state.subsyncSubtitleTracks.length === 0;
|
||||||
|
|
||||||
|
const preferred = ctx.state.subsyncSubtitleTracks.find(
|
||||||
|
(track) => track.id === preferredTrackId,
|
||||||
|
);
|
||||||
|
const fallback = ctx.state.subsyncSubtitleTracks[0];
|
||||||
|
const selected = preferred ?? fallback;
|
||||||
|
if (selected) {
|
||||||
|
select.value = String(selected.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderReferenceTracks(preferredValue: string | null): void {
|
||||||
|
const select = ctx.dom.subsyncReferenceSelect;
|
||||||
|
const targetTrackId = getSelectedTargetTrackId();
|
||||||
|
const values: string[] = [];
|
||||||
|
|
||||||
|
select.innerHTML = '';
|
||||||
|
select.value = '';
|
||||||
|
for (const track of ctx.state.subsyncSubtitleTracks) {
|
||||||
|
if (track.id === targetTrackId) continue;
|
||||||
|
appendOption(select, String(track.id), track.label);
|
||||||
|
values.push(String(track.id));
|
||||||
|
}
|
||||||
|
if (currentPayload?.videoReferenceAvailable) {
|
||||||
|
appendOption(select, VIDEO_REFERENCE_VALUE, 'Video file (audio reference)');
|
||||||
|
values.push(VIDEO_REFERENCE_VALUE);
|
||||||
|
}
|
||||||
|
select.disabled = values.length === 0;
|
||||||
|
|
||||||
|
const preferred = preferredValue && values.includes(preferredValue) ? preferredValue : null;
|
||||||
|
const defaultTrackValue =
|
||||||
|
currentPayload?.defaultReferenceTrackId !== null &&
|
||||||
|
currentPayload?.defaultReferenceTrackId !== undefined
|
||||||
|
? String(currentPayload.defaultReferenceTrackId)
|
||||||
|
: null;
|
||||||
|
const fallback =
|
||||||
|
defaultTrackValue && values.includes(defaultTrackValue) ? defaultTrackValue : values[0];
|
||||||
|
const selected = preferred ?? fallback;
|
||||||
|
if (selected) {
|
||||||
|
select.value = selected;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function describeSubsyncState(): string {
|
||||||
|
if (!currentPayload) return '';
|
||||||
|
const alassReady = hasAlassReference();
|
||||||
|
if (alassReady && currentPayload.ffsubsyncAvailable) {
|
||||||
|
return 'Choose engine, reference and out-of-sync subtitle, then run.';
|
||||||
|
}
|
||||||
|
if (alassReady) {
|
||||||
|
return 'Choose the alass reference and out-of-sync subtitle, then run.';
|
||||||
|
}
|
||||||
|
if (currentPayload.ffsubsyncAvailable) {
|
||||||
|
return 'No reference available for alass. Use ffsubsync.';
|
||||||
|
}
|
||||||
|
return 'No sync engine available for current media.';
|
||||||
}
|
}
|
||||||
|
|
||||||
function closeSubsyncModal(): void {
|
function closeSubsyncModal(): void {
|
||||||
@@ -46,30 +130,27 @@ export function createSubsyncModal(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function openSubsyncModal(payload: SubsyncManualPayload): void {
|
function openSubsyncModal(payload: SubsyncManualPayload, selection?: SubsyncSelection): void {
|
||||||
ctx.state.subsyncSubmitting = false;
|
ctx.state.subsyncSubmitting = false;
|
||||||
ctx.state.subsyncSourceTracks = payload.sourceTracks;
|
ctx.state.subsyncSubtitleTracks = payload.subtitleTracks;
|
||||||
ffsubsyncAvailable = payload.ffsubsyncAvailable;
|
currentPayload = payload;
|
||||||
|
|
||||||
const hasSources = ctx.state.subsyncSourceTracks.length > 0;
|
const alassReady = hasAlassReference();
|
||||||
ctx.dom.subsyncEngineAlass.checked = hasSources;
|
const useAlass = selection ? selection.engine === 'alass' && alassReady : alassReady;
|
||||||
ctx.dom.subsyncEngineFfsubsync.checked = !hasSources && ffsubsyncAvailable;
|
ctx.dom.subsyncEngineAlass.checked = useAlass;
|
||||||
ctx.dom.subsyncEngineFfsubsync.disabled = !ffsubsyncAvailable;
|
ctx.dom.subsyncEngineFfsubsync.checked = !useAlass && payload.ffsubsyncAvailable;
|
||||||
ctx.dom.subsyncRunButton.disabled = !hasSources && !ffsubsyncAvailable;
|
ctx.dom.subsyncEngineAlass.disabled = !alassReady;
|
||||||
|
ctx.dom.subsyncEngineFfsubsync.disabled = !payload.ffsubsyncAvailable;
|
||||||
|
ctx.dom.subsyncRunButton.disabled = !alassReady && !payload.ffsubsyncAvailable;
|
||||||
|
|
||||||
renderSubsyncSourceTracks();
|
renderTargetTracks(
|
||||||
updateSubsyncSourceVisibility();
|
selection
|
||||||
|
? (selection.targetTrackId ?? payload.defaultTargetTrackId)
|
||||||
setSubsyncStatus(
|
: payload.defaultTargetTrackId,
|
||||||
!ffsubsyncAvailable && hasSources
|
|
||||||
? 'Choose alass source, then run.'
|
|
||||||
: !ffsubsyncAvailable
|
|
||||||
? 'No source subtitles available for alass.'
|
|
||||||
: hasSources
|
|
||||||
? 'Choose engine and source, then run.'
|
|
||||||
: 'No source subtitles available for alass. Use ffsubsync.',
|
|
||||||
false,
|
|
||||||
);
|
);
|
||||||
|
renderReferenceTracks(selection?.referenceValue ?? null);
|
||||||
|
updateSubsyncFieldVisibility();
|
||||||
|
setSubsyncStatus(describeSubsyncState(), false);
|
||||||
|
|
||||||
ctx.state.subsyncModalOpen = true;
|
ctx.state.subsyncModalOpen = true;
|
||||||
options.syncSettingsModalSubtitleSuppression();
|
options.syncSettingsModalSubtitleSuppression();
|
||||||
@@ -80,25 +161,11 @@ export function createSubsyncModal(
|
|||||||
}
|
}
|
||||||
|
|
||||||
function reopenSubsyncModalWithError(
|
function reopenSubsyncModalWithError(
|
||||||
sourceTracks: SubsyncManualPayload['sourceTracks'],
|
payload: SubsyncManualPayload,
|
||||||
engine: 'alass' | 'ffsubsync',
|
selection: SubsyncSelection,
|
||||||
sourceTrackId: number | null,
|
|
||||||
message: string,
|
message: string,
|
||||||
): void {
|
): void {
|
||||||
openSubsyncModal({ sourceTracks, ffsubsyncAvailable });
|
openSubsyncModal(payload, selection);
|
||||||
|
|
||||||
if (engine === 'alass' && sourceTracks.length > 0) {
|
|
||||||
ctx.dom.subsyncEngineAlass.checked = true;
|
|
||||||
ctx.dom.subsyncEngineFfsubsync.checked = false;
|
|
||||||
if (Number.isFinite(sourceTrackId)) {
|
|
||||||
ctx.dom.subsyncSourceSelect.value = String(sourceTrackId);
|
|
||||||
}
|
|
||||||
} else if (ffsubsyncAvailable) {
|
|
||||||
ctx.dom.subsyncEngineAlass.checked = false;
|
|
||||||
ctx.dom.subsyncEngineFfsubsync.checked = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
updateSubsyncSourceVisibility();
|
|
||||||
setSubsyncStatus(message, true);
|
setSubsyncStatus(message, true);
|
||||||
window.electronAPI.notifyOverlayModalOpened('subsync');
|
window.electronAPI.notifyOverlayModalOpened('subsync');
|
||||||
}
|
}
|
||||||
@@ -106,6 +173,7 @@ export function createSubsyncModal(
|
|||||||
async function runSubsyncManualFromModal(): Promise<void> {
|
async function runSubsyncManualFromModal(): Promise<void> {
|
||||||
if (ctx.state.subsyncSubmitting) return;
|
if (ctx.state.subsyncSubmitting) return;
|
||||||
if (ctx.dom.subsyncRunButton.disabled) return;
|
if (ctx.dom.subsyncRunButton.disabled) return;
|
||||||
|
if (!currentPayload) return;
|
||||||
|
|
||||||
const useAlass = ctx.dom.subsyncEngineAlass.checked;
|
const useAlass = ctx.dom.subsyncEngineAlass.checked;
|
||||||
const useFfsubsync = ctx.dom.subsyncEngineFfsubsync.checked;
|
const useFfsubsync = ctx.dom.subsyncEngineFfsubsync.checked;
|
||||||
@@ -115,33 +183,46 @@ export function createSubsyncModal(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const engine = useAlass ? 'alass' : 'ffsubsync';
|
const engine = useAlass ? 'alass' : 'ffsubsync';
|
||||||
const sourceTrackId =
|
const referenceValue = ctx.dom.subsyncReferenceSelect.value;
|
||||||
engine === 'alass' && ctx.dom.subsyncSourceSelect.value
|
const targetTrackId = getSelectedTargetTrackId();
|
||||||
? Number.parseInt(ctx.dom.subsyncSourceSelect.value, 10)
|
|
||||||
: null;
|
|
||||||
|
|
||||||
if (engine === 'alass' && !Number.isFinite(sourceTrackId)) {
|
if (targetTrackId === null) {
|
||||||
setSubsyncStatus('Select a source subtitle track for alass.', true);
|
setSubsyncStatus('Select the out-of-sync subtitle track to retime.', true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (engine === 'alass' && !referenceValue) {
|
||||||
|
setSubsyncStatus('Select a reference for alass.', true);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const sourceTracksSnapshot = ctx.state.subsyncSourceTracks.map((track) => ({ ...track }));
|
const useVideoReference = referenceValue === VIDEO_REFERENCE_VALUE;
|
||||||
|
const request: SubsyncManualRunRequest = {
|
||||||
|
engine,
|
||||||
|
targetTrackId,
|
||||||
|
};
|
||||||
|
if (engine === 'alass') {
|
||||||
|
request.referenceMode = useVideoReference ? 'video' : 'track';
|
||||||
|
request.referenceTrackId = useVideoReference ? null : Number.parseInt(referenceValue, 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
const payloadSnapshot: SubsyncManualPayload = {
|
||||||
|
...currentPayload,
|
||||||
|
subtitleTracks: currentPayload.subtitleTracks.map((track) => ({ ...track })),
|
||||||
|
};
|
||||||
|
const selection: SubsyncSelection = { engine, referenceValue, targetTrackId };
|
||||||
|
|
||||||
ctx.state.subsyncSubmitting = true;
|
ctx.state.subsyncSubmitting = true;
|
||||||
ctx.dom.subsyncRunButton.disabled = true;
|
ctx.dom.subsyncRunButton.disabled = true;
|
||||||
closeSubsyncModal();
|
closeSubsyncModal();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await window.electronAPI.runSubsyncManual({
|
const result = await window.electronAPI.runSubsyncManual(request);
|
||||||
engine,
|
|
||||||
sourceTrackId,
|
|
||||||
});
|
|
||||||
if (result.ok) return;
|
if (result.ok) return;
|
||||||
reopenSubsyncModalWithError(sourceTracksSnapshot, engine, sourceTrackId, result.message);
|
reopenSubsyncModalWithError(payloadSnapshot, selection, result.message);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
reopenSubsyncModalWithError(
|
reopenSubsyncModalWithError(
|
||||||
sourceTracksSnapshot,
|
payloadSnapshot,
|
||||||
engine,
|
selection,
|
||||||
sourceTrackId,
|
|
||||||
`Subsync failed: ${(error as Error).message}`,
|
`Subsync failed: ${(error as Error).message}`,
|
||||||
);
|
);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -171,10 +252,13 @@ export function createSubsyncModal(
|
|||||||
closeSubsyncModal();
|
closeSubsyncModal();
|
||||||
});
|
});
|
||||||
ctx.dom.subsyncEngineAlass.addEventListener('change', () => {
|
ctx.dom.subsyncEngineAlass.addEventListener('change', () => {
|
||||||
updateSubsyncSourceVisibility();
|
updateSubsyncFieldVisibility();
|
||||||
});
|
});
|
||||||
ctx.dom.subsyncEngineFfsubsync.addEventListener('change', () => {
|
ctx.dom.subsyncEngineFfsubsync.addEventListener('change', () => {
|
||||||
updateSubsyncSourceVisibility();
|
updateSubsyncFieldVisibility();
|
||||||
|
});
|
||||||
|
ctx.dom.subsyncTargetSelect.addEventListener('change', () => {
|
||||||
|
renderReferenceTracks(ctx.dom.subsyncReferenceSelect.value || null);
|
||||||
});
|
});
|
||||||
ctx.dom.subsyncRunButton.addEventListener('click', () => {
|
ctx.dom.subsyncRunButton.addEventListener('click', () => {
|
||||||
void runSubsyncManualFromModal();
|
void runSubsyncManualFromModal();
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ import type {
|
|||||||
SubtitlePosition,
|
SubtitlePosition,
|
||||||
SubtitleSidebarSnapshotConfig,
|
SubtitleSidebarSnapshotConfig,
|
||||||
SubtitleCue,
|
SubtitleCue,
|
||||||
SubsyncSourceTrack,
|
SubsyncSubtitleTrack,
|
||||||
YoutubePickerOpenPayload,
|
YoutubePickerOpenPayload,
|
||||||
} from '../types';
|
} from '../types';
|
||||||
|
|
||||||
@@ -85,7 +85,7 @@ export type RendererState = {
|
|||||||
characterDictionaryStatus: string;
|
characterDictionaryStatus: string;
|
||||||
|
|
||||||
subsyncModalOpen: boolean;
|
subsyncModalOpen: boolean;
|
||||||
subsyncSourceTracks: SubsyncSourceTrack[];
|
subsyncSubtitleTracks: SubsyncSubtitleTrack[];
|
||||||
subsyncSubmitting: boolean;
|
subsyncSubmitting: boolean;
|
||||||
|
|
||||||
controllerSelectModalOpen: boolean;
|
controllerSelectModalOpen: boolean;
|
||||||
@@ -213,7 +213,7 @@ export function createRendererState(): RendererState {
|
|||||||
characterDictionaryStatus: '',
|
characterDictionaryStatus: '',
|
||||||
|
|
||||||
subsyncModalOpen: false,
|
subsyncModalOpen: false,
|
||||||
subsyncSourceTracks: [],
|
subsyncSubtitleTracks: [],
|
||||||
subsyncSubmitting: false,
|
subsyncSubmitting: false,
|
||||||
|
|
||||||
controllerSelectModalOpen: false,
|
controllerSelectModalOpen: false,
|
||||||
|
|||||||
@@ -90,8 +90,10 @@ export type RendererDom = {
|
|||||||
subsyncCloseButton: HTMLButtonElement;
|
subsyncCloseButton: HTMLButtonElement;
|
||||||
subsyncEngineAlass: HTMLInputElement;
|
subsyncEngineAlass: HTMLInputElement;
|
||||||
subsyncEngineFfsubsync: HTMLInputElement;
|
subsyncEngineFfsubsync: HTMLInputElement;
|
||||||
subsyncSourceLabel: HTMLLabelElement;
|
subsyncReferenceLabel: HTMLLabelElement;
|
||||||
subsyncSourceSelect: HTMLSelectElement;
|
subsyncReferenceSelect: HTMLSelectElement;
|
||||||
|
subsyncTargetLabel: HTMLLabelElement;
|
||||||
|
subsyncTargetSelect: HTMLSelectElement;
|
||||||
subsyncRunButton: HTMLButtonElement;
|
subsyncRunButton: HTMLButtonElement;
|
||||||
subsyncStatus: HTMLDivElement;
|
subsyncStatus: HTMLDivElement;
|
||||||
|
|
||||||
@@ -255,8 +257,10 @@ export function resolveRendererDom(): RendererDom {
|
|||||||
subsyncCloseButton: getRequiredElement<HTMLButtonElement>('subsyncClose'),
|
subsyncCloseButton: getRequiredElement<HTMLButtonElement>('subsyncClose'),
|
||||||
subsyncEngineAlass: getRequiredElement<HTMLInputElement>('subsyncEngineAlass'),
|
subsyncEngineAlass: getRequiredElement<HTMLInputElement>('subsyncEngineAlass'),
|
||||||
subsyncEngineFfsubsync: getRequiredElement<HTMLInputElement>('subsyncEngineFfsubsync'),
|
subsyncEngineFfsubsync: getRequiredElement<HTMLInputElement>('subsyncEngineFfsubsync'),
|
||||||
subsyncSourceLabel: getRequiredElement<HTMLLabelElement>('subsyncSourceLabel'),
|
subsyncReferenceLabel: getRequiredElement<HTMLLabelElement>('subsyncReferenceLabel'),
|
||||||
subsyncSourceSelect: getRequiredElement<HTMLSelectElement>('subsyncSourceSelect'),
|
subsyncReferenceSelect: getRequiredElement<HTMLSelectElement>('subsyncReferenceSelect'),
|
||||||
|
subsyncTargetLabel: getRequiredElement<HTMLLabelElement>('subsyncTargetLabel'),
|
||||||
|
subsyncTargetSelect: getRequiredElement<HTMLSelectElement>('subsyncTargetSelect'),
|
||||||
subsyncRunButton: getRequiredElement<HTMLButtonElement>('subsyncRun'),
|
subsyncRunButton: getRequiredElement<HTMLButtonElement>('subsyncRun'),
|
||||||
subsyncStatus: getRequiredElement<HTMLDivElement>('subsyncStatus'),
|
subsyncStatus: getRequiredElement<HTMLDivElement>('subsyncStatus'),
|
||||||
|
|
||||||
|
|||||||
@@ -295,14 +295,27 @@ export function parseControllerConfigUpdate(value: unknown): ControllerConfigUpd
|
|||||||
|
|
||||||
export function parseSubsyncManualRunRequest(value: unknown): SubsyncManualRunRequest | null {
|
export function parseSubsyncManualRunRequest(value: unknown): SubsyncManualRunRequest | null {
|
||||||
if (!isObject(value)) return null;
|
if (!isObject(value)) return null;
|
||||||
const { engine, sourceTrackId } = value;
|
const { engine, referenceMode, referenceTrackId, targetTrackId } = value;
|
||||||
if (engine !== 'alass' && engine !== 'ffsubsync') return null;
|
if (engine !== 'alass' && engine !== 'ffsubsync') return null;
|
||||||
if (sourceTrackId !== undefined && sourceTrackId !== null && !isInteger(sourceTrackId)) {
|
if (referenceMode !== undefined && referenceMode !== 'track' && referenceMode !== 'video') {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const parseOptionalTrackId = (raw: unknown): number | null | undefined | false => {
|
||||||
|
if (raw === undefined) return undefined;
|
||||||
|
if (raw === null) return null;
|
||||||
|
return isInteger(raw) ? raw : false;
|
||||||
|
};
|
||||||
|
|
||||||
|
const parsedReferenceTrackId = parseOptionalTrackId(referenceTrackId);
|
||||||
|
const parsedTargetTrackId = parseOptionalTrackId(targetTrackId);
|
||||||
|
if (parsedReferenceTrackId === false || parsedTargetTrackId === false) return null;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
engine,
|
engine,
|
||||||
sourceTrackId: sourceTrackId === undefined ? undefined : (sourceTrackId as number | null),
|
referenceMode,
|
||||||
|
referenceTrackId: parsedReferenceTrackId,
|
||||||
|
targetTrackId: parsedTargetTrackId,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -33,7 +33,8 @@ export interface SubsyncContext {
|
|||||||
videoPath: string;
|
videoPath: string;
|
||||||
primaryTrack: MpvTrack;
|
primaryTrack: MpvTrack;
|
||||||
secondaryTrack: MpvTrack | null;
|
secondaryTrack: MpvTrack | null;
|
||||||
sourceTracks: MpvTrack[];
|
/** Every usable subtitle track, including the primary one. */
|
||||||
|
subtitleTracks: MpvTrack[];
|
||||||
audioStreamIndex: number | null;
|
audioStreamIndex: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,15 @@ import type { AiFeatureConfig } from './integrations';
|
|||||||
import type { NotificationType } from './notification';
|
import type { NotificationType } from './notification';
|
||||||
import type { NPlusOneMatchMode } from './subtitle';
|
import type { NPlusOneMatchMode } from './subtitle';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Card that a Kiku/Lapis note generates. The note types mark this with mutually
|
||||||
|
* exclusive `Is...Card` flag fields, so only one kind may be flagged per note.
|
||||||
|
*/
|
||||||
|
export type CardKind = 'sentence' | 'audio' | 'word-and-sentence' | 'click';
|
||||||
|
|
||||||
|
/** Card kind SubMiner flags on word cards; 'none' leaves the flag fields untouched. */
|
||||||
|
export type WordCardKind = CardKind | 'none';
|
||||||
|
|
||||||
export interface NotificationOptions {
|
export interface NotificationOptions {
|
||||||
body?: string;
|
body?: string;
|
||||||
icon?: string;
|
icon?: string;
|
||||||
@@ -115,4 +124,7 @@ export interface AnkiConnectConfig {
|
|||||||
fieldGrouping?: 'auto' | 'manual' | 'disabled';
|
fieldGrouping?: 'auto' | 'manual' | 'disabled';
|
||||||
deleteDuplicateInAuto?: boolean;
|
deleteDuplicateInAuto?: boolean;
|
||||||
};
|
};
|
||||||
|
lapisKiku?: {
|
||||||
|
wordCardKind?: WordCardKind;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
+4
-1
@@ -1,4 +1,4 @@
|
|||||||
import type { AnkiConnectConfig } from './anki';
|
import type { AnkiConnectConfig, WordCardKind } from './anki';
|
||||||
import type {
|
import type {
|
||||||
AiConfig,
|
AiConfig,
|
||||||
AiFeatureConfig,
|
AiFeatureConfig,
|
||||||
@@ -285,6 +285,9 @@ export interface ResolvedConfig {
|
|||||||
fieldGrouping: 'auto' | 'manual' | 'disabled';
|
fieldGrouping: 'auto' | 'manual' | 'disabled';
|
||||||
deleteDuplicateInAuto: boolean;
|
deleteDuplicateInAuto: boolean;
|
||||||
};
|
};
|
||||||
|
lapisKiku: {
|
||||||
|
wordCardKind: WordCardKind;
|
||||||
|
};
|
||||||
};
|
};
|
||||||
shortcuts: Required<ShortcutsConfig>;
|
shortcuts: Required<ShortcutsConfig>;
|
||||||
secondarySub: Required<SecondarySubConfig>;
|
secondarySub: Required<SecondarySubConfig>;
|
||||||
|
|||||||
+12
-3
@@ -78,19 +78,28 @@ export interface MpvClient {
|
|||||||
send(command: { command: unknown[]; request_id?: number }): boolean;
|
send(command: { command: unknown[]; request_id?: number }): boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SubsyncSourceTrack {
|
export interface SubsyncSubtitleTrack {
|
||||||
id: number;
|
id: number;
|
||||||
label: string;
|
label: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SubsyncManualPayload {
|
export interface SubsyncManualPayload {
|
||||||
sourceTracks: SubsyncSourceTrack[];
|
subtitleTracks: SubsyncSubtitleTrack[];
|
||||||
|
defaultReferenceTrackId: number | null;
|
||||||
|
defaultTargetTrackId: number | null;
|
||||||
|
videoReferenceAvailable: boolean;
|
||||||
ffsubsyncAvailable: boolean;
|
ffsubsyncAvailable: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type SubsyncReferenceMode = 'track' | 'video';
|
||||||
|
|
||||||
export interface SubsyncManualRunRequest {
|
export interface SubsyncManualRunRequest {
|
||||||
engine: 'alass' | 'ffsubsync';
|
engine: 'alass' | 'ffsubsync';
|
||||||
sourceTrackId?: number | null;
|
/** alass reference source: another subtitle track, or the loaded media file itself. */
|
||||||
|
referenceMode?: SubsyncReferenceMode;
|
||||||
|
referenceTrackId?: number | null;
|
||||||
|
/** Subtitle track to retime. Defaults to the active primary track. */
|
||||||
|
targetTrackId?: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SubsyncResult {
|
export interface SubsyncResult {
|
||||||
|
|||||||
Reference in New Issue
Block a user