Compare commits

..

5 Commits

48 changed files with 1995 additions and 292 deletions
+18
View File
@@ -1,5 +1,23 @@
# 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
+7 -9
View File
@@ -26,7 +26,7 @@
"eslint": "^10.8.0",
"prettier": "^3.8.1",
"typescript": "^5.9.3",
"undici": "7.28.0",
"undici": "7.29.0",
},
},
},
@@ -36,8 +36,9 @@
"overrides": {
"@xmldom/xmldom": "0.8.13",
"app-builder-lib": "26.15.3",
"brace-expansion": "5.0.8",
"brace-expansion": "5.0.9",
"electron-builder-squirrel-windows": "26.15.3",
"fast-uri": "3.1.5",
"form-data": "4.0.6",
"ip-address": "10.2.0",
"js-yaml": "4.3.0",
@@ -46,6 +47,7 @@
"picomatch": "4.0.4",
"tar": "7.5.21",
"tmp": "0.2.7",
"undici": "7.29.0",
},
"packages": {
"@discordjs/collection": ["@discordjs/collection@2.1.1", "", {}, "sha512-LiSusze9Tc7qF03sLCujF5iZp7K+vRNEDBZ86FT9aQAv3vxMLihUvKvpsCWiQ2DJq1tVckopKm1rxomgNUc9hg=="],
@@ -266,7 +268,7 @@
"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=="],
@@ -404,7 +406,7 @@
"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=="],
@@ -714,7 +716,7 @@
"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=="],
@@ -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/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=="],
"@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/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=="],
"pkijs/@noble/hashes": ["@noble/hashes@1.4.0", "", {}, "sha512-V1JJ1WTRUqHHrOSh597hURcMqVKVGL/ea3kv0gSnEdsEZ0/+VyPghM1lMNGc00z7CIQorSvbKpuJkxvuHbvdbg=="],
+18
View File
@@ -1,5 +1,23 @@
# 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**
+2 -2
View File
@@ -1196,9 +1196,9 @@ See [TsukiHime Integration](/tsukihime-integration) for the modal workflow, lang
### 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
```json
+6 -3
View File
@@ -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.
2. Select the sync engine (alass or ffsubsync).
3. For alass, select a reference subtitle track from the video.
4. SubMiner runs the sync and reloads the corrected subtitle.
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. 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.
+1 -1
View File
@@ -227,7 +227,7 @@ Install ffsubsync or configure the path:
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).
- 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).
+1 -1
View File
@@ -149,7 +149,7 @@ Once Jellyfin is configured, the tray menu includes `Jellyfin Discovery` for sta
- `--log-level` controls logger verbosity.
- `--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).
- 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.
+3 -1
View File
@@ -64,7 +64,7 @@ Use the basic subtitle websocket when you only need the current subtitle line as
- **Client auth:** none
- **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
@@ -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.
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
```json
+18 -5
View File
@@ -3,7 +3,7 @@
# Subtitle Overlay Priming
Status: active
Last verified: 2026-06-14
Last verified: 2026-08-04
Owner: Kyle Yasuda
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.
6. Tokenization cache hit: call `consumeCachedSubtitle(text)`, `onSubtitleChange(text)`, and
`emitSubtitle(cachedPayload)`, then prime secondary subtitles.
7. Cache miss: call `refreshCurrentSubtitle(text)` and let normal tokenization emit the final
payload.
7. Cache miss: call `refreshCurrentSubtitle(text)`. Normal processing emits a plain payload
synchronously, then replaces it with the tokenized payload when ready.
In `src/main.ts`, both `onSubtitleChange` and `refreshCurrentSubtitle` pause
`subtitlePrefetchService`, notify it with `onSeek(lastObservedTimePos)`, and then call the matching
`subtitleProcessingController` method. This gives the visible overlay priority over background
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
- `emitSubtitle(payload)` maps to `emitSubtitlePayload(payload)`, which sends the normal
annotated subtitle payload to overlay windows and subtitle websocket listeners.
- `emitSubtitle(payload)` maps to `emitSubtitlePayload(payload)`. Overlay windows and annotation
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
`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
+6 -4
View File
@@ -2,7 +2,7 @@
"name": "subminer",
"productName": "SubMiner",
"desktopName": "SubMiner.desktop",
"version": "0.19.1",
"version": "0.19.2",
"description": "All-in-one sentence mining overlay with AnkiConnect and dictionary integration",
"packageManager": "bun@1.3.5",
"main": "dist/main-entry.js",
@@ -84,8 +84,9 @@
"overrides": {
"@xmldom/xmldom": "0.8.13",
"app-builder-lib": "26.15.3",
"brace-expansion": "5.0.8",
"brace-expansion": "5.0.9",
"electron-builder-squirrel-windows": "26.15.3",
"fast-uri": "3.1.5",
"form-data": "4.0.6",
"ip-address": "10.2.0",
"js-yaml": "4.3.0",
@@ -93,7 +94,8 @@
"minimatch": "10.2.5",
"picomatch": "4.0.4",
"tar": "7.5.21",
"tmp": "0.2.7"
"tmp": "0.2.7",
"undici": "7.29.0"
},
"keywords": [
"anki",
@@ -125,7 +127,7 @@
"@types/ws": "^8.18.1",
"electron": "42.6.0",
"electron-builder": "26.15.3",
"undici": "7.28.0",
"undici": "7.29.0",
"esbuild": "^0.25.12",
"eslint": "^10.8.0",
"prettier": "^3.8.1",
+21 -13
View File
@@ -1,22 +1,30 @@
## Highlights
### Added
- Kiku/Lapis Word Card Type Setting
- A new setting (Mining/Anki > Kiku/Lapis Features > "Word Card Type") lets you choose which card-type flag gets marked on Kiku/Lapis word cards, including a click-card option that SubMiner couldn't set before.
- Handy if you only want click cards flagged instead of the default word-and-sentence marking.
- Choosing a card type now clears any other flags automatically, so a note can't end up marked as two types at once.
### Changed
- **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
- Yomitan Popup on macOS
- Fixed the popup going unresponsive after mining a card — clicks outside it no longer leak through to mpv, and the overlay no longer flickers hidden and shown.
- Scrolling over the popup now scrolls its definitions instead of seeking the video.
- YouTube Playlist Links
- Opening a video from a playlist URL (like a Watch Later link with `list=`/`index=`) no longer times out while loading subtitles, metadata, or playback info.
- **Startup Logging**
- Background startup now respects your configured log level even when no `--log-level` flag is passed.
- **Streaming Subtitle Tokenization**
- 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.
- Subtitle cues are no longer dropped when switching to a subtitle track embedded in the stream.
- 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.
- 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.
- **Subtitle Line Display**
- Subtitle lines now appear immediately at their cue time even if tokenization hasn't finished, upgrading in place with annotations once ready.
- A failed tokenization attempt is no longer cached as plain text, so the line gets another chance at full annotations later.
## What's Changed
- feat(anki): add configurable word card type for Kiku/Lapis by @ksyasuda in #175
- fix(overlay): keep Yomitan popup interactive on macOS/Windows by @ksyasuda in #177
- fix(youtube): prevent playlist URLs from stalling yt-dlp probes by @ksyasuda in #180
- feat(subsync): add reference and target subtitle track picker by @ksyasuda in #181
- fix(logging): surface subtitle processing debug/warn logs by @ksyasuda in #182
- fix(streaming): keep subtitle tokenization prefetch warm for full episodes by @ksyasuda in #183
- fix(overlay): show plain subtitle line immediately on tokenization cache miss by @ksyasuda in #184
## Installation
+5 -1
View File
@@ -1,5 +1,9 @@
export { Texthooker } from './texthooker';
export { hasMpvWebsocketPlugin, SubtitleWebSocket } from './subtitle-ws';
export {
hasMpvWebsocketPlugin,
isSubtitleAnnotationUpgrade,
SubtitleWebSocket,
} from './subtitle-ws';
export { registerGlobalShortcuts } from './shortcut';
export { createIpcDepsRuntime, registerIpcHandlers } from './ipc';
export { shortcutMatchesInputForLocalFallback } from './shortcut-fallback';
+2 -2
View File
@@ -205,7 +205,7 @@ test('runStartupBootstrapRuntime skips lifecycle when generate-config flow handl
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 args = makeArgs({ background: true });
@@ -222,7 +222,7 @@ test('runStartupBootstrapRuntime enables quiet background mode by default', () =
});
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', () => {
+1 -1
View File
@@ -45,7 +45,7 @@ export function runStartupBootstrapRuntime(
if (initialArgs.logLevel) {
deps.setLogLevel(initialArgs.logLevel, 'cli');
} else if (initialArgs.background || initialArgs.update) {
} else if (initialArgs.update) {
deps.setLogLevel('warn', 'cli');
}
+397 -22
View File
@@ -8,6 +8,7 @@ import {
runSubsyncManual,
triggerSubsyncFromConfig,
} from './subsync';
import type { SubsyncManualPayload } from '../../types';
function makeDeps(
overrides: Partial<TriggerSubsyncFromConfigDeps> = {},
@@ -76,7 +77,7 @@ test('triggerSubsyncFromConfig opens manual picker', async () => {
await triggerSubsyncFromConfig(
makeDeps({
openManualPicker: (payload) => {
payloadTrackCount = payload.sourceTracks.length;
payloadTrackCount = payload.subtitleTracks.length;
ffsubsyncAvailable = payload.ffsubsyncAvailable;
},
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.ok(osd.includes('Subsync: choose engine and source'));
assert.ok(osd.includes('Subsync: choose engine and subtitles'));
assert.equal(inProgressState, false);
});
@@ -140,7 +141,7 @@ test('triggerSubsyncFromConfig does not run automatic sync', async () => {
await triggerSubsyncFromConfig(
makeDeps({
openManualPicker: (payload) => {
payloadTrackCount = payload.sourceTracks.length;
payloadTrackCount = payload.subtitleTracks.length;
},
showMpvOsd: (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.deepEqual(osd, ['Subsync: choose engine and source']);
assert.deepEqual(osd, ['Subsync: choose engine and subtitles']);
});
test('triggerSubsyncFromConfig dedupes repeated subtitle source tracks', async () => {
@@ -195,12 +196,71 @@ test('triggerSubsyncFromConfig dedupes repeated subtitle source tracks', async (
},
}),
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 () => {
@@ -217,15 +277,157 @@ test('triggerSubsyncFromConfig reports failures to OSD', async () => {
assert.ok(osd.some((line) => line.startsWith('Subsync failed: MPV not connected')));
});
test('runSubsyncManual requires a source track for alass', async () => {
const result = await runSubsyncManual({ engine: 'alass', sourceTrackId: null }, makeDeps());
test('runSubsyncManual requires a reference track for alass', async () => {
const result = await runSubsyncManual({ engine: 'alass', referenceTrackId: null }, makeDeps());
assert.deepEqual(result, {
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 () => {
const osd: string[] = [];
const inProgress: boolean[] = [];
@@ -242,7 +444,7 @@ test('triggerSubsyncFromConfig does not validate sync tool paths before manual s
inProgress.push(value);
},
openManualPicker: (payload) => {
payloadTrackCount = payload.sourceTracks.length;
payloadTrackCount = payload.subtitleTracks.length;
},
showMpvOsd: (text) => {
osd.push(text);
@@ -251,8 +453,8 @@ test('triggerSubsyncFromConfig does not validate sync tool paths before manual s
);
assert.deepEqual(inProgress, [false]);
assert.equal(payloadTrackCount, 1);
assert.deepEqual(osd, ['Subsync: choose engine and source']);
assert.equal(payloadTrackCount, 2);
assert.deepEqual(osd, ['Subsync: choose engine and subtitles']);
});
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.message, 'Subtitle synchronized with ffsubsync');
@@ -346,7 +548,7 @@ test('runSubsyncManual constructs ffsubsync command and returns success', async
const ffOutputFlagIndex = ffArgs.indexOf('-o');
assert.equal(ffOutputFlagIndex >= 0, true);
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]);
});
@@ -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);
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.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(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));
});
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 () => {
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'subsync-alass-internal-source-'));
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.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]);
});
@@ -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.message, 'Subtitle synchronized with ffsubsync');
+195 -34
View File
@@ -21,6 +21,11 @@ interface FileExtractionResult {
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 {
const parts = [
`code=${result.code ?? 'n/a'}`,
@@ -90,16 +95,28 @@ function getSourceTrackIdentity(track: MpvTrack): string {
return 'unknown';
}
function dedupeSourceTracks(tracks: MpvTrack[]): MpvTrack[] {
const deduped = new Map<string, MpvTrack>();
function isPinned(track: MpvTrack, pinnedIds: Set<number>): boolean {
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) {
if (isPinned(track, pinnedIds)) continue;
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)) {
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 {
@@ -142,20 +159,21 @@ async function gatherSubsyncContext(client: MpvClientLike): Promise<SubsyncConte
}
const secondaryTrack = subtitleTracks.find((track) => track.id === secondarySid) ?? null;
const sourceTracks = subtitleTracks
.filter((track) => track.id !== sid)
.filter((track) => {
const usableTracks = subtitleTracks.filter((track) => {
if (typeof track.id !== 'number') return false;
if (!track.external) return true;
const filename = track['external-filename'];
return typeof filename === 'string' && filename.length > 0;
});
const uniqueSourceTracks = dedupeSourceTracks(sourceTracks);
return {
videoPath,
primaryTrack,
secondaryTrack,
sourceTracks: uniqueSourceTracks,
subtitleTracks: dedupeSubtitleTracks(
usableTracks,
new Set([sid, secondarySid].filter((id): id is number => typeof id === 'number')),
),
audioStreamIndex: client.currentAudioStreamIndex,
};
}
@@ -271,41 +289,104 @@ async function runFfsubsyncSync(
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) {
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] });
}
async function subsyncToReference(
engine: 'alass' | 'ffsubsync',
referenceFilePath: string,
targetTrack: MpvTrack,
context: SubsyncContext,
resolved: SubsyncResolvedConfig,
client: MpvClientLike,
slot: SubtitleSlot,
): Promise<SubsyncResult> {
const ffmpegPath = ensureExecutablePath(resolved.ffmpegPath, 'ffmpeg');
const primaryExtraction = await extractSubtitleTrackToFile(
const targetExtraction = await extractSubtitleTrackToFile(
ffmpegPath,
context.videoPath,
context.primaryTrack,
targetTrack,
);
const replacePrimary = resolved.replace !== false && !primaryExtraction.temporary;
const outputPath = buildRetimedPath(primaryExtraction.path, replacePrimary);
const replaceTarget = resolved.replace !== false && !targetExtraction.temporary;
const outputPath = buildRetimedPath(targetExtraction.path, replaceTarget);
try {
let result: CommandResult;
if (engine === 'alass') {
const alassPath = ensureExecutablePath(resolved.alassPath, 'alass');
result = await runAlassSync(alassPath, referenceFilePath, primaryExtraction.path, outputPath);
result = await runAlassSync(alassPath, referenceFilePath, targetExtraction.path, outputPath);
} else {
const ffsubsyncPath = ensureExecutablePath(resolved.ffsubsyncPath, 'ffsubsync');
result = await runFfsubsyncSync(
ffsubsyncPath,
context.videoPath,
primaryExtraction.path,
targetExtraction.path,
outputPath,
context.audioStreamIndex,
);
@@ -319,13 +400,13 @@ async function subsyncToReference(
};
}
loadSyncedSubtitle(client, outputPath);
await loadSyncedSubtitle(client, outputPath, slot);
return {
ok: true,
message: `Subtitle synchronized with ${engine}`,
};
} 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(
request: SubsyncManualRunRequest,
deps: SubsyncCoreDeps,
@@ -345,6 +445,12 @@ export async function runSubsyncManual(
const context = await gatherSubsyncContext(client);
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') {
try {
validateFfsubsyncReference(context.videoPath);
@@ -354,22 +460,64 @@ export async function runSubsyncManual(
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 (!sourceTrack) {
return { ok: false, message: 'Select a subtitle source track for alass' };
if (request.referenceMode === 'video') {
if (isRemoteMediaPath(context.videoPath)) {
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');
let sourceExtraction: FileExtractionResult | null = null;
let referenceExtraction: FileExtractionResult | null = null;
try {
sourceExtraction = await extractSubtitleTrackToFile(ffmpegPath, context.videoPath, sourceTrack);
return await subsyncToReference('alass', sourceExtraction.path, context, resolved, client);
referenceExtraction = await extractSubtitleTrackToFile(
ffmpegPath,
context.videoPath,
referenceTrack,
);
return await subsyncToReference(
'alass',
referenceExtraction.path,
targetTrack,
context,
resolved,
client,
targetSlot,
);
} finally {
if (sourceExtraction) {
cleanupTemporaryFile(sourceExtraction);
if (referenceExtraction) {
cleanupTemporaryFile(referenceExtraction);
}
}
}
@@ -377,14 +525,27 @@ export async function runSubsyncManual(
export async function openSubsyncManualPicker(deps: TriggerSubsyncFromConfigDeps): Promise<void> {
const client = getMpvClientForSubsync(deps);
const context = await gatherSubsyncContext(client);
const payload: SubsyncManualPayload = {
ffsubsyncAvailable: !isRemoteMediaPath(context.videoPath),
sourceTracks: context.sourceTracks
const subtitleTracks = context.subtitleTracks
.filter((track) => typeof track.id === 'number')
.map((track) => ({
id: track.id as number,
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);
}
@@ -397,7 +558,7 @@ export async function triggerSubsyncFromConfig(deps: TriggerSubsyncFromConfigDep
try {
await openSubsyncManualPicker(deps);
deps.showMpvOsd('Subsync: choose engine and source');
deps.showMpvOsd('Subsync: choose engine and subtitles');
} catch (error) {
deps.showMpvOsd(`Subsync failed: ${(error as Error).message}`);
} finally {
+17 -18
View File
@@ -74,7 +74,6 @@ test('prefetch service tokenizes priority window cues and caches them', async ()
preCacheTokenization: (text, data) => {
cached.set(text, data);
},
isCacheFull: () => false,
priorityWindowSize: 3,
});
@@ -91,32 +90,38 @@ test('prefetch service tokenizes priority window cues and caches them', async ()
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);
let tokenizeCalls = 0;
let cacheSize = 0;
const tokenized: string[] = [];
// 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({
cues,
tokenizeSubtitle: async (text) => {
tokenizeCalls += 1;
tokenized.push(text);
return { text, tokens: [] };
},
preCacheTokenization: () => {
cacheSize += 1;
preCacheTokenization: (text) => {
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,
});
service.start(0);
for (let i = 0; i < 30; i += 1) {
for (let i = 0; i < 60; i += 1) {
await flushMicrotasks();
}
service.stop();
// Should have stopped at 5 (cache full), not tokenized all 20
assert.ok(tokenizeCalls <= 6, `Expected <= 6 tokenize calls, got ${tokenizeCalls}`);
assert.equal(tokenized.length, 20, `Expected all 20 cues warmed, got ${tokenized.length}`);
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 () => {
@@ -130,7 +135,6 @@ test('prefetch service can be stopped mid-flight', async () => {
return { text, tokens: [] };
},
preCacheTokenization: () => {},
isCacheFull: () => false,
priorityWindowSize: 3,
});
@@ -159,7 +163,6 @@ test('prefetch service onSeek re-prioritizes from new position', async () => {
preCacheTokenization: (text) => {
cachedTexts.push(text);
},
isCacheFull: () => false,
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');
});
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 cachedTexts: string[] = [];
@@ -193,7 +196,6 @@ test('prefetch service still warms the priority window when cache is full', asyn
preCacheTokenization: (text) => {
cachedTexts.push(text);
},
isCacheFull: () => true,
priorityWindowSize: 3,
});
@@ -217,7 +219,6 @@ test('prefetch service pause/resume halts and continues tokenization', async ()
return { text, tokens: [] };
},
preCacheTokenization: () => {},
isCacheFull: () => false,
priorityWindowSize: 3,
});
@@ -255,7 +256,6 @@ test('prefetch service skips cues already present in tokenization cache', async
},
preCacheTokenization: () => {},
hasCachedTokenization: (text) => text === 'line-0' || text === 'line-1',
isCacheFull: () => false,
priorityWindowSize: 3,
});
@@ -285,7 +285,6 @@ test('prefetch service deduplicates repeated cue text within a run', async () =>
return { text, tokens: [] };
},
preCacheTokenization: () => {},
isCacheFull: () => false,
priorityWindowSize: 3,
});
+5 -7
View File
@@ -7,7 +7,6 @@ export interface SubtitlePrefetchServiceDeps {
tokenizeSubtitle: (text: string) => Promise<SubtitleData | null>;
preCacheTokenization: (text: string, data: SubtitleData) => void;
hasCachedTokenization?: (text: string) => boolean;
isCacheFull: () => boolean;
priorityWindowSize?: number;
}
@@ -57,11 +56,14 @@ export function createSubtitlePrefetchService(
let paused = false;
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(
cuesToProcess: SubtitleCue[],
runId: number,
warmedKeys: Set<string>,
options: { allowWhenCacheFull?: boolean } = {},
): Promise<void> {
for (const cue of cuesToProcess) {
if (stopped || runId !== currentRunId) {
@@ -77,10 +79,6 @@ export function createSubtitlePrefetchService(
return;
}
if (!options.allowWhenCacheFull && deps.isCacheFull()) {
return;
}
const cacheKey = normalizeSubtitleCacheKey(cue.text);
if (!cacheKey || warmedKeys.has(cacheKey) || deps.hasCachedTokenization?.(cue.text)) {
if (cacheKey) {
@@ -110,7 +108,7 @@ export function createSubtitlePrefetchService(
// Phase 1: Priority window
const priorityCues = computePriorityWindow(cues, currentTimeSeconds, windowSize);
await tokenizeCueList(priorityCues, runId, warmedKeys, { allowWhenCacheFull: true });
await tokenizeCueList(priorityCues, runId, warmedKeys);
if (stopped || runId !== currentRunId) {
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));
}
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 controller = createSubtitleProcessingController({
tokenizeSubtitle: async (text) => ({ text, tokens: [] }),
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('字幕');
await flushMicrotasks();
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 () => {
const emitted: SubtitleData[] = [];
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();
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 () => {
@@ -60,7 +184,10 @@ test('subtitle processing skips duplicate subtitle emission', async () => {
controller.onSubtitleChange('same');
await flushMicrotasks();
assert.equal(emitted.length, 1);
assert.deepEqual(emitted, [
{ text: 'same', tokens: null },
{ text: 'same', tokens: [] },
]);
assert.equal(tokenizeCalls, 1);
});
@@ -84,7 +211,9 @@ test('subtitle processing reuses cached tokenization for repeated subtitle text'
assert.equal(tokenizeCalls, 2);
assert.deepEqual(emitted, [
{ text: 'first', tokens: null },
{ text: 'first', tokens: [] },
{ text: 'second', tokens: null },
{ text: 'second', tokens: [] },
{ text: 'first', tokens: [] },
]);
@@ -100,7 +229,48 @@ test('subtitle processing falls back to plain subtitle when tokenization returns
controller.onSubtitleChange('fallback');
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 }]);
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 () => {
@@ -120,7 +290,10 @@ test('subtitle processing ignores duplicate current subtitle refresh without cac
await flushMicrotasks();
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 () => {
@@ -146,7 +319,10 @@ test('subtitle processing coalesces refresh requests while current subtitle is p
await flushMicrotasks();
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 () => {
@@ -168,6 +344,7 @@ test('subtitle processing refresh re-tokenizes after cache invalidation', async
assert.equal(tokenizeCalls, 2);
assert.deepEqual(emitted, [
{ text: 'same', tokens: null },
{ text: 'same', tokens: [{ value: 1 } 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');
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 () => {
@@ -205,10 +385,10 @@ test('subtitle processing cache invalidation only affects future subtitle events
await flushMicrotasks();
assert.equal(callsByText.get('same'), 1);
assert.equal(emitted.length, 3);
assert.equal(emitted.length, 5);
controller.invalidateTokenizationCache();
assert.equal(emitted.length, 3);
assert.equal(emitted.length, 5);
controller.onSubtitleChange('different');
await flushMicrotasks();
@@ -308,25 +488,54 @@ test('hasCachedSubtitle checks prefetched entries without consuming them', async
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({
tokenizeSubtitle: async (text) => ({ text, tokens: null }),
tokenizeSubtitle: async (text) => ({ text, tokens: [] }),
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({
tokenizeSubtitle: async (text) => ({ text, tokens: [] }),
emitSubtitle: () => {},
});
// Fill cache to the 256 limit
for (let i = 0; i < 256; i += 1) {
for (let i = 0; i < 2000; i += 1) {
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;
logDebug?: (message: string) => void;
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 {
onSubtitleChange: (text: string) => void;
refreshCurrentSubtitle: (textOverride?: string) => void;
@@ -14,7 +23,6 @@ export interface SubtitleProcessingController {
preCacheTokenization: (text: string, data: SubtitleData) => void;
consumeCachedSubtitle: (text: string) => SubtitleData | null;
hasCachedSubtitle: (text: string) => boolean;
isCacheFull: () => boolean;
}
export function normalizeSubtitleCacheKey(text: string): string {
@@ -24,9 +32,15 @@ export function normalizeSubtitleCacheKey(text: string): string {
export function createSubtitleProcessingController(
deps: SubtitleProcessingControllerDeps,
): 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 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 lastEmittedGeneration = 0;
let processing = false;
@@ -70,9 +84,12 @@ export function createSubtitleProcessingController(
const startedAtMs = now();
if (!text.trim()) {
if (lastPlainEmittedText !== text) {
deps.emitSubtitle({ text, tokens: null });
}
lastEmittedText = text;
lastEmittedGeneration = generation;
lastPlainEmittedText = null;
break;
}
@@ -82,11 +99,25 @@ export function createSubtitleProcessingController(
if (cachedTokenized) {
output = cachedTokenized;
} 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);
// 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) {
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) {
deps.logDebug?.(`Subtitle tokenization failed: ${(error as Error).message}`);
@@ -107,9 +138,16 @@ export function createSubtitleProcessingController(
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);
}
lastEmittedText = text;
lastEmittedGeneration = generation;
lastPlainEmittedText = null;
deps.logDebug?.(
`Subtitle tokenization delivered; elapsed=${now() - startedAtMs}ms, staleDrops=${staleDropCount}`,
);
@@ -136,6 +174,14 @@ export function createSubtitleProcessingController(
return;
}
latestText = text;
if (
processing &&
text !== lastPlainEmittedText &&
!tokenizationCache.has(normalizeSubtitleCacheKey(text))
) {
deps.emitSubtitle({ text, tokens: null });
lastPlainEmittedText = text;
}
processLatest();
},
refreshCurrentSubtitle: (textOverride?: string) => {
@@ -169,13 +215,11 @@ export function createSubtitleProcessingController(
latestText = text;
lastEmittedText = text;
lastEmittedGeneration = cacheGeneration;
lastPlainEmittedText = null;
return cached;
},
hasCachedSubtitle: (text: string) => {
return tokenizationCache.has(normalizeSubtitleCacheKey(text));
},
isCacheFull: () => {
return tokenizationCache.size >= SUBTITLE_TOKENIZATION_CACHE_LIMIT;
},
};
}
+35
View File
@@ -1,6 +1,7 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
isSubtitleAnnotationUpgrade,
serializeInitialSubtitleWebsocketMessage,
serializeSubtitleMarkup,
serializeSubtitleWebsocketMessage,
@@ -13,6 +14,40 @@ const frequencyOptions = {
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', () => {
const payload: SubtitleData = {
text: 'a < b\nx & y',
+14
View File
@@ -20,6 +20,20 @@ export type SubtitleWebsocketFrequencyOptions = {
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 = {
payloadMode?: SubtitleWebsocketPayloadMode;
};
+16 -2
View File
@@ -295,6 +295,7 @@ import {
importYomitanDictionaryFromZip,
initializeOverlayAnkiIntegration as initializeOverlayAnkiIntegrationCore,
initializeOverlayRuntime as initializeOverlayRuntimeCore,
isSubtitleAnnotationUpgrade,
isOverlayWindowContentReady,
jellyfinTicksToSecondsRuntime,
listJellyfinItemsRuntime,
@@ -1817,6 +1818,8 @@ function withCurrentSubtitleTiming(payload: SubtitleData): SubtitleData {
}
function emitSubtitlePayload(payload: SubtitleData): void {
const timedPayload = withCurrentSubtitleTiming(payload);
const currentSubtitleData = appState.currentSubtitleData;
const isAnnotationUpgrade = isSubtitleAnnotationUpgrade(currentSubtitleData, timedPayload);
const frequencyDictionary = configService.getConfig().subtitleStyle.frequencyDictionary;
const frequencyOptions = {
enabled: frequencyDictionary.enabled,
@@ -1825,7 +1828,9 @@ function emitSubtitlePayload(payload: SubtitleData): void {
};
appState.currentSubtitleData = timedPayload;
overlayManager.broadcastToOverlayWindows('subtitle:set', timedPayload);
if (!isAnnotationUpgrade) {
subtitleWsService.broadcast(timedPayload, frequencyOptions);
}
annotationSubtitleWsService.broadcast(timedPayload, frequencyOptions);
autoplayReadyGate.maybeSignalPluginAutoplayReady(timedPayload, { forceWhilePaused: true });
subtitlePrefetchService?.resume();
@@ -1955,7 +1960,6 @@ const subtitlePrefetchInitController = createSubtitlePrefetchInitController({
subtitleProcessingController.preCacheTokenization(text, data);
},
hasCachedTokenization: (text) => subtitleProcessingController.hasCachedSubtitle(text),
isCacheFull: () => subtitleProcessingController.isCacheFull(),
logInfo: (message) => logger.info(message),
logWarn: (message) => logger.warn(message),
onParsedSubtitleCuesChanged: (cues, sourceKey) => {
@@ -1982,15 +1986,22 @@ const resolveActiveSubtitleSidebarSourceHandler = createResolveActiveSubtitleSid
getFfmpegPath: () => configService.getConfig().subsync.ffmpeg_path.trim() || 'ffmpeg',
extractInternalSubtitleTrack: (ffmpegPath, videoPath, track) =>
extractInternalSubtitleTrackToTempFile(ffmpegPath, videoPath, track),
logDebug: (message) => logger.debug(message),
});
const refreshSubtitlePrefetchFromActiveTrackHandler =
createRefreshSubtitlePrefetchFromActiveTrackHandler({
getMpvClient: () => appState.mpvClient,
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,
resolveActiveSubtitleSidebarSource: (input) => resolveActiveSubtitleSidebarSourceHandler(input),
logDebug: (message) => logger.debug(message),
logWarn: (message) => logger.warn(message),
});
const subtitlePrefetchRuntime = {
@@ -2994,6 +3005,8 @@ const {
streamIndex,
delaySeconds,
}),
initSubtitlePrefetch: (sourcePath) =>
subtitlePrefetchRuntime.refreshSubtitleSidebarFromSource(sourcePath),
logDebug: (message, error) => {
logger.debug(message, error);
},
@@ -4366,6 +4379,7 @@ const {
refreshDiscordPresence: () => {
discordPresenceRuntime.publishDiscordPresence();
},
logSubtitleProcessingDebug: (message: string) => logger.debug(message),
ensureImmersionTrackerInitialized: () => {
ensureImmersionTrackerStarted();
},
+52
View File
@@ -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', () => {
const source = readMainSource();
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', () => {
const source = readMainSource();
const subtitleBlock = source.match(
+14 -2
View File
@@ -326,7 +326,13 @@ test('handleOverlayModalClosed hides modal window only after all pending modals
});
runtime.sendToActiveOverlayWindow(
'subsync:open-manual',
{ ffsubsyncAvailable: true, sourceTracks: [] },
{
ffsubsyncAvailable: true,
videoReferenceAvailable: true,
subtitleTracks: [],
defaultReferenceTrackId: null,
defaultTargetTrackId: null,
},
{
restoreOnModalClose: 'subsync',
},
@@ -560,7 +566,13 @@ test('modal runtime notifies callers when modal input state becomes active/inact
});
runtime.sendToActiveOverlayWindow(
'subsync:open-manual',
{ ffsubsyncAvailable: true, sourceTracks: [] },
{
ffsubsyncAvailable: true,
videoReferenceAvailable: true,
subtitleTracks: [],
defaultReferenceTrackId: null,
defaultTargetTrackId: null,
},
{
restoreOnModalClose: 'subsync',
},
@@ -4,7 +4,7 @@ import { composeIpcRuntimeHandlers } from './ipc-runtime-composer';
test('composeIpcRuntimeHandlers returns callable IPC handlers and registration bridge', async () => {
let registered = false;
let receivedSourceTrackId: number | null | undefined;
let receivedReferenceTrackId: number | null | undefined;
const composed = composeIpcRuntimeHandlers({
mpvCommandMainDeps: {
@@ -25,7 +25,7 @@ test('composeIpcRuntimeHandlers returns callable IPC handlers and registration b
},
handleMpvCommandFromIpcRuntime: () => {},
runSubsyncManualFromIpc: async (request) => {
receivedSourceTrackId = request.sourceTrackId;
receivedReferenceTrackId = request.referenceTrackId;
return {
ok: true,
message: 'ok',
@@ -124,10 +124,10 @@ test('composeIpcRuntimeHandlers returns callable IPC handlers and registration b
const result = await composed.runSubsyncManualFromIpc({
engine: 'alass',
sourceTrackId: 7,
referenceTrackId: 7,
});
assert.deepEqual(result, { ok: true, message: 'ok' });
assert.equal(receivedSourceTrackId, 7);
assert.equal(receivedReferenceTrackId, 7);
composed.registerIpcRuntimeHandlers();
assert.equal(registered, true);
@@ -11,7 +11,7 @@ import { createOverlayNotificationDelivery } from './overlay-notification-delive
test('notifyConfiguredStatus routes both to overlay and system without osd', () => {
const calls: string[] = [];
notifyConfiguredStatus('Subsync: choose engine and source', {
notifyConfiguredStatus('Subsync: choose engine and subtitles', {
getNotificationType: () => 'both',
showOsd: (message) => {
calls.push(`osd:${message}`);
@@ -25,8 +25,8 @@ test('notifyConfiguredStatus routes both to overlay and system without osd', ()
});
assert.deepEqual(calls, [
'overlay::SubMiner:Subsync: choose engine and source:info:auto',
'desktop:SubMiner:Subsync: choose engine and source',
'overlay::SubMiner:Subsync: choose engine and subtitles:info:auto',
'desktop:SubMiner:Subsync: choose engine and subtitles',
]);
});
@@ -28,6 +28,9 @@ export function createBuildPreloadJellyfinExternalSubtitlesMainDepsHandler(
? (itemId, streamIndex, delaySeconds) =>
deps.saveSubtitleDelay!(itemId, streamIndex, delaySeconds)
: undefined,
initSubtitlePrefetch: deps.initSubtitlePrefetch
? (sourcePath) => deps.initSubtitlePrefetch!(sourcePath)
: undefined,
logDebug: (message: string, error: unknown) => deps.logDebug(message, error),
});
}
@@ -40,6 +40,9 @@ function makeDeps(overrides: {
>[0]['setActiveSubtitleDelayKey'];
loadSubtitleSourceText?: (source: string) => Promise<string>;
saveSubtitleDelay?: (itemId: string, streamIndex: number, delaySeconds: number) => void;
initSubtitlePrefetch?: Parameters<
typeof createPreloadJellyfinExternalSubtitlesHandler
>[0]['initSubtitlePrefetch'];
logDebug?: Parameters<typeof createPreloadJellyfinExternalSubtitlesHandler>[0]['logDebug'];
}) {
return {
@@ -58,6 +61,7 @@ function makeDeps(overrides: {
setActiveSubtitleDelayKey: overrides.setActiveSubtitleDelayKey,
loadSubtitleSourceText: overrides.loadSubtitleSourceText,
saveSubtitleDelay: overrides.saveSubtitleDelay,
initSubtitlePrefetch: overrides.initSubtitlePrefetch,
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 () => {
const commands: Array<Array<string | number>> = [];
const preload = createPreloadJellyfinExternalSubtitlesHandler(
@@ -320,6 +320,7 @@ export function createPreloadJellyfinExternalSubtitlesHandler(deps: {
setActiveSubtitleDelayKey?: (key: JellyfinSubtitleDelayKey | null) => void;
loadSubtitleSourceText?: (source: string) => Promise<string>;
saveSubtitleDelay?: (itemId: string, streamIndex: number, delaySeconds: number) => boolean | void;
initSubtitlePrefetch?: (sourcePath: string) => void | Promise<void>;
logDebug: (message: string, error: unknown) => void;
}): PreloadJellyfinExternalSubtitlesHandler {
const activeCacheDirs = new Set<string>();
@@ -329,6 +330,18 @@ export function createPreloadJellyfinExternalSubtitlesHandler(deps: {
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 {
const dirs = [...activeCacheDirs];
if (dirs.length === 0) return;
@@ -438,6 +451,7 @@ export function createPreloadJellyfinExternalSubtitlesHandler(deps: {
}
}
deps.sendMpvCommand(['set_property', 'sid', japanesePrimaryId]);
startSubtitlePrefetchForCachedTrack(selectedCachedTrack.path);
} else {
deps.setActiveSubtitleDelayKey?.(null);
resetManagedSubtitleDelay();
@@ -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', () => {
const calls: string[] = [];
const handler = createHandleMpvSubtitleChangeHandler({
@@ -20,11 +20,15 @@ export function createHandleMpvSubtitleChangeHandler(deps: {
broadcastSubtitle: (payload: SubtitleData) => void;
onSubtitleChange: (text: string) => void;
refreshDiscordPresence: () => void;
logDebug?: (message: string) => void;
}) {
return ({ text }: { text: string }): void => {
deps.setCurrentSubText(text);
const immediatePayload = deps.getImmediateSubtitlePayload?.(text) ?? null;
if (immediatePayload) {
deps.logDebug?.(
`[subtitle-processing] emitted cached subtitle immediately (${text.length} chars)`,
);
deps.onSubtitleChange(text);
(deps.emitImmediateSubtitle ?? deps.broadcastSubtitle)(immediatePayload);
} else {
@@ -47,6 +47,7 @@ export function createBindMpvMainEventHandlersHandler(deps: {
emitImmediateSubtitle?: (payload: SubtitleData) => void;
broadcastSubtitle: (payload: SubtitleData) => void;
onSubtitleChange: (text: string) => void;
logSubtitleProcessingDebug?: (message: string) => void;
refreshDiscordPresence: () => void;
setCurrentSubAssText: (text: string) => void;
@@ -123,6 +124,9 @@ export function createBindMpvMainEventHandlersHandler(deps: {
: undefined,
broadcastSubtitle: (payload) => deps.broadcastSubtitle(payload),
onSubtitleChange: (text) => deps.onSubtitleChange(text),
logDebug: deps.logSubtitleProcessingDebug
? (message) => deps.logSubtitleProcessingDebug?.(message)
: undefined,
refreshDiscordPresence: () => deps.refreshDiscordPresence(),
});
const handleMpvSubtitleAssChange = createHandleMpvSubtitleAssChangeHandler({
@@ -54,6 +54,7 @@ export function createBuildBindMpvMainEventHandlersMainDepsHandler(deps: {
getImmediateSubtitlePayload?: (text: string) => SubtitleData | null;
emitImmediateSubtitle?: (payload: SubtitleData) => void;
onSubtitleChange: (text: string) => void;
logSubtitleProcessingDebug?: (message: string) => void;
onSubtitleTrackChange?: (sid: number | null) => void;
onSubtitleTrackListChange?: (trackList: unknown[] | null) => void;
updateCurrentMediaPath: (path: string) => void;
@@ -155,6 +156,9 @@ export function createBuildBindMpvMainEventHandlersMainDepsHandler(deps: {
broadcastSubtitle: (payload: SubtitleData) =>
deps.broadcastToOverlayWindows('subtitle:set', payload),
onSubtitleChange: (text: string) => deps.onSubtitleChange(text),
logSubtitleProcessingDebug: deps.logSubtitleProcessingDebug
? (message: string) => deps.logSubtitleProcessingDebug!(message)
: undefined,
onSubtitleTrackChange: deps.onSubtitleTrackChange
? (sid: number | null) => deps.onSubtitleTrackChange!(sid)
: undefined,
+7 -1
View File
@@ -5,7 +5,13 @@ import type { SubsyncManualPayload } from '../../types';
const payload: SubsyncManualPayload = {
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 () => {
@@ -54,7 +54,6 @@ test('latest subtitle prefetch init wins over stale async loads', async () => {
}),
tokenizeSubtitle: async () => null,
preCacheTokenization: () => {},
isCacheFull: () => false,
logInfo: () => {},
logWarn: () => {},
});
@@ -99,7 +98,6 @@ test('cancelPendingInit prevents an in-flight load from attaching a stale servic
}),
tokenizeSubtitle: async () => null,
preCacheTokenization: () => {},
isCacheFull: () => false,
logInfo: () => {},
logWarn: () => {},
});
@@ -137,7 +135,6 @@ test('subtitle prefetch init publishes parsed cues and clears them on cancel', a
}),
tokenizeSubtitle: async () => null,
preCacheTokenization: () => {},
isCacheFull: () => false,
logInfo: () => {},
logWarn: () => {},
onParsedSubtitleCuesChanged: (cues) => {
@@ -181,7 +178,6 @@ test('subtitle prefetch init publishes the provided stable source key instead of
}),
tokenizeSubtitle: async () => null,
preCacheTokenization: () => {},
isCacheFull: () => false,
logInfo: () => {},
logWarn: () => {},
onParsedSubtitleCuesChanged: (_cues, source) => {
@@ -222,7 +218,6 @@ test('subtitle prefetch init clears parsed cues when initialization fails', asyn
}),
tokenizeSubtitle: async () => null,
preCacheTokenization: () => {},
isCacheFull: () => false,
logInfo: () => {},
logWarn: () => {},
onParsedSubtitleCuesChanged: (cues) => {
@@ -234,3 +229,25 @@ test('subtitle prefetch init clears parsed cues when initialization fails', asyn
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/);
});
+3 -2
View File
@@ -14,7 +14,6 @@ export interface SubtitlePrefetchInitControllerDeps {
tokenizeSubtitle: (text: string) => Promise<SubtitleData | null>;
preCacheTokenization: (text: string, data: SubtitleData) => void;
hasCachedTokenization?: (text: string) => boolean;
isCacheFull: () => boolean;
logInfo: (message: string) => void;
logWarn: (message: string) => void;
onParsedSubtitleCuesChanged?: (cues: SubtitleCue[] | null, sourceKey: string | null) => void;
@@ -59,6 +58,9 @@ export function createSubtitlePrefetchInitController(
const cues = deps.parseSubtitleCues(content, sourcePath);
if (revision !== initRevision || cues.length === 0) {
if (revision === initRevision) {
deps.logWarn(
`[subtitle-prefetch] parsed 0 cues from ${sourcePath}; prefetch disabled for this source`,
);
deps.onParsedSubtitleCuesChanged?.(null, null);
}
return;
@@ -69,7 +71,6 @@ export function createSubtitlePrefetchInitController(
tokenizeSubtitle: (text) => deps.tokenizeSubtitle(text),
preCacheTokenization: (text, data) => deps.preCacheTokenization(text, data),
hasCachedTokenization: (text) => deps.hasCachedTokenization?.(text) ?? false,
isCacheFull: () => deps.isCacheFull(),
});
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(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/);
});
+22 -2
View File
@@ -86,6 +86,7 @@ export function createResolveActiveSubtitleSidebarSourceHandler(deps: {
videoPath: string,
track: MpvSubtitleTrackLike,
) => Promise<{ path: string; cleanup: () => Promise<void> } | null>;
logDebug?: (message: string) => void;
}) {
return async (input: {
currentExternalFilenameRaw: unknown;
@@ -104,6 +105,7 @@ export function createResolveActiveSubtitleSidebarSourceHandler(deps: {
const track = getActiveSubtitleTrack(input.currentTrackRaw, input.trackListRaw, input.sidRaw);
if (!track) {
deps.logDebug?.('[subtitle-prefetch] no active subtitle track selected yet');
return null;
}
@@ -114,6 +116,7 @@ export function createResolveActiveSubtitleSidebarSourceHandler(deps: {
}
if (isRemoteMediaPath(input.videoPath)) {
deps.logDebug?.('[subtitle-prefetch] skipping internal subtitle extraction for remote media');
return null;
}
@@ -123,6 +126,9 @@ export function createResolveActiveSubtitleSidebarSourceHandler(deps: {
track,
);
if (!extracted) {
deps.logDebug?.(
`[subtitle-prefetch] internal subtitle extraction unavailable (codec=${String(track.codec ?? 'unknown')}, ff-index=${String(track['ff-index'] ?? 'unknown')})`,
);
return null;
}
@@ -144,10 +150,13 @@ export function createRefreshSubtitlePrefetchFromActiveTrackHandler(deps: {
resolveActiveSubtitleSidebarSource: (
input: Parameters<ReturnType<typeof createResolveActiveSubtitleSidebarSourceHandler>>[0],
) => Promise<ActiveSubtitleSidebarSource | null>;
logDebug?: (message: string) => void;
logWarn?: (message: string) => void;
}) {
return async (): Promise<void> => {
const client = deps.getMpvClient();
if (!client?.connected) {
deps.logDebug?.('[subtitle-prefetch] skipped refresh: mpv client not connected');
return;
}
@@ -162,6 +171,7 @@ export function createRefreshSubtitlePrefetchFromActiveTrackHandler(deps: {
]);
const videoPath = typeof videoPathRaw === 'string' ? videoPathRaw : '';
if (!videoPath) {
deps.logDebug?.('[subtitle-prefetch] skipped refresh: no media path');
deps.subtitlePrefetchInitController.cancelPendingInit();
return;
}
@@ -175,8 +185,14 @@ export function createRefreshSubtitlePrefetchFromActiveTrackHandler(deps: {
});
if (!resolvedSource) {
if (deps.shouldKeepExistingCuesOnMissingSource?.(videoPath) === true) {
deps.logDebug?.(
'[subtitle-prefetch] no active subtitle source resolved; keeping existing cues',
);
return;
}
deps.logDebug?.(
'[subtitle-prefetch] no active subtitle source resolved; cancelling prefetch',
);
deps.subtitlePrefetchInitController.cancelPendingInit();
return;
}
@@ -190,8 +206,12 @@ export function createRefreshSubtitlePrefetchFromActiveTrackHandler(deps: {
} finally {
await resolvedSource.cleanup?.();
}
} catch {
// Skip refresh when the track query fails.
} catch (error) {
deps.logWarn?.(
`[subtitle-prefetch] failed to refresh from active track: ${
error instanceof Error ? error.message : String(error)
}`,
);
}
};
}
+7 -3
View File
@@ -359,9 +359,13 @@
ffsubsync
</label>
</div>
<label id="subsyncSourceLabel" class="subsync-field">
<span>Source Subtitle (for alass)</span>
<select id="subsyncSourceSelect"></select>
<label id="subsyncReferenceLabel" class="subsync-field">
<span>Reference (correct timing, for alass)</span>
<select id="subsyncReferenceSelect"></select>
</label>
<label id="subsyncTargetLabel" class="subsync-field">
<span>Out-of-sync Subtitle (gets retimed)</span>
<select id="subsyncTargetSelect"></select>
</label>
</div>
<div id="subsyncStatus" class="runtime-options-status"></div>
+183 -32
View File
@@ -2,6 +2,7 @@ import test from 'node:test';
import assert from 'node:assert/strict';
import { createSubsyncModal } from './subsync.js';
import type { SubsyncManualPayload, SubsyncManualRunRequest } from '../../types';
type Listener = () => void;
@@ -52,18 +53,54 @@ function createDeferred<T>() {
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 modalClassList = createClassList();
const statusClassList = createClassList();
const sourceLabelClassList = createClassList();
const referenceLabelClassList = createClassList();
const targetLabelClassList = createClassList();
const runButtonEvents = createEventTarget();
const closeButtonEvents = createEventTarget();
const engineAlassEvents = createEventTarget();
const engineFfsubsyncEvents = createEventTarget();
const sourceOptions: Array<{ value: string; textContent: string }> = [];
const runButton = {
disabled: false,
addEventListener: runButtonEvents.addEventListener,
@@ -77,6 +114,7 @@ function createTestHarness(runSubsyncManual: () => Promise<{ ok: boolean; messag
const subsyncEngineAlass = {
checked: false,
disabled: false,
addEventListener: engineAlassEvents.addEventListener,
dispatch: engineAlassEvents.dispatch,
};
@@ -88,18 +126,8 @@ function createTestHarness(runSubsyncManual: () => Promise<{ ok: boolean; messag
dispatch: engineFfsubsyncEvents.dispatch,
};
const sourceSelect = {
innerHTML: '',
value: '',
disabled: false,
appendChild: (option: { value: string; textContent: string }) => {
sourceOptions.push(option);
if (!sourceSelect.value) {
sourceSelect.value = option.value;
}
return option;
},
};
const referenceSelect = createSelectStub();
const targetSelect = createSelectStub();
let notifyClosedCalls = 0;
let notifyOpenedCalls = 0;
@@ -139,8 +167,10 @@ function createTestHarness(runSubsyncManual: () => Promise<{ ok: boolean; messag
subsyncCloseButton: closeButton,
subsyncEngineAlass,
subsyncEngineFfsubsync,
subsyncSourceLabel: { classList: sourceLabelClassList },
subsyncSourceSelect: sourceSelect,
subsyncReferenceLabel: { classList: referenceLabelClassList },
subsyncReferenceSelect: referenceSelect,
subsyncTargetLabel: { classList: targetLabelClassList },
subsyncTargetSelect: targetSelect,
subsyncRunButton: runButton,
subsyncStatus: {
textContent: '',
@@ -149,7 +179,7 @@ function createTestHarness(runSubsyncManual: () => Promise<{ ok: boolean; messag
},
state: {
subsyncModalOpen: false,
subsyncSourceTracks: [],
subsyncSubtitleTracks: [],
subsyncSubmitting: false,
isOverSubtitle: false,
},
@@ -166,6 +196,9 @@ function createTestHarness(runSubsyncManual: () => Promise<{ ok: boolean; messag
ctx,
modal,
runButton,
referenceSelect,
targetSelect,
referenceLabelClassList,
statusClassList,
getNotifyClosedCalls: () => notifyClosedCalls,
getNotifyOpenedCalls: () => notifyOpenedCalls,
@@ -187,16 +220,28 @@ async function flushMicrotasks(): Promise<void> {
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 () => {
const deferred = createDeferred<{ ok: boolean; message: string }>();
const harness = createTestHarness(async () => deferred.promise);
try {
harness.modal.wireDomEvents();
harness.modal.openSubsyncModal({
sourceTracks: [{ id: 2, label: 'External #2 - eng' }],
ffsubsyncAvailable: true,
});
harness.modal.openSubsyncModal(BASE_PAYLOAD);
harness.runButton.dispatch('click');
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.ctx.dom.subsyncRunButton.disabled, false);
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.getNotifyOpenedCalls(), 1);
} finally {
@@ -231,15 +277,17 @@ test('subsync modal disables ffsubsync when payload marks it unavailable', () =>
const harness = createTestHarness(async () => ({ ok: true, message: 'ok' }));
try {
harness.modal.openSubsyncModal({
sourceTracks: [{ id: 2, label: 'External #2 - eng' }],
ffsubsyncAvailable: false,
});
harness.modal.openSubsyncModal(
payloadWith({ ffsubsyncAvailable: false, videoReferenceAvailable: false }),
);
assert.equal(harness.ctx.dom.subsyncEngineAlass.checked, true);
assert.equal(harness.ctx.dom.subsyncEngineFfsubsync.checked, false);
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 {
harness.restoreGlobals();
}
@@ -253,10 +301,14 @@ test('subsync modal ignores enter submission when no sync engine is available',
});
try {
harness.modal.openSubsyncModal({
sourceTracks: [],
harness.modal.openSubsyncModal(
payloadWith({
subtitleTracks: [{ id: 1, label: 'External #1 - jpn (active)' }],
defaultReferenceTrackId: null,
videoReferenceAvailable: false,
ffsubsyncAvailable: false,
});
}),
);
harness.modal.handleSubsyncKeydown({
key: 'Enter',
@@ -270,3 +322,102 @@ test('subsync modal ignores enter submission when no sync engine is available',
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
View File
@@ -1,6 +1,14 @@
import type { SubsyncManualPayload } from '../../types';
import type { SubsyncManualPayload, SubsyncManualRunRequest } from '../../types';
import type { ModalStateReader, RendererContext } from '../context';
const VIDEO_REFERENCE_VALUE = 'video';
interface SubsyncSelection {
engine: 'alass' | 'ffsubsync';
referenceValue: string;
targetTrackId: number | null;
}
export function createSubsyncModal(
ctx: RendererContext,
options: {
@@ -8,27 +16,103 @@ export function createSubsyncModal(
syncSettingsModalSubtitleSuppression: () => void;
},
) {
let ffsubsyncAvailable = true;
let currentPayload: SubsyncManualPayload | null = null;
function setSubsyncStatus(message: string, isError = false): void {
ctx.dom.subsyncStatus.textContent = message;
ctx.dom.subsyncStatus.classList.toggle('error', isError);
}
function updateSubsyncSourceVisibility(): void {
const useAlass = ctx.dom.subsyncEngineAlass.checked;
ctx.dom.subsyncSourceLabel.classList.toggle('hidden', !useAlass);
function hasAlassReference(): boolean {
if (!currentPayload) return false;
return currentPayload.videoReferenceAvailable || currentPayload.subtitleTracks.length > 1;
}
function renderSubsyncSourceTracks(): void {
ctx.dom.subsyncSourceSelect.innerHTML = '';
for (const track of ctx.state.subsyncSourceTracks) {
const option = document.createElement('option');
option.value = String(track.id);
option.textContent = track.label;
ctx.dom.subsyncSourceSelect.appendChild(option);
function updateSubsyncFieldVisibility(): void {
const useAlass = ctx.dom.subsyncEngineAlass.checked;
ctx.dom.subsyncReferenceLabel.classList.toggle('hidden', !useAlass);
ctx.dom.subsyncTargetLabel.classList.toggle(
'hidden',
ctx.state.subsyncSubtitleTracks.length === 0,
);
}
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 {
@@ -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.subsyncSourceTracks = payload.sourceTracks;
ffsubsyncAvailable = payload.ffsubsyncAvailable;
ctx.state.subsyncSubtitleTracks = payload.subtitleTracks;
currentPayload = payload;
const hasSources = ctx.state.subsyncSourceTracks.length > 0;
ctx.dom.subsyncEngineAlass.checked = hasSources;
ctx.dom.subsyncEngineFfsubsync.checked = !hasSources && ffsubsyncAvailable;
ctx.dom.subsyncEngineFfsubsync.disabled = !ffsubsyncAvailable;
ctx.dom.subsyncRunButton.disabled = !hasSources && !ffsubsyncAvailable;
const alassReady = hasAlassReference();
const useAlass = selection ? selection.engine === 'alass' && alassReady : alassReady;
ctx.dom.subsyncEngineAlass.checked = useAlass;
ctx.dom.subsyncEngineFfsubsync.checked = !useAlass && payload.ffsubsyncAvailable;
ctx.dom.subsyncEngineAlass.disabled = !alassReady;
ctx.dom.subsyncEngineFfsubsync.disabled = !payload.ffsubsyncAvailable;
ctx.dom.subsyncRunButton.disabled = !alassReady && !payload.ffsubsyncAvailable;
renderSubsyncSourceTracks();
updateSubsyncSourceVisibility();
setSubsyncStatus(
!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,
renderTargetTracks(
selection
? (selection.targetTrackId ?? payload.defaultTargetTrackId)
: payload.defaultTargetTrackId,
);
renderReferenceTracks(selection?.referenceValue ?? null);
updateSubsyncFieldVisibility();
setSubsyncStatus(describeSubsyncState(), false);
ctx.state.subsyncModalOpen = true;
options.syncSettingsModalSubtitleSuppression();
@@ -80,25 +161,11 @@ export function createSubsyncModal(
}
function reopenSubsyncModalWithError(
sourceTracks: SubsyncManualPayload['sourceTracks'],
engine: 'alass' | 'ffsubsync',
sourceTrackId: number | null,
payload: SubsyncManualPayload,
selection: SubsyncSelection,
message: string,
): void {
openSubsyncModal({ sourceTracks, ffsubsyncAvailable });
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();
openSubsyncModal(payload, selection);
setSubsyncStatus(message, true);
window.electronAPI.notifyOverlayModalOpened('subsync');
}
@@ -106,6 +173,7 @@ export function createSubsyncModal(
async function runSubsyncManualFromModal(): Promise<void> {
if (ctx.state.subsyncSubmitting) return;
if (ctx.dom.subsyncRunButton.disabled) return;
if (!currentPayload) return;
const useAlass = ctx.dom.subsyncEngineAlass.checked;
const useFfsubsync = ctx.dom.subsyncEngineFfsubsync.checked;
@@ -115,33 +183,46 @@ export function createSubsyncModal(
}
const engine = useAlass ? 'alass' : 'ffsubsync';
const sourceTrackId =
engine === 'alass' && ctx.dom.subsyncSourceSelect.value
? Number.parseInt(ctx.dom.subsyncSourceSelect.value, 10)
: null;
const referenceValue = ctx.dom.subsyncReferenceSelect.value;
const targetTrackId = getSelectedTargetTrackId();
if (engine === 'alass' && !Number.isFinite(sourceTrackId)) {
setSubsyncStatus('Select a source subtitle track for alass.', true);
if (targetTrackId === null) {
setSubsyncStatus('Select the out-of-sync subtitle track to retime.', true);
return;
}
if (engine === 'alass' && !referenceValue) {
setSubsyncStatus('Select a reference for alass.', true);
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.dom.subsyncRunButton.disabled = true;
closeSubsyncModal();
try {
const result = await window.electronAPI.runSubsyncManual({
engine,
sourceTrackId,
});
const result = await window.electronAPI.runSubsyncManual(request);
if (result.ok) return;
reopenSubsyncModalWithError(sourceTracksSnapshot, engine, sourceTrackId, result.message);
reopenSubsyncModalWithError(payloadSnapshot, selection, result.message);
} catch (error) {
reopenSubsyncModalWithError(
sourceTracksSnapshot,
engine,
sourceTrackId,
payloadSnapshot,
selection,
`Subsync failed: ${(error as Error).message}`,
);
} finally {
@@ -171,10 +252,13 @@ export function createSubsyncModal(
closeSubsyncModal();
});
ctx.dom.subsyncEngineAlass.addEventListener('change', () => {
updateSubsyncSourceVisibility();
updateSubsyncFieldVisibility();
});
ctx.dom.subsyncEngineFfsubsync.addEventListener('change', () => {
updateSubsyncSourceVisibility();
updateSubsyncFieldVisibility();
});
ctx.dom.subsyncTargetSelect.addEventListener('change', () => {
renderReferenceTracks(ctx.dom.subsyncReferenceSelect.value || null);
});
ctx.dom.subsyncRunButton.addEventListener('click', () => {
void runSubsyncManualFromModal();
+3 -3
View File
@@ -18,7 +18,7 @@ import type {
SubtitlePosition,
SubtitleSidebarSnapshotConfig,
SubtitleCue,
SubsyncSourceTrack,
SubsyncSubtitleTrack,
YoutubePickerOpenPayload,
} from '../types';
@@ -85,7 +85,7 @@ export type RendererState = {
characterDictionaryStatus: string;
subsyncModalOpen: boolean;
subsyncSourceTracks: SubsyncSourceTrack[];
subsyncSubtitleTracks: SubsyncSubtitleTrack[];
subsyncSubmitting: boolean;
controllerSelectModalOpen: boolean;
@@ -213,7 +213,7 @@ export function createRendererState(): RendererState {
characterDictionaryStatus: '',
subsyncModalOpen: false,
subsyncSourceTracks: [],
subsyncSubtitleTracks: [],
subsyncSubmitting: false,
controllerSelectModalOpen: false,
+8 -4
View File
@@ -90,8 +90,10 @@ export type RendererDom = {
subsyncCloseButton: HTMLButtonElement;
subsyncEngineAlass: HTMLInputElement;
subsyncEngineFfsubsync: HTMLInputElement;
subsyncSourceLabel: HTMLLabelElement;
subsyncSourceSelect: HTMLSelectElement;
subsyncReferenceLabel: HTMLLabelElement;
subsyncReferenceSelect: HTMLSelectElement;
subsyncTargetLabel: HTMLLabelElement;
subsyncTargetSelect: HTMLSelectElement;
subsyncRunButton: HTMLButtonElement;
subsyncStatus: HTMLDivElement;
@@ -255,8 +257,10 @@ export function resolveRendererDom(): RendererDom {
subsyncCloseButton: getRequiredElement<HTMLButtonElement>('subsyncClose'),
subsyncEngineAlass: getRequiredElement<HTMLInputElement>('subsyncEngineAlass'),
subsyncEngineFfsubsync: getRequiredElement<HTMLInputElement>('subsyncEngineFfsubsync'),
subsyncSourceLabel: getRequiredElement<HTMLLabelElement>('subsyncSourceLabel'),
subsyncSourceSelect: getRequiredElement<HTMLSelectElement>('subsyncSourceSelect'),
subsyncReferenceLabel: getRequiredElement<HTMLLabelElement>('subsyncReferenceLabel'),
subsyncReferenceSelect: getRequiredElement<HTMLSelectElement>('subsyncReferenceSelect'),
subsyncTargetLabel: getRequiredElement<HTMLLabelElement>('subsyncTargetLabel'),
subsyncTargetSelect: getRequiredElement<HTMLSelectElement>('subsyncTargetSelect'),
subsyncRunButton: getRequiredElement<HTMLButtonElement>('subsyncRun'),
subsyncStatus: getRequiredElement<HTMLDivElement>('subsyncStatus'),
+16 -3
View File
@@ -295,14 +295,27 @@ export function parseControllerConfigUpdate(value: unknown): ControllerConfigUpd
export function parseSubsyncManualRunRequest(value: unknown): SubsyncManualRunRequest | null {
if (!isObject(value)) return null;
const { engine, sourceTrackId } = value;
const { engine, referenceMode, referenceTrackId, targetTrackId } = value;
if (engine !== 'alass' && engine !== 'ffsubsync') return null;
if (sourceTrackId !== undefined && sourceTrackId !== null && !isInteger(sourceTrackId)) {
if (referenceMode !== undefined && referenceMode !== 'track' && referenceMode !== 'video') {
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 {
engine,
sourceTrackId: sourceTrackId === undefined ? undefined : (sourceTrackId as number | null),
referenceMode,
referenceTrackId: parsedReferenceTrackId,
targetTrackId: parsedTargetTrackId,
};
}
+2 -1
View File
@@ -33,7 +33,8 @@ export interface SubsyncContext {
videoPath: string;
primaryTrack: MpvTrack;
secondaryTrack: MpvTrack | null;
sourceTracks: MpvTrack[];
/** Every usable subtitle track, including the primary one. */
subtitleTracks: MpvTrack[];
audioStreamIndex: number | null;
}
+12 -3
View File
@@ -78,19 +78,28 @@ export interface MpvClient {
send(command: { command: unknown[]; request_id?: number }): boolean;
}
export interface SubsyncSourceTrack {
export interface SubsyncSubtitleTrack {
id: number;
label: string;
}
export interface SubsyncManualPayload {
sourceTracks: SubsyncSourceTrack[];
subtitleTracks: SubsyncSubtitleTrack[];
defaultReferenceTrackId: number | null;
defaultTargetTrackId: number | null;
videoReferenceAvailable: boolean;
ffsubsyncAvailable: boolean;
}
export type SubsyncReferenceMode = 'track' | 'video';
export interface SubsyncManualRunRequest {
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 {