refactor: make subsync manual-only, default opt-in features off, preserv

- Remove subsync.defaultMode; subsync always opens manual picker
- Default jellyfinRemoteSession warmup and nameMatchEnabled to false
- Stop rewriting config file during legacy migration (resolve in-memory only)
- Fix macOS quit on window-close for --setup launch mode
This commit is contained in:
2026-05-20 21:37:08 -07:00
parent 02a5d95542
commit 525cb7e1fd
35 changed files with 195 additions and 241 deletions
+1 -1
View File
@@ -92,7 +92,7 @@ Browse sibling episode files and the active mpv queue in one overlay modal. Open
</tr> </tr>
<tr> <tr>
<td><b>alass / ffsubsync</b></td> <td><b>alass / ffsubsync</b></td>
<td>Automatic subtitle retiming — requires <code>alass</code> or <code>ffsubsync</code> on your <code>PATH</code> (optional; subtitle syncing is disabled without them)</td> <td>Manual subtitle retiming — requires <code>alass</code> or <code>ffsubsync</code> on your <code>PATH</code> (optional; subtitle syncing is disabled without them)</td>
</tr> </tr>
<tr> <tr>
<td><b>WebSocket</b></td> <td><b>WebSocket</b></td>
@@ -0,0 +1,4 @@
type: fixed
area: config
- Preserved user config files during legacy config compatibility handling.
@@ -0,0 +1,4 @@
type: changed
area: config
- Defaulted Jellyfin remote-session startup warmup and character-name subtitle highlighting to off.
+4
View File
@@ -0,0 +1,4 @@
type: changed
area: subtitles
- Subsync now always opens the manual picker and the `subsync.defaultMode` config/settings option has been removed.
+3 -4
View File
@@ -155,7 +155,7 @@
"mecab": true, // Warm up MeCab tokenizer at startup. Values: true | false "mecab": true, // Warm up MeCab tokenizer at startup. Values: true | false
"yomitanExtension": true, // Warm up Yomitan extension at startup. Values: true | false "yomitanExtension": true, // Warm up Yomitan extension at startup. Values: true | false
"subtitleDictionaries": true, // Warm up subtitle dictionaries at startup. Values: true | false "subtitleDictionaries": true, // Warm up subtitle dictionaries at startup. Values: true | false
"jellyfinRemoteSession": true // Warm up Jellyfin remote session at startup. Values: true | false "jellyfinRemoteSession": false // Warm up Jellyfin remote session at startup. Values: true | false
}, // Background warmup controls for MeCab, Yomitan, dictionaries, and Jellyfin session. }, // Background warmup controls for MeCab, Yomitan, dictionaries, and Jellyfin session.
// ========================================== // ==========================================
@@ -336,12 +336,11 @@
}, // Dual subtitle track options. }, // Dual subtitle track options.
// ========================================== // ==========================================
// Auto Subtitle Sync // Subtitle Sync
// Subsync engine and executable paths. // Subsync engine and executable paths.
// Hot-reload: subsync changes apply to the next subtitle sync run. // Hot-reload: subsync changes apply to the next subtitle sync run.
// ========================================== // ==========================================
"subsync": { "subsync": {
"defaultMode": "auto", // Subsync default mode. Values: auto | manual
"alass_path": "", // Optional absolute path to the alass binary used by subsync. Leave empty to auto-discover from PATH. "alass_path": "", // Optional absolute path to the alass binary used by subsync. Leave empty to auto-discover from PATH.
"ffsubsync_path": "", // Optional absolute path to the ffsubsync binary used by subsync. Leave empty to auto-discover from PATH. "ffsubsync_path": "", // Optional absolute path to the ffsubsync binary used by subsync. Leave empty to auto-discover from PATH.
"ffmpeg_path": "", // Optional absolute path to the ffmpeg binary used by subsync. Leave empty to auto-discover from PATH. "ffmpeg_path": "", // Optional absolute path to the ffmpeg binary used by subsync. Leave empty to auto-discover from PATH.
@@ -384,7 +383,7 @@
"preserveLineBreaks": false, // Preserve line breaks in visible overlay subtitle rendering. When false, line breaks are flattened to spaces for a single-line flow. Values: true | false "preserveLineBreaks": false, // Preserve line breaks in visible overlay subtitle rendering. When false, line breaks are flattened to spaces for a single-line flow. Values: true | false
"autoPauseVideoOnHover": true, // Automatically pause mpv playback while hovering subtitle text, then resume on leave. Values: true | false "autoPauseVideoOnHover": true, // Automatically pause mpv playback while hovering subtitle text, then resume on leave. Values: true | false
"autoPauseVideoOnYomitanPopup": true, // Automatically pause mpv playback while Yomitan popup is open, then resume when popup closes. Values: true | false "autoPauseVideoOnYomitanPopup": true, // Automatically pause mpv playback while Yomitan popup is open, then resume when popup closes. Values: true | false
"nameMatchEnabled": true, // Enable subtitle token coloring for matches from the SubMiner character dictionary. Values: true | false "nameMatchEnabled": false, // Enable subtitle token coloring for matches from the SubMiner character dictionary. Values: true | false
"nameMatchColor": "#f5bde6", // Hex color used when a subtitle token matches an entry from the SubMiner character dictionary. "nameMatchColor": "#f5bde6", // Hex color used when a subtitle token matches an entry from the SubMiner character dictionary.
"nPlusOneColor": "#c6a0f6", // Color used for the single N+1 target token subtitle highlight. "nPlusOneColor": "#c6a0f6", // Color used for the single N+1 target token subtitle highlight.
"knownWordColor": "#a6da95", // Color used for known-word subtitle highlights. "knownWordColor": "#a6da95", // Color used for known-word subtitle highlights.
+3 -3
View File
@@ -88,7 +88,7 @@ Name matching runs inside Yomitan's scanning pipeline during subtitle tokenizati
1. Yomitan receives subtitle text and scans for dictionary matches. 1. Yomitan receives subtitle text and scans for dictionary matches.
2. Entries from "SubMiner Character Dictionary" are checked with exact primary-source matching — the token must match the entry's `originalText` with `isPrimary: true` and `matchType: 'exact'`. 2. Entries from "SubMiner Character Dictionary" are checked with exact primary-source matching — the token must match the entry's `originalText` with `isPrimary: true` and `matchType: 'exact'`.
3. Matched tokens are flagged `isNameMatch: true` and forwarded to the renderer. 3. Matched tokens are flagged `isNameMatch: true` and forwarded to the renderer.
4. The renderer applies the name-match highlight color (default: `#f5bde6`). 4. If `subtitleStyle.nameMatchEnabled` is enabled, the renderer applies the name-match highlight color (default: `#f5bde6`).
Name matches are visually distinct from [N+1 targeting, frequency highlighting, and JLPT tags](/subtitle-annotations) so you can tell at a glance whether a highlighted word is a character name or a vocabulary target. Name matches are visually distinct from [N+1 targeting, frequency highlighting, and JLPT tags](/subtitle-annotations) so you can tell at a glance whether a highlighted word is a character name or a vocabulary target.
@@ -96,7 +96,7 @@ Name matches are visually distinct from [N+1 targeting, frequency highlighting,
| Option | Default | Description | | Option | Default | Description |
| -------------------------------- | --------- | ---------------------------------- | | -------------------------------- | --------- | ---------------------------------- |
| `subtitleStyle.nameMatchEnabled` | `true` | Toggle character-name highlighting | | `subtitleStyle.nameMatchEnabled` | `false` | Toggle character-name highlighting |
| `subtitleStyle.nameMatchColor` | `#f5bde6` | Highlight color for matched names | | `subtitleStyle.nameMatchColor` | `#f5bde6` | Highlight color for matched names |
## Dictionary Entries ## Dictionary Entries
@@ -228,7 +228,7 @@ merged.zip
| `anilist.characterDictionary.collapsibleSections.description` | `false` | Start Description section expanded | | `anilist.characterDictionary.collapsibleSections.description` | `false` | Start Description section expanded |
| `anilist.characterDictionary.collapsibleSections.characterInformation` | `false` | Start Character Information section expanded | | `anilist.characterDictionary.collapsibleSections.characterInformation` | `false` | Start Character Information section expanded |
| `anilist.characterDictionary.collapsibleSections.voicedBy` | `false` | Start Voiced By section expanded | | `anilist.characterDictionary.collapsibleSections.voicedBy` | `false` | Start Voiced By section expanded |
| `subtitleStyle.nameMatchEnabled` | `true` | Toggle character-name highlighting in subtitles | | `subtitleStyle.nameMatchEnabled` | `false` | Toggle character-name highlighting in subtitles |
| `subtitleStyle.nameMatchColor` | `#f5bde6` | Highlight color for character-name matches | | `subtitleStyle.nameMatchColor` | `#f5bde6` | Highlight color for character-name matches |
## Reference Implementation ## Reference Implementation
+7 -9
View File
@@ -171,7 +171,7 @@ The configuration file includes several main sections:
**External Integrations** **External Integrations**
- [**Jimaku**](#jimaku) - Jimaku API configuration and defaults - [**Jimaku**](#jimaku) - Jimaku API configuration and defaults
- [**Auto Subtitle Sync**](#auto-subtitle-sync) - Sync current subtitle with `alass`/`ffsubsync` - [**Subtitle Sync**](#subtitle-sync) - Sync current subtitle with `alass`/`ffsubsync`
- [**AniList**](#anilist) - Optional post-watch progress updates - [**AniList**](#anilist) - Optional post-watch progress updates
- [**Yomitan**](#yomitan) - Reuse an external read-only Yomitan profile via `yomitan.externalProfilePath` - [**Yomitan**](#yomitan) - Reuse an external read-only Yomitan profile via `yomitan.externalProfilePath`
- [**Jellyfin**](#jellyfin) - Optional Jellyfin auth, library listing, and playback launch - [**Jellyfin**](#jellyfin) - Optional Jellyfin auth, library listing, and playback launch
@@ -258,7 +258,7 @@ Control which startup warmups run in the background versus deferring to first re
"mecab": true, "mecab": true,
"yomitanExtension": true, "yomitanExtension": true,
"subtitleDictionaries": true, "subtitleDictionaries": true,
"jellyfinRemoteSession": true "jellyfinRemoteSession": false
} }
} }
``` ```
@@ -271,7 +271,7 @@ Control which startup warmups run in the background versus deferring to first re
| `subtitleDictionaries` | `true`, `false` | Warm up JLPT + frequency dictionaries at startup | | `subtitleDictionaries` | `true`, `false` | Warm up JLPT + frequency dictionaries at startup |
| `jellyfinRemoteSession` | `true`, `false` | Warm up Jellyfin remote session at startup (still requires Jellyfin remote auto-connect settings) | | `jellyfinRemoteSession` | `true`, `false` | Warm up Jellyfin remote session at startup (still requires Jellyfin remote auto-connect settings) |
Defaults warm everything (`true` for all toggles, `lowPowerMode: false`). Setting a warmup toggle to `false` defers that work until first usage. Defaults warm local tokenizer/dictionary work (`true` for `mecab`, `yomitanExtension`, and `subtitleDictionaries`) with `lowPowerMode: false`; Jellyfin remote session warmup is opt-in (`false` by default). Setting a warmup toggle to `false` defers that work until first usage.
### WebSocket Server ### WebSocket Server
@@ -391,7 +391,7 @@ See `config.example.jsonc` for detailed configuration options.
| `autoPauseVideoOnYomitanPopup` | boolean | Pause playback while the Yomitan popup is open, then resume when the popup closes (`true` by default). | | `autoPauseVideoOnYomitanPopup` | boolean | Pause playback while the Yomitan popup is open, then resume when the popup closes (`true` by default). |
| `hoverTokenColor` | string | Hex color used for hovered subtitle token highlight in mpv (default: catppuccin mauve) | | `hoverTokenColor` | string | Hex color used for hovered subtitle token highlight in mpv (default: catppuccin mauve) |
| `hoverTokenBackgroundColor` | string | CSS color used for hovered subtitle token background highlight (default: `"transparent"`); `hoverBackground` is accepted as an alias | | `hoverTokenBackgroundColor` | string | CSS color used for hovered subtitle token background highlight (default: `"transparent"`); `hoverBackground` is accepted as an alias |
| `nameMatchEnabled` | boolean | Enable subtitle token coloring for matches from the SubMiner character dictionary (`true` by default) | | `nameMatchEnabled` | boolean | Enable subtitle token coloring for matches from the SubMiner character dictionary (`false` by default) |
| `nameMatchColor` | string | Hex color used for subtitle tokens matched from the SubMiner character dictionary (default: `#f5bde6`) | | `nameMatchColor` | string | Hex color used for subtitle tokens matched from the SubMiner character dictionary (default: `#f5bde6`) |
| `knownWordColor` | string | Hex color used for known-word subtitle highlights (default: `#a6da95`) | | `knownWordColor` | string | Hex color used for known-word subtitle highlights (default: `#a6da95`) |
| `nPlusOneColor` | string | Hex color used for the single N+1 target subtitle highlight (default: `#c6a0f6`) | | `nPlusOneColor` | string | Hex color used for the single N+1 target subtitle highlight (default: `#c6a0f6`) |
@@ -1111,17 +1111,16 @@ Jimaku is rate limited; if you hit a limit, SubMiner will surface the retry dela
Set `openBrowser` to `false` to only print the URL without opening a browser. Set `openBrowser` to `false` to only print the URL without opening a browser.
### Auto Subtitle Sync ### Subtitle Sync
Sync the active subtitle track using `alass` (preferred) or `ffsubsync`. Both are **optional external tools** that must be installed separately and available on your `PATH` (or configured via the path options below). Subtitle syncing is silently skipped if neither is found. 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).
- [`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 a secondary subtitle as reference
- [`ffsubsync`](https://github.com/smacke/ffsubsync) — audio-based sync using the video file as reference (fallback) - [`ffsubsync`](https://github.com/smacke/ffsubsync) — audio-based sync using the video file as reference
```json ```json
{ {
"subsync": { "subsync": {
"defaultMode": "auto",
"alass_path": "", "alass_path": "",
"ffsubsync_path": "", "ffsubsync_path": "",
"ffmpeg_path": "", "ffmpeg_path": "",
@@ -1132,7 +1131,6 @@ Sync the active subtitle track using `alass` (preferred) or `ffsubsync`. Both ar
| Option | Values | Description | | Option | Values | Description |
| ---------------- | -------------------- | ------------------------------------------------------------------------------------------------------------------------- | | ---------------- | -------------------- | ------------------------------------------------------------------------------------------------------------------------- |
| `defaultMode` | `"auto"`, `"manual"` | `auto`: try `alass` against secondary subtitle, then fallback to `ffsubsync`; `manual`: open overlay picker |
| `alass_path` | string path | Path to `alass` executable. Empty or `null` resolves from `PATH`. `alass` must be installed separately. | | `alass_path` | string path | Path to `alass` executable. Empty or `null` resolves from `PATH`. `alass` must be installed separately. |
| `ffsubsync_path` | string path | Path to `ffsubsync` executable. Empty or `null` resolves from `PATH`. `ffsubsync` must be installed separately. | | `ffsubsync_path` | string path | Path to `ffsubsync` executable. Empty or `null` resolves from `PATH`. `ffsubsync` must be installed separately. |
| `ffmpeg_path` | string path | Path to `ffmpeg` (used for internal subtitle extraction). Empty or `null` falls back to `/usr/bin/ffmpeg`. | | `ffmpeg_path` | string path | Path to `ffmpeg` (used for internal subtitle extraction). Empty or `null` falls back to `/usr/bin/ffmpeg`. |
+1 -1
View File
@@ -25,7 +25,7 @@ Mine vocabulary cards from Yomitan or directly from subtitle lines. SubMiner aut
## Subtitle Download & Sync ## Subtitle Download & Sync
Search and download subtitles from Jimaku, then automatically synchronize them with alass or ffsubsync — all from within SubMiner. Search and download subtitles from Jimaku, then retime them with alass or ffsubsync — all from within SubMiner.
<!-- <video controls playsinline preload="metadata" :poster="withBase(`/assets/demos/subtitle-sync-poster.jpg?v=${v}`)"> <!-- <video controls playsinline preload="metadata" :poster="withBase(`/assets/demos/subtitle-sync-poster.jpg?v=${v}`)">
<source :src="withBase(`/assets/demos/subtitle-sync.webm?v=${v}`)" type="video/webm" /> <source :src="withBase(`/assets/demos/subtitle-sync.webm?v=${v}`)" type="video/webm" />
+1 -1
View File
@@ -66,7 +66,7 @@ features:
src: /assets/subtitle-download.svg src: /assets/subtitle-download.svg
alt: Subtitle download icon alt: Subtitle download icon
title: Subtitle Download & Sync title: Subtitle Download & Sync
details: Search and pull subtitles from Jimaku, then auto-sync timing with alass or ffsubsync — all from the overlay. details: Search and pull subtitles from Jimaku, then retime subtitles with alass or ffsubsync — all from the overlay.
link: /jimaku-integration link: /jimaku-integration
linkText: Jimaku integration linkText: Jimaku integration
- icon: - icon:
+1 -1
View File
@@ -22,7 +22,7 @@ Only **mpv** is strictly required to run SubMiner. Everything else enhances the
| ffmpegthumbnailer | Optional | Video thumbnail generation for the picker. | | ffmpegthumbnailer | Optional | Video thumbnail generation for the picker. |
| guessit | Optional | Better AniSkip title/season/episode parsing. | | guessit | Optional | Better AniSkip title/season/episode parsing. |
| alass | Optional | Subtitle sync engine (preferred). Disabled without alass or ffsubsync. | | alass | Optional | Subtitle sync engine (preferred). Disabled without alass or ffsubsync. |
| ffsubsync | Optional | Subtitle sync engine (fallback). Disabled without alass or ffsubsync. | | ffsubsync | Optional | Audio-based subtitle sync engine. Disabled without alass or ffsubsync. |
| fuse2 | Linux only | Required to run the AppImage. | | fuse2 | Linux only | Required to run the AppImage. |
### Linux ### Linux
+3 -4
View File
@@ -155,7 +155,7 @@
"mecab": true, // Warm up MeCab tokenizer at startup. Values: true | false "mecab": true, // Warm up MeCab tokenizer at startup. Values: true | false
"yomitanExtension": true, // Warm up Yomitan extension at startup. Values: true | false "yomitanExtension": true, // Warm up Yomitan extension at startup. Values: true | false
"subtitleDictionaries": true, // Warm up subtitle dictionaries at startup. Values: true | false "subtitleDictionaries": true, // Warm up subtitle dictionaries at startup. Values: true | false
"jellyfinRemoteSession": true // Warm up Jellyfin remote session at startup. Values: true | false "jellyfinRemoteSession": false // Warm up Jellyfin remote session at startup. Values: true | false
}, // Background warmup controls for MeCab, Yomitan, dictionaries, and Jellyfin session. }, // Background warmup controls for MeCab, Yomitan, dictionaries, and Jellyfin session.
// ========================================== // ==========================================
@@ -336,12 +336,11 @@
}, // Dual subtitle track options. }, // Dual subtitle track options.
// ========================================== // ==========================================
// Auto Subtitle Sync // Subtitle Sync
// Subsync engine and executable paths. // Subsync engine and executable paths.
// Hot-reload: subsync changes apply to the next subtitle sync run. // Hot-reload: subsync changes apply to the next subtitle sync run.
// ========================================== // ==========================================
"subsync": { "subsync": {
"defaultMode": "auto", // Subsync default mode. Values: auto | manual
"alass_path": "", // Optional absolute path to the alass binary used by subsync. Leave empty to auto-discover from PATH. "alass_path": "", // Optional absolute path to the alass binary used by subsync. Leave empty to auto-discover from PATH.
"ffsubsync_path": "", // Optional absolute path to the ffsubsync binary used by subsync. Leave empty to auto-discover from PATH. "ffsubsync_path": "", // Optional absolute path to the ffsubsync binary used by subsync. Leave empty to auto-discover from PATH.
"ffmpeg_path": "", // Optional absolute path to the ffmpeg binary used by subsync. Leave empty to auto-discover from PATH. "ffmpeg_path": "", // Optional absolute path to the ffmpeg binary used by subsync. Leave empty to auto-discover from PATH.
@@ -384,7 +383,7 @@
"preserveLineBreaks": false, // Preserve line breaks in visible overlay subtitle rendering. When false, line breaks are flattened to spaces for a single-line flow. Values: true | false "preserveLineBreaks": false, // Preserve line breaks in visible overlay subtitle rendering. When false, line breaks are flattened to spaces for a single-line flow. Values: true | false
"autoPauseVideoOnHover": true, // Automatically pause mpv playback while hovering subtitle text, then resume on leave. Values: true | false "autoPauseVideoOnHover": true, // Automatically pause mpv playback while hovering subtitle text, then resume on leave. Values: true | false
"autoPauseVideoOnYomitanPopup": true, // Automatically pause mpv playback while Yomitan popup is open, then resume when popup closes. Values: true | false "autoPauseVideoOnYomitanPopup": true, // Automatically pause mpv playback while Yomitan popup is open, then resume when popup closes. Values: true | false
"nameMatchEnabled": true, // Enable subtitle token coloring for matches from the SubMiner character dictionary. Values: true | false "nameMatchEnabled": false, // Enable subtitle token coloring for matches from the SubMiner character dictionary. Values: true | false
"nameMatchColor": "#f5bde6", // Hex color used when a subtitle token matches an entry from the SubMiner character dictionary. "nameMatchColor": "#f5bde6", // Hex color used when a subtitle token matches an entry from the SubMiner character dictionary.
"nPlusOneColor": "#c6a0f6", // Color used for the single N+1 target token subtitle highlight. "nPlusOneColor": "#c6a0f6", // Color used for the single N+1 target token subtitle highlight.
"knownWordColor": "#a6da95", // Color used for known-word subtitle highlights. "knownWordColor": "#a6da95", // Color used for known-word subtitle highlights.
+1 -1
View File
@@ -49,7 +49,7 @@ Character-name matches are built from the active merged SubMiner character dicti
| Option | Default | Description | | Option | Default | Description |
| -------------------------------- | --------- | ---------------------------------------- | | -------------------------------- | --------- | ---------------------------------------- |
| `subtitleStyle.nameMatchEnabled` | `true` | Enable character-name token highlighting | | `subtitleStyle.nameMatchEnabled` | `false` | Enable character-name token highlighting |
| `subtitleStyle.nameMatchColor` | `#f5bde6` | Color used for character-name matches | | `subtitleStyle.nameMatchColor` | `#f5bde6` | Color used for character-name matches |
For full details on dictionary generation, name variant expansion, auto-sync lifecycle, and configuration, see the dedicated [Character Dictionary](/character-dictionary) page. For full details on dictionary generation, name variant expansion, auto-sync lifecycle, and configuration, see the dedicated [Character Dictionary](/character-dictionary) page.
+1 -1
View File
@@ -296,7 +296,7 @@ Install ffsubsync or configure the path:
**"Subtitle synchronization failed"** **"Subtitle synchronization failed"**
SubMiner tries alass first, then falls back to ffsubsync. If both fail: If subtitle sync fails:
- Ensure the reference subtitle track exists in the video (alass requires a source track). - Ensure the reference subtitle track exists in the video (alass requires a source track).
- Check that `ffmpeg` is available (used to extract the internal subtitle track). - Check that `ffmpeg` is available (used to extract the internal subtitle track).
+24 -99
View File
@@ -89,7 +89,7 @@ test('loads defaults when config is missing', () => {
assert.equal(config.startupWarmups.mecab, true); assert.equal(config.startupWarmups.mecab, true);
assert.equal(config.startupWarmups.yomitanExtension, true); assert.equal(config.startupWarmups.yomitanExtension, true);
assert.equal(config.startupWarmups.subtitleDictionaries, true); assert.equal(config.startupWarmups.subtitleDictionaries, true);
assert.equal(config.startupWarmups.jellyfinRemoteSession, true); assert.equal(config.startupWarmups.jellyfinRemoteSession, false);
assert.equal(config.shortcuts.markAudioCard, 'CommandOrControl+Shift+A'); assert.equal(config.shortcuts.markAudioCard, 'CommandOrControl+Shift+A');
assert.equal(config.shortcuts.openCharacterDictionary, 'CommandOrControl+Alt+A'); assert.equal(config.shortcuts.openCharacterDictionary, 'CommandOrControl+Alt+A');
assert.equal(config.shortcuts.toggleSubtitleSidebar, 'Backslash'); assert.equal(config.shortcuts.toggleSubtitleSidebar, 'Backslash');
@@ -222,12 +222,10 @@ test('throws actionable startup parse error for malformed config at construction
); );
}); });
test('migrates legacy subtitle appearance options into css declaration objects on load', () => { test('resolves legacy subtitle appearance options without rewriting config on load', () => {
const dir = makeTempDir(); const dir = makeTempDir();
const configPath = path.join(dir, 'config.jsonc'); const configPath = path.join(dir, 'config.jsonc');
fs.writeFileSync( const originalContent = `{
configPath,
`{
"subtitleStyle": { "subtitleStyle": {
"fontSize": 42, "fontSize": 42,
"fontColor": "#ffffff", "fontColor": "#ffffff",
@@ -251,63 +249,29 @@ test('migrates legacy subtitle appearance options into css declaration objects o
"font-size": "19px" "font-size": "19px"
} }
} }
}`, }`;
'utf-8', fs.writeFileSync(configPath, originalContent, 'utf-8');
);
const service = new ConfigService(dir); const service = new ConfigService(dir);
const parsed = parseConfigContent(configPath, fs.readFileSync(configPath, 'utf-8')) as { assert.equal(fs.readFileSync(configPath, 'utf-8'), originalContent);
subtitleStyle: {
fontSize?: unknown;
fontColor?: unknown;
hoverTokenColor?: unknown;
hoverTokenBackgroundColor?: unknown;
css?: Record<string, string>;
secondary?: {
fontSize?: unknown;
fontColor?: unknown;
css?: Record<string, string>;
};
};
subtitleSidebar: {
fontFamily?: unknown;
fontSize?: unknown;
textColor?: unknown;
timestampColor?: unknown;
css?: Record<string, string>;
};
};
assert.deepEqual(parsed.subtitleStyle.css, { assert.deepEqual(service.getConfig().subtitleStyle.css, {
color: '#ffffff', color: '#ffffff',
'font-size': '44px', 'font-size': '44px',
'--subtitle-hover-token-color': '#abcdef', '--subtitle-hover-token-color': '#abcdef',
'--subtitle-hover-token-background-color': 'transparent', '--subtitle-hover-token-background-color': 'transparent',
'text-wrap': 'balance', 'text-wrap': 'balance',
}); });
assert.equal(Object.hasOwn(parsed.subtitleStyle, 'fontSize'), false); assert.deepEqual(service.getConfig().subtitleStyle.secondary.css, {
assert.equal(Object.hasOwn(parsed.subtitleStyle, 'fontColor'), false);
assert.equal(Object.hasOwn(parsed.subtitleStyle, 'hoverTokenColor'), false);
assert.equal(Object.hasOwn(parsed.subtitleStyle, 'hoverTokenBackgroundColor'), false);
assert.deepEqual(parsed.subtitleStyle.secondary?.css, {
color: '#bbbbbb', color: '#bbbbbb',
'font-size': '28px', 'font-size': '28px',
}); });
assert.equal(Object.hasOwn(parsed.subtitleStyle.secondary ?? {}, 'fontSize'), false); assert.deepEqual(service.getConfig().subtitleSidebar.css, {
assert.equal(Object.hasOwn(parsed.subtitleStyle.secondary ?? {}, 'fontColor'), false);
assert.deepEqual(parsed.subtitleSidebar.css, {
'font-family': 'M PLUS 1, sans-serif', 'font-family': 'M PLUS 1, sans-serif',
color: '#dddddd', color: '#dddddd',
'font-size': '19px', 'font-size': '19px',
'--subtitle-sidebar-timestamp-color': '#aaaaaa', '--subtitle-sidebar-timestamp-color': '#aaaaaa',
}); });
assert.equal(Object.hasOwn(parsed.subtitleSidebar, 'fontFamily'), false);
assert.equal(Object.hasOwn(parsed.subtitleSidebar, 'fontSize'), false);
assert.equal(Object.hasOwn(parsed.subtitleSidebar, 'textColor'), false);
assert.equal(Object.hasOwn(parsed.subtitleSidebar, 'timestampColor'), false);
assert.equal(service.getConfig().subtitleStyle.css['font-size'], '44px');
assert.equal(service.getConfig().subtitleStyle.secondary.css['font-size'], '28px');
assert.equal(service.getConfig().subtitleSidebar.css['font-size'], '19px');
}); });
test('parses subtitleStyle.preserveLineBreaks and warns on invalid values', () => { test('parses subtitleStyle.preserveLineBreaks and warns on invalid values', () => {
@@ -2067,12 +2031,10 @@ test('ignores invalid legacy ankiConnect n+1 color value after migration attempt
assert.ok(warnings.some((warning) => warning.path === 'ankiConnect.knownWords.color')); assert.ok(warnings.some((warning) => warning.path === 'ankiConnect.knownWords.color'));
}); });
test('migrates legacy ankiConnect n+1 color value to subtitleStyle', () => { test('resolves legacy ankiConnect n+1 color value without rewriting config', () => {
const dir = makeTempDir(); const dir = makeTempDir();
const configPath = path.join(dir, 'config.jsonc'); const configPath = path.join(dir, 'config.jsonc');
fs.writeFileSync( const originalContent = `{
configPath,
`{
"ankiConnect": { "ankiConnect": {
"nPlusOne": { "nPlusOne": {
"nPlusOne": "#c6a0f6" "nPlusOne": "#c6a0f6"
@@ -2081,23 +2043,15 @@ test('migrates legacy ankiConnect n+1 color value to subtitleStyle', () => {
"color": "#a6da95" "color": "#a6da95"
} }
} }
}`, }`;
'utf-8', fs.writeFileSync(configPath, originalContent, 'utf-8');
);
const service = new ConfigService(dir); const service = new ConfigService(dir);
const config = service.getConfig(); const config = service.getConfig();
assert.equal(config.subtitleStyle.nPlusOneColor, '#c6a0f6'); assert.equal(config.subtitleStyle.nPlusOneColor, '#c6a0f6');
assert.equal(config.subtitleStyle.knownWordColor, '#a6da95'); assert.equal(config.subtitleStyle.knownWordColor, '#a6da95');
assert.equal(fs.readFileSync(configPath, 'utf-8'), originalContent);
const parsed = parseConfigContent(configPath, fs.readFileSync(configPath, 'utf-8')) as {
ankiConnect: { nPlusOne?: Record<string, unknown> };
subtitleStyle: { nPlusOneColor?: string; knownWordColor?: string };
};
assert.equal(parsed.subtitleStyle.nPlusOneColor, '#c6a0f6');
assert.equal(parsed.subtitleStyle.knownWordColor, '#a6da95');
assert.equal(Object.hasOwn(parsed.ankiConnect.nPlusOne ?? {}, 'nPlusOne'), false);
}); });
test('legacy migration failures are logged and rethrown', () => { test('legacy migration failures are logged and rethrown', () => {
@@ -2110,12 +2064,10 @@ test('legacy migration failures are logged and rethrown', () => {
assert.match(catchBlock, /throw error;/); assert.match(catchBlock, /throw error;/);
}); });
test('migrates legacy ankiConnect nPlusOne known-word settings to knownWords', () => { test('resolves legacy ankiConnect nPlusOne known-word settings without rewriting config', () => {
const dir = makeTempDir(); const dir = makeTempDir();
const configPath = path.join(dir, 'config.jsonc'); const configPath = path.join(dir, 'config.jsonc');
fs.writeFileSync( const originalContent = `{
configPath,
`{
"ankiConnect": { "ankiConnect": {
"nPlusOne": { "nPlusOne": {
"highlightEnabled": true, "highlightEnabled": true,
@@ -2125,20 +2077,12 @@ test('migrates legacy ankiConnect nPlusOne known-word settings to knownWords', (
"knownWord": "#a6da95" "knownWord": "#a6da95"
} }
} }
}`, }`;
'utf-8', fs.writeFileSync(configPath, originalContent, 'utf-8');
);
const service = new ConfigService(dir); const service = new ConfigService(dir);
const config = service.getConfig(); const config = service.getConfig();
const warnings = service.getWarnings(); const warnings = service.getWarnings();
const parsed = parseConfigContent(configPath, fs.readFileSync(configPath, 'utf-8')) as {
ankiConnect: {
knownWords: Record<string, unknown>;
nPlusOne?: Record<string, unknown>;
};
subtitleStyle: { knownWordColor?: string };
};
assert.equal(config.ankiConnect.knownWords.highlightEnabled, true); assert.equal(config.ankiConnect.knownWords.highlightEnabled, true);
assert.equal(config.ankiConnect.nPlusOne.enabled, true); assert.equal(config.ankiConnect.nPlusOne.enabled, true);
@@ -2149,28 +2093,14 @@ test('migrates legacy ankiConnect nPlusOne known-word settings to knownWords', (
'Kaishi 1.5k': ['Expression', 'Word', 'Reading', 'Word Reading'], 'Kaishi 1.5k': ['Expression', 'Word', 'Reading', 'Word Reading'],
}); });
assert.equal(config.subtitleStyle.knownWordColor, '#a6da95'); assert.equal(config.subtitleStyle.knownWordColor, '#a6da95');
assert.equal(parsed.ankiConnect.knownWords.highlightEnabled, true); assert.equal(fs.readFileSync(configPath, 'utf-8'), originalContent);
assert.equal(parsed.ankiConnect.knownWords.refreshMinutes, 90);
assert.equal(parsed.ankiConnect.knownWords.matchMode, 'surface');
assert.deepEqual(parsed.ankiConnect.knownWords.decks, {
Mining: ['Expression', 'Word', 'Reading', 'Word Reading'],
'Kaishi 1.5k': ['Expression', 'Word', 'Reading', 'Word Reading'],
});
assert.equal(parsed.subtitleStyle.knownWordColor, '#a6da95');
assert.ok(
['highlightEnabled', 'refreshMinutes', 'matchMode', 'decks', 'knownWord'].every(
(key) => !Object.hasOwn(parsed.ankiConnect.nPlusOne ?? {}, key),
),
);
assert.ok(warnings.every((warning) => !warning.path.startsWith('ankiConnect.nPlusOne.'))); assert.ok(warnings.every((warning) => !warning.path.startsWith('ankiConnect.nPlusOne.')));
}); });
test('migrates duplicate ankiConnect nPlusOne objects to the modal path', () => { test('resolves duplicate ankiConnect nPlusOne objects without rewriting config', () => {
const dir = makeTempDir(); const dir = makeTempDir();
const configPath = path.join(dir, 'config.jsonc'); const configPath = path.join(dir, 'config.jsonc');
fs.writeFileSync( const originalContent = `{
configPath,
`{
"ankiConnect": { "ankiConnect": {
"nPlusOne": { "nPlusOne": {
"enabled": true, "enabled": true,
@@ -2183,19 +2113,14 @@ test('migrates duplicate ankiConnect nPlusOne objects to the modal path', () =>
"minSentenceWords": "3" "minSentenceWords": "3"
} }
} }
}`, }`;
'utf-8', fs.writeFileSync(configPath, originalContent, 'utf-8');
);
const service = new ConfigService(dir); const service = new ConfigService(dir);
const config = service.getConfig(); const config = service.getConfig();
const parsed = parseConfigContent(configPath, fs.readFileSync(configPath, 'utf-8')) as {
ankiConnect: { nPlusOne: Record<string, unknown> };
};
assert.equal(config.ankiConnect.nPlusOne.enabled, true); assert.equal(config.ankiConnect.nPlusOne.enabled, true);
assert.equal(parsed.ankiConnect.nPlusOne.enabled, true); assert.equal(fs.readFileSync(configPath, 'utf-8'), originalContent);
assert.equal(parsed.ankiConnect.nPlusOne.minSentenceWords, '3');
}); });
test('supports legacy ankiConnect.behavior N+1 settings as fallback', () => { test('supports legacy ankiConnect.behavior N+1 settings as fallback', () => {
+1 -2
View File
@@ -105,7 +105,6 @@ export const CORE_DEFAULT_CONFIG: Pick<
primarySubLanguages: ['ja', 'jpn'], primarySubLanguages: ['ja', 'jpn'],
}, },
subsync: { subsync: {
defaultMode: 'auto',
alass_path: '', alass_path: '',
ffsubsync_path: '', ffsubsync_path: '',
ffmpeg_path: '', ffmpeg_path: '',
@@ -116,7 +115,7 @@ export const CORE_DEFAULT_CONFIG: Pick<
mecab: true, mecab: true,
yomitanExtension: true, yomitanExtension: true,
subtitleDictionaries: true, subtitleDictionaries: true,
jellyfinRemoteSession: true, jellyfinRemoteSession: false,
}, },
updates: { updates: {
enabled: true, enabled: true,
+1 -1
View File
@@ -10,7 +10,7 @@ export const SUBTITLE_DEFAULT_CONFIG: Pick<ResolvedConfig, 'subtitleStyle' | 'su
autoPauseVideoOnYomitanPopup: true, autoPauseVideoOnYomitanPopup: true,
hoverTokenColor: '#f4dbd6', hoverTokenColor: '#f4dbd6',
hoverTokenBackgroundColor: 'transparent', hoverTokenBackgroundColor: 'transparent',
nameMatchEnabled: true, nameMatchEnabled: false,
nameMatchColor: '#f5bde6', nameMatchColor: '#f5bde6',
fontFamily: 'Hiragino Sans, M PLUS 1, Source Han Sans JP, Noto Sans CJK JP', fontFamily: 'Hiragino Sans, M PLUS 1, Source Han Sans JP, Noto Sans CJK JP',
fontSize: 35, fontSize: 35,
-7
View File
@@ -388,13 +388,6 @@ export function buildCoreConfigOptionRegistry(
defaultValue: defaultConfig.annotationWebsocket.port, defaultValue: defaultConfig.annotationWebsocket.port,
description: 'Annotated subtitle websocket server port.', description: 'Annotated subtitle websocket server port.',
}, },
{
path: 'subsync.defaultMode',
kind: 'enum',
enumValues: ['auto', 'manual'],
defaultValue: defaultConfig.subsync.defaultMode,
description: 'Subsync default mode.',
},
{ {
path: 'subsync.replace', path: 'subsync.replace',
kind: 'boolean', kind: 'boolean',
+1 -1
View File
@@ -90,7 +90,7 @@ const CORE_TEMPLATE_SECTIONS: ConfigTemplateSection[] = [
key: 'secondarySub', key: 'secondarySub',
}, },
{ {
title: 'Auto Subtitle Sync', title: 'Subtitle Sync',
description: ['Subsync engine and executable paths.'], description: ['Subsync engine and executable paths.'],
notes: ['Hot-reload: subsync changes apply to the next subtitle sync run.'], notes: ['Hot-reload: subsync changes apply to the next subtitle sync run.'],
key: 'subsync', key: 'subsync',
-7
View File
@@ -273,13 +273,6 @@ export function applyCoreDomainConfig(context: ResolveContext): void {
} }
if (isObject(src.subsync)) { if (isObject(src.subsync)) {
const mode = src.subsync.defaultMode;
if (mode === 'auto' || mode === 'manual') {
resolved.subsync.defaultMode = mode;
} else if (mode !== undefined) {
warn('subsync.defaultMode', mode, resolved.subsync.defaultMode, 'Expected auto or manual.');
}
const alass = asString(src.subsync.alass_path); const alass = asString(src.subsync.alass_path);
if (alass !== undefined) resolved.subsync.alass_path = alass; if (alass !== undefined) resolved.subsync.alass_path = alass;
const ffsubsync = asString(src.subsync.ffsubsync_path); const ffsubsync = asString(src.subsync.ffsubsync_path);
+1 -1
View File
@@ -162,7 +162,7 @@ test('subtitleStyle nameMatchEnabled falls back on invalid value', () => {
applySubtitleDomainConfig(context); applySubtitleDomainConfig(context);
assert.equal(context.resolved.subtitleStyle.nameMatchEnabled, true); assert.equal(context.resolved.subtitleStyle.nameMatchEnabled, false);
assert.ok( assert.ok(
warnings.some( warnings.some(
(warning) => (warning) =>
-1
View File
@@ -149,7 +149,6 @@ export class ConfigService {
if (!migrated) { if (!migrated) {
return rawConfig; return rawConfig;
} }
fs.writeFileSync(configPath, content, 'utf-8');
return rawConfig; return rawConfig;
} catch (error) { } catch (error) {
console.error(`[ConfigService] legacy config migration failed for ${configPath}:`, error); console.error(`[ConfigService] legacy config migration failed for ${configPath}:`, error);
+8 -3
View File
@@ -194,8 +194,8 @@ test('settings registry exposes css declaration editor for subtitle sidebar appe
test('settings registry routes playback-related integrations into integrations', () => { test('settings registry routes playback-related integrations into integrations', () => {
assert.equal(field('jimaku.apiBaseUrl').category, 'integrations'); assert.equal(field('jimaku.apiBaseUrl').category, 'integrations');
assert.equal(field('jimaku.apiBaseUrl').section, 'Jimaku'); assert.equal(field('jimaku.apiBaseUrl').section, 'Jimaku');
assert.equal(field('subsync.defaultMode').category, 'integrations'); assert.equal(field('subsync.replace').category, 'integrations');
assert.equal(field('subsync.defaultMode').section, 'Subtitle Sync'); assert.equal(field('subsync.replace').section, 'Subtitle Sync');
}); });
test('settings registry puts feature toggles first, then other toggles alphabetically', () => { test('settings registry puts feature toggles first, then other toggles alphabetically', () => {
@@ -258,7 +258,7 @@ test('settings registry marks safe live config paths as hot-reloadable', () => {
'jimaku.apiBaseUrl', 'jimaku.apiBaseUrl',
'jimaku.languagePreference', 'jimaku.languagePreference',
'jimaku.maxEntryResults', 'jimaku.maxEntryResults',
'subsync.defaultMode', 'subsync.replace',
'ankiConnect.behavior.autoUpdateNewCards', 'ankiConnect.behavior.autoUpdateNewCards',
'ankiConnect.knownWords.highlightEnabled', 'ankiConnect.knownWords.highlightEnabled',
'ankiConnect.knownWords.refreshMinutes', 'ankiConnect.knownWords.refreshMinutes',
@@ -279,6 +279,11 @@ test('settings registry marks safe live config paths as hot-reloadable', () => {
} }
}); });
test('settings registry does not expose removed subsync mode option', () => {
const paths = new Set(fields.map((candidate) => candidate.configPath));
assert.equal(paths.has('subsync.defaultMode'), false);
});
test('settings registry keeps unsafe config siblings restart-required', () => { test('settings registry keeps unsafe config siblings restart-required', () => {
for (const path of [ for (const path of [
'stats.serverPort', 'stats.serverPort',
+19
View File
@@ -242,3 +242,22 @@ test('startAppLifecycle quits macOS config-only launch when all windows close',
handler(); handler();
assert.deepEqual(calls, ['quitApp']); assert.deepEqual(calls, ['quitApp']);
}); });
test('startAppLifecycle quits macOS setup-only launch when all windows close', () => {
let windowAllClosedHandler: (() => void) | null = null;
const { deps, calls } = createDeps({
shouldStartApp: () => true,
isDarwinPlatform: () => true,
shouldQuitOnWindowAllClosed: () => true,
onWindowAllClosed: (handler) => {
windowAllClosedHandler = handler;
},
});
startAppLifecycle(makeArgs({ setup: true }), deps);
const handler = windowAllClosedHandler as (() => void) | null;
assert.ok(handler);
handler();
assert.deepEqual(calls, ['quitApp']);
});
+1 -1
View File
@@ -166,7 +166,7 @@ export function startAppLifecycle(initialArgs: CliArgs, deps: AppLifecycleServic
deps.onWindowAllClosed(() => { deps.onWindowAllClosed(() => {
if ( if (
deps.shouldQuitOnWindowAllClosed() && deps.shouldQuitOnWindowAllClosed() &&
(!deps.isDarwinPlatform() || initialArgs.settings) (!deps.isDarwinPlatform() || initialArgs.settings || initialArgs.setup)
) { ) {
deps.quitApp(); deps.quitApp();
} }
+2 -2
View File
@@ -27,7 +27,7 @@ test('classifyConfigHotReloadDiff treats safe nested config paths as hot-reloada
next.logging.level = 'debug'; next.logging.level = 'debug';
next.youtube.primarySubLanguages = ['ja', 'en']; next.youtube.primarySubLanguages = ['ja', 'en'];
next.jimaku.maxEntryResults = prev.jimaku.maxEntryResults + 1; next.jimaku.maxEntryResults = prev.jimaku.maxEntryResults + 1;
next.subsync.defaultMode = prev.subsync.defaultMode === 'auto' ? 'manual' : 'auto'; next.subsync.replace = !prev.subsync.replace;
next.ankiConnect.behavior.autoUpdateNewCards = !prev.ankiConnect.behavior.autoUpdateNewCards; next.ankiConnect.behavior.autoUpdateNewCards = !prev.ankiConnect.behavior.autoUpdateNewCards;
next.ankiConnect.knownWords.highlightEnabled = !prev.ankiConnect.knownWords.highlightEnabled; next.ankiConnect.knownWords.highlightEnabled = !prev.ankiConnect.knownWords.highlightEnabled;
next.ankiConnect.knownWords.refreshMinutes = prev.ankiConnect.knownWords.refreshMinutes + 5; next.ankiConnect.knownWords.refreshMinutes = prev.ankiConnect.knownWords.refreshMinutes + 5;
@@ -58,7 +58,7 @@ test('classifyConfigHotReloadDiff treats safe nested config paths as hot-reloada
'logging.level', 'logging.level',
'youtube.primarySubLanguages', 'youtube.primarySubLanguages',
'jimaku.maxEntryResults', 'jimaku.maxEntryResults',
'subsync.defaultMode', 'subsync.replace',
'ankiConnect.behavior.autoUpdateNewCards', 'ankiConnect.behavior.autoUpdateNewCards',
'ankiConnect.knownWords.highlightEnabled', 'ankiConnect.knownWords.highlightEnabled',
'ankiConnect.knownWords.refreshMinutes', 'ankiConnect.knownWords.refreshMinutes',
+34 -14
View File
@@ -41,7 +41,6 @@ function makeDeps(
return { return {
getMpvClient: () => mpvClient, getMpvClient: () => mpvClient,
getResolvedConfig: () => ({ getResolvedConfig: () => ({
defaultMode: 'manual',
alassPath: '/usr/bin/alass', alassPath: '/usr/bin/alass',
ffsubsyncPath: '/usr/bin/ffsubsync', ffsubsyncPath: '/usr/bin/ffsubsync',
ffmpegPath: '/usr/bin/ffmpeg', ffmpegPath: '/usr/bin/ffmpeg',
@@ -68,7 +67,7 @@ test('triggerSubsyncFromConfig returns early when already in progress', async ()
assert.deepEqual(osd, ['Subsync already running']); assert.deepEqual(osd, ['Subsync already running']);
}); });
test('triggerSubsyncFromConfig opens manual picker in manual mode', async () => { test('triggerSubsyncFromConfig opens manual picker', async () => {
const osd: string[] = []; const osd: string[] = [];
let payloadTrackCount = 0; let payloadTrackCount = 0;
let inProgressState: boolean | null = null; let inProgressState: boolean | null = null;
@@ -92,6 +91,31 @@ test('triggerSubsyncFromConfig opens manual picker in manual mode', async () =>
assert.equal(inProgressState, false); assert.equal(inProgressState, false);
}); });
test('triggerSubsyncFromConfig does not run automatic sync', async () => {
const osd: string[] = [];
let payloadTrackCount = 0;
let spinnerRan = false;
await triggerSubsyncFromConfig(
makeDeps({
openManualPicker: (payload) => {
payloadTrackCount = payload.sourceTracks.length;
},
showMpvOsd: (text) => {
osd.push(text);
},
runWithSubsyncSpinner: async <T>(task: () => Promise<T>) => {
spinnerRan = true;
return task();
},
}),
);
assert.equal(payloadTrackCount, 1);
assert.equal(spinnerRan, false);
assert.deepEqual(osd, ['Subsync: choose engine and source']);
});
test('triggerSubsyncFromConfig dedupes repeated subtitle source tracks', async () => { test('triggerSubsyncFromConfig dedupes repeated subtitle source tracks', async () => {
let payloadTrackCount = 0; let payloadTrackCount = 0;
@@ -161,14 +185,14 @@ test('runSubsyncManual requires a source track for alass', async () => {
}); });
}); });
test('triggerSubsyncFromConfig reports path validation failures', async () => { test('triggerSubsyncFromConfig does not validate sync tool paths before manual selection', async () => {
const osd: string[] = []; const osd: string[] = [];
const inProgress: boolean[] = []; const inProgress: boolean[] = [];
let payloadTrackCount = 0;
await triggerSubsyncFromConfig( await triggerSubsyncFromConfig(
makeDeps({ makeDeps({
getResolvedConfig: () => ({ getResolvedConfig: () => ({
defaultMode: 'auto',
alassPath: '/missing/alass', alassPath: '/missing/alass',
ffsubsyncPath: '/missing/ffsubsync', ffsubsyncPath: '/missing/ffsubsync',
ffmpegPath: '/missing/ffmpeg', ffmpegPath: '/missing/ffmpeg',
@@ -176,16 +200,18 @@ test('triggerSubsyncFromConfig reports path validation failures', async () => {
setSubsyncInProgress: (value) => { setSubsyncInProgress: (value) => {
inProgress.push(value); inProgress.push(value);
}, },
openManualPicker: (payload) => {
payloadTrackCount = payload.sourceTracks.length;
},
showMpvOsd: (text) => { showMpvOsd: (text) => {
osd.push(text); osd.push(text);
}, },
}), }),
); );
assert.deepEqual(inProgress, [true, false]); assert.deepEqual(inProgress, [false]);
assert.ok( assert.equal(payloadTrackCount, 1);
osd.some((line) => line.startsWith('Subsync failed: Configured ffmpeg executable not found')), assert.deepEqual(osd, ['Subsync: choose engine and source']);
);
}); });
function writeExecutableScript(filePath: string, content: string): void { function writeExecutableScript(filePath: string, content: string): void {
@@ -260,7 +286,6 @@ test('runSubsyncManual constructs ffsubsync command and returns success', async
}, },
}), }),
getResolvedConfig: () => ({ getResolvedConfig: () => ({
defaultMode: 'manual',
alassPath, alassPath,
ffsubsyncPath, ffsubsyncPath,
ffmpegPath, ffmpegPath,
@@ -326,7 +351,6 @@ test('runSubsyncManual writes deterministic _retimed filename when replace is fa
}, },
}), }),
getResolvedConfig: () => ({ getResolvedConfig: () => ({
defaultMode: 'manual',
alassPath, alassPath,
ffsubsyncPath, ffsubsyncPath,
ffmpegPath, ffmpegPath,
@@ -382,7 +406,6 @@ test('runSubsyncManual reports ffsubsync command failures with details', async (
}, },
}), }),
getResolvedConfig: () => ({ getResolvedConfig: () => ({
defaultMode: 'manual',
alassPath, alassPath,
ffsubsyncPath, ffsubsyncPath,
ffmpegPath, ffmpegPath,
@@ -448,7 +471,6 @@ test('runSubsyncManual constructs alass command and returns failure on non-zero
}, },
}), }),
getResolvedConfig: () => ({ getResolvedConfig: () => ({
defaultMode: 'manual',
alassPath, alassPath,
ffsubsyncPath, ffsubsyncPath,
ffmpegPath, ffmpegPath,
@@ -520,7 +542,6 @@ test('runSubsyncManual keeps internal alass source file alive until sync finishe
}, },
}), }),
getResolvedConfig: () => ({ getResolvedConfig: () => ({
defaultMode: 'manual',
alassPath, alassPath,
ffsubsyncPath, ffsubsyncPath,
ffmpegPath, ffmpegPath,
@@ -577,7 +598,6 @@ test('runSubsyncManual resolves string sid values from mpv stream properties', a
}, },
}), }),
getResolvedConfig: () => ({ getResolvedConfig: () => ({
defaultMode: 'manual',
alassPath, alassPath,
ffsubsyncPath, ffsubsyncPath,
ffmpegPath, ffmpegPath,
+2 -64
View File
@@ -15,9 +15,6 @@ import {
SubsyncResolvedConfig, SubsyncResolvedConfig,
} from '../../subsync/utils'; } from '../../subsync/utils';
import { isRemoteMediaPath } from '../../jimaku/utils'; import { isRemoteMediaPath } from '../../jimaku/utils';
import { createLogger } from '../../logger';
const logger = createLogger('main:subsync');
interface FileExtractionResult { interface FileExtractionResult {
path: string; path: string;
@@ -340,57 +337,6 @@ function validateFfsubsyncReference(videoPath: string): void {
} }
} }
async function runSubsyncAutoInternal(deps: SubsyncCoreDeps): Promise<SubsyncResult> {
const client = getMpvClientForSubsync(deps);
const context = await gatherSubsyncContext(client);
const resolved = deps.getResolvedConfig();
const ffmpegPath = ensureExecutablePath(resolved.ffmpegPath, 'ffmpeg');
if (context.secondaryTrack) {
let secondaryExtraction: FileExtractionResult | null = null;
try {
secondaryExtraction = await extractSubtitleTrackToFile(
ffmpegPath,
context.videoPath,
context.secondaryTrack,
);
const alassResult = await subsyncToReference(
'alass',
secondaryExtraction.path,
context,
resolved,
client,
);
if (alassResult.ok) {
return alassResult;
}
} catch (error) {
logger.warn('Auto alass sync failed, trying ffsubsync fallback:', error);
} finally {
if (secondaryExtraction) {
cleanupTemporaryFile(secondaryExtraction);
}
}
}
const ffsubsyncPath = ensureExecutablePath(resolved.ffsubsyncPath, 'ffsubsync');
if (!ffsubsyncPath) {
return {
ok: false,
message: 'No secondary subtitle for alass and ffsubsync not configured',
};
}
try {
validateFfsubsyncReference(context.videoPath);
} catch (error) {
return {
ok: false,
message: `ffsubsync synchronization failed: ${(error as Error).message}`,
};
}
return subsyncToReference('ffsubsync', context.videoPath, context, resolved, client);
}
export async function runSubsyncManual( export async function runSubsyncManual(
request: SubsyncManualRunRequest, request: SubsyncManualRunRequest,
deps: SubsyncCoreDeps, deps: SubsyncCoreDeps,
@@ -448,17 +394,9 @@ export async function triggerSubsyncFromConfig(deps: TriggerSubsyncFromConfigDep
return; return;
} }
const resolved = deps.getResolvedConfig();
try { try {
if (resolved.defaultMode === 'manual') { await openSubsyncManualPicker(deps);
await openSubsyncManualPicker(deps); deps.showMpvOsd('Subsync: choose engine and source');
deps.showMpvOsd('Subsync: choose engine and source');
return;
}
deps.setSubsyncInProgress(true);
const result = await deps.runWithSubsyncSpinner(() => runSubsyncAutoInternal(deps));
deps.showMpvOsd(result.message);
} catch (error) { } catch (error) {
deps.showMpvOsd(`Subsync failed: ${(error as Error).message}`); deps.showMpvOsd(`Subsync failed: ${(error as Error).message}`);
} finally { } finally {
@@ -73,6 +73,52 @@ test('update dialog presenter does not focus app or yield before showing non-mac
assert.deepEqual(calls, ['dialog:SubMiner is up to date (v0.14.0)']); assert.deepEqual(calls, ['dialog:SubMiner is up to date (v0.14.0)']);
}); });
test('update dialog presenter still shows macOS dialog when focus fails', async () => {
const calls: string[] = [];
const showMessageBox: ShowMessageBox = async (options) => {
calls.push(`dialog:${options.message}`);
return { response: 0 };
};
const presenter = createUpdateDialogPresenter({
platform: 'darwin',
focusApp: () => {
calls.push('focus');
throw new Error('focus failed');
},
yieldToRunLoop: async () => {
calls.push('yield');
},
showMessageBox,
});
await presenter.showNoUpdateDialog('0.14.0');
assert.deepEqual(calls, ['focus', 'dialog:SubMiner is up to date (v0.14.0)']);
});
test('update dialog presenter still shows macOS dialog when yielding fails', async () => {
const calls: string[] = [];
const showMessageBox: ShowMessageBox = async (options) => {
calls.push(`dialog:${options.message}`);
return { response: 0 };
};
const presenter = createUpdateDialogPresenter({
platform: 'darwin',
focusApp: () => {
calls.push('focus');
},
yieldToRunLoop: async () => {
calls.push('yield');
throw new Error('yield failed');
},
showMessageBox,
});
await presenter.showNoUpdateDialog('0.14.0');
assert.deepEqual(calls, ['focus', 'yield', 'dialog:SubMiner is up to date (v0.14.0)']);
});
test('manual update required dialog explains that automatic install is unavailable', async () => { test('manual update required dialog explains that automatic install is unavailable', async () => {
let shown: let shown:
| { | {
+5 -1
View File
@@ -46,7 +46,11 @@ async function maybeFocusAppForDialog(deps: UpdateDialogPresenterDeps): Promise<
export function createUpdateDialogPresenter(deps: UpdateDialogPresenterDeps) { export function createUpdateDialogPresenter(deps: UpdateDialogPresenterDeps) {
const showFocusedMessageBox: ShowMessageBox = async (options) => { const showFocusedMessageBox: ShowMessageBox = async (options) => {
await maybeFocusAppForDialog(deps); try {
await maybeFocusAppForDialog(deps);
} catch {
// Best-effort focus only; never block the dialog itself.
}
return deps.showMessageBox(options); return deps.showMessageBox(options);
}; };
+1 -1
View File
@@ -214,7 +214,7 @@
<div id="subsyncModal" class="modal hidden" aria-hidden="true"> <div id="subsyncModal" class="modal hidden" aria-hidden="true">
<div class="modal-content subsync-modal-content"> <div class="modal-content subsync-modal-content">
<div class="modal-header"> <div class="modal-header">
<div class="modal-title">Auto Subtitle Sync</div> <div class="modal-title">Subtitle Sync</div>
<button id="subsyncClose" class="modal-close" type="button">Close</button> <button id="subsyncClose" class="modal-close" type="button">Close</button>
</div> </div>
<div class="modal-body"> <div class="modal-body">
+1 -1
View File
@@ -215,7 +215,7 @@ export function createRendererState(): RendererState {
knownWordColor: '#a6da95', knownWordColor: '#a6da95',
nPlusOneColor: '#c6a0f6', nPlusOneColor: '#c6a0f6',
nameMatchEnabled: true, nameMatchEnabled: false,
nameMatchColor: '#f5bde6', nameMatchColor: '#f5bde6',
jlptN1Color: '#ed8796', jlptN1Color: '#ed8796',
jlptN2Color: '#f5a97f', jlptN2Color: '#f5a97f',
+11 -1
View File
@@ -258,7 +258,7 @@ test('computeWordClass preserves known and n+1 classes while adding JLPT classes
assert.equal(computeWordClass(nPlusOneJlpt), 'word word-n-plus-one word-jlpt-n2'); assert.equal(computeWordClass(nPlusOneJlpt), 'word word-n-plus-one word-jlpt-n2');
}); });
test('computeWordClass applies name-match class ahead of known, n+1, frequency, and JLPT classes', () => { test('computeWordClass applies name-match class ahead of known, n+1, frequency, and JLPT classes when enabled', () => {
const token = createToken({ const token = createToken({
isKnown: true, isKnown: true,
isNPlusOneTarget: true, isNPlusOneTarget: true,
@@ -270,6 +270,7 @@ test('computeWordClass applies name-match class ahead of known, n+1, frequency,
assert.equal( assert.equal(
computeWordClass(token, { computeWordClass(token, {
nameMatchEnabled: true,
enabled: true, enabled: true,
topX: 100, topX: 100,
mode: 'single', mode: 'single',
@@ -280,6 +281,15 @@ test('computeWordClass applies name-match class ahead of known, n+1, frequency,
); );
}); });
test('computeWordClass skips name-match class by default', () => {
const token = createToken({
surface: 'アクア',
}) as MergedToken & { isNameMatch?: boolean };
token.isNameMatch = true;
assert.equal(computeWordClass(token), 'word');
});
test('computeWordClass skips name-match class when disabled', () => { test('computeWordClass skips name-match class when disabled', () => {
const token = createToken({ const token = createToken({
surface: 'アクア', surface: 'アクア',
+2 -2
View File
@@ -93,7 +93,7 @@ const DEFAULT_FREQUENCY_RENDER_SETTINGS: FrequencyRenderSettings = {
singleColor: '#f5a97f', singleColor: '#f5a97f',
bandedColors: ['#ed8796', '#f5a97f', '#f9e2af', '#8bd5ca', '#8aadf4'], bandedColors: ['#ed8796', '#f5a97f', '#f9e2af', '#8bd5ca', '#8aadf4'],
}; };
const DEFAULT_NAME_MATCH_ENABLED = true; const DEFAULT_NAME_MATCH_ENABLED = false;
function hasPrioritizedNameMatch( function hasPrioritizedNameMatch(
token: MergedToken, token: MergedToken,
@@ -724,7 +724,7 @@ export function createSubtitleRenderer(ctx: RendererContext) {
if (style.fontStyle) ctx.dom.subtitleRoot.style.fontStyle = style.fontStyle; if (style.fontStyle) ctx.dom.subtitleRoot.style.fontStyle = style.fontStyle;
const knownWordColor = style.knownWordColor ?? ctx.state.knownWordColor ?? '#a6da95'; const knownWordColor = style.knownWordColor ?? ctx.state.knownWordColor ?? '#a6da95';
const nPlusOneColor = style.nPlusOneColor ?? ctx.state.nPlusOneColor ?? '#c6a0f6'; const nPlusOneColor = style.nPlusOneColor ?? ctx.state.nPlusOneColor ?? '#c6a0f6';
const nameMatchEnabled = style.nameMatchEnabled ?? ctx.state.nameMatchEnabled ?? true; const nameMatchEnabled = style.nameMatchEnabled ?? ctx.state.nameMatchEnabled ?? false;
const nameMatchColor = style.nameMatchColor ?? ctx.state.nameMatchColor ?? '#f5bde6'; const nameMatchColor = style.nameMatchColor ?? ctx.state.nameMatchColor ?? '#f5bde6';
const hoverTokenColor = sanitizeSubtitleHoverTokenColor(style.hoverTokenColor); const hoverTokenColor = sanitizeSubtitleHoverTokenColor(style.hoverTokenColor);
const hoverTokenBackgroundColor = sanitizeSubtitleHoverTokenBackgroundColor( const hoverTokenBackgroundColor = sanitizeSubtitleHoverTokenBackgroundColor(
+1 -3
View File
@@ -2,7 +2,7 @@ import * as fs from 'fs';
import * as childProcess from 'child_process'; import * as childProcess from 'child_process';
import * as path from 'path'; import * as path from 'path';
import { DEFAULT_CONFIG } from '../config'; import { DEFAULT_CONFIG } from '../config';
import { SubsyncConfig, SubsyncMode } from '../types'; import { SubsyncConfig } from '../types';
export interface MpvTrack { export interface MpvTrack {
id?: number; id?: number;
@@ -17,7 +17,6 @@ export interface MpvTrack {
} }
export interface SubsyncResolvedConfig { export interface SubsyncResolvedConfig {
defaultMode: SubsyncMode;
alassPath: string; alassPath: string;
ffsubsyncPath: string; ffsubsyncPath: string;
ffmpegPath: string; ffmpegPath: string;
@@ -89,7 +88,6 @@ export function getSubsyncConfig(config: SubsyncConfig | undefined): SubsyncReso
}; };
return { return {
defaultMode: config?.defaultMode ?? DEFAULT_CONFIG.subsync.defaultMode,
alassPath: resolvePath(config?.alass_path, DEFAULT_SUBSYNC_EXECUTABLE_PATHS.alass), alassPath: resolvePath(config?.alass_path, DEFAULT_SUBSYNC_EXECUTABLE_PATHS.alass),
ffsubsyncPath: resolvePath(config?.ffsubsync_path, DEFAULT_SUBSYNC_EXECUTABLE_PATHS.ffsubsync), ffsubsyncPath: resolvePath(config?.ffsubsync_path, DEFAULT_SUBSYNC_EXECUTABLE_PATHS.ffsubsync),
ffmpegPath: resolvePath(config?.ffmpeg_path, DEFAULT_SUBSYNC_EXECUTABLE_PATHS.ffmpeg), ffmpegPath: resolvePath(config?.ffmpeg_path, DEFAULT_SUBSYNC_EXECUTABLE_PATHS.ffmpeg),
-3
View File
@@ -67,10 +67,7 @@ export interface MpvConfig {
aniskipButtonKey?: string; aniskipButtonKey?: string;
} }
export type SubsyncMode = 'auto' | 'manual';
export interface SubsyncConfig { export interface SubsyncConfig {
defaultMode?: SubsyncMode;
alass_path?: string; alass_path?: string;
ffsubsync_path?: string; ffsubsync_path?: string;
ffmpeg_path?: string; ffmpeg_path?: string;