mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-05-26 00:55:16 -07:00
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:
@@ -92,7 +92,7 @@ Browse sibling episode files and the active mpv queue in one overlay modal. Open
|
||||
</tr>
|
||||
<tr>
|
||||
<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>
|
||||
<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.
|
||||
@@ -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.
|
||||
@@ -155,7 +155,7 @@
|
||||
"mecab": true, // Warm up MeCab tokenizer 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
|
||||
"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.
|
||||
|
||||
// ==========================================
|
||||
@@ -336,12 +336,11 @@
|
||||
}, // Dual subtitle track options.
|
||||
|
||||
// ==========================================
|
||||
// Auto Subtitle Sync
|
||||
// Subtitle Sync
|
||||
// Subsync engine and executable paths.
|
||||
// Hot-reload: subsync changes apply to the next subtitle sync run.
|
||||
// ==========================================
|
||||
"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.
|
||||
"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.
|
||||
@@ -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
|
||||
"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
|
||||
"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.
|
||||
"nPlusOneColor": "#c6a0f6", // Color used for the single N+1 target token subtitle highlight.
|
||||
"knownWordColor": "#a6da95", // Color used for known-word subtitle highlights.
|
||||
|
||||
@@ -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.
|
||||
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.
|
||||
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.
|
||||
|
||||
@@ -96,7 +96,7 @@ Name matches are visually distinct from [N+1 targeting, frequency highlighting,
|
||||
|
||||
| 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 |
|
||||
|
||||
## Dictionary Entries
|
||||
@@ -228,7 +228,7 @@ merged.zip
|
||||
| `anilist.characterDictionary.collapsibleSections.description` | `false` | Start Description section expanded |
|
||||
| `anilist.characterDictionary.collapsibleSections.characterInformation` | `false` | Start Character Information 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 |
|
||||
|
||||
## Reference Implementation
|
||||
|
||||
@@ -171,7 +171,7 @@ The configuration file includes several main sections:
|
||||
**External Integrations**
|
||||
|
||||
- [**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
|
||||
- [**Yomitan**](#yomitan) - Reuse an external read-only Yomitan profile via `yomitan.externalProfilePath`
|
||||
- [**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,
|
||||
"yomitanExtension": 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 |
|
||||
| `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
|
||||
|
||||
@@ -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). |
|
||||
| `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 |
|
||||
| `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`) |
|
||||
| `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`) |
|
||||
@@ -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.
|
||||
|
||||
### 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
|
||||
- [`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
|
||||
{
|
||||
"subsync": {
|
||||
"defaultMode": "auto",
|
||||
"alass_path": "",
|
||||
"ffsubsync_path": "",
|
||||
"ffmpeg_path": "",
|
||||
@@ -1132,7 +1131,6 @@ Sync the active subtitle track using `alass` (preferred) or `ffsubsync`. Both ar
|
||||
|
||||
| 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. |
|
||||
| `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`. |
|
||||
|
||||
+1
-1
@@ -25,7 +25,7 @@ Mine vocabulary cards from Yomitan or directly from subtitle lines. SubMiner aut
|
||||
|
||||
## 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}`)">
|
||||
<source :src="withBase(`/assets/demos/subtitle-sync.webm?v=${v}`)" type="video/webm" />
|
||||
|
||||
+1
-1
@@ -66,7 +66,7 @@ features:
|
||||
src: /assets/subtitle-download.svg
|
||||
alt: Subtitle download icon
|
||||
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
|
||||
linkText: Jimaku integration
|
||||
- icon:
|
||||
|
||||
@@ -22,7 +22,7 @@ Only **mpv** is strictly required to run SubMiner. Everything else enhances the
|
||||
| ffmpegthumbnailer | Optional | Video thumbnail generation for the picker. |
|
||||
| guessit | Optional | Better AniSkip title/season/episode parsing. |
|
||||
| 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. |
|
||||
|
||||
### Linux
|
||||
|
||||
@@ -155,7 +155,7 @@
|
||||
"mecab": true, // Warm up MeCab tokenizer 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
|
||||
"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.
|
||||
|
||||
// ==========================================
|
||||
@@ -336,12 +336,11 @@
|
||||
}, // Dual subtitle track options.
|
||||
|
||||
// ==========================================
|
||||
// Auto Subtitle Sync
|
||||
// Subtitle Sync
|
||||
// Subsync engine and executable paths.
|
||||
// Hot-reload: subsync changes apply to the next subtitle sync run.
|
||||
// ==========================================
|
||||
"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.
|
||||
"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.
|
||||
@@ -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
|
||||
"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
|
||||
"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.
|
||||
"nPlusOneColor": "#c6a0f6", // Color used for the single N+1 target token subtitle highlight.
|
||||
"knownWordColor": "#a6da95", // Color used for known-word subtitle highlights.
|
||||
|
||||
@@ -49,7 +49,7 @@ Character-name matches are built from the active merged SubMiner character dicti
|
||||
|
||||
| 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 |
|
||||
|
||||
For full details on dictionary generation, name variant expansion, auto-sync lifecycle, and configuration, see the dedicated [Character Dictionary](/character-dictionary) page.
|
||||
|
||||
@@ -296,7 +296,7 @@ Install ffsubsync or configure the path:
|
||||
|
||||
**"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).
|
||||
- Check that `ffmpeg` is available (used to extract the internal subtitle track).
|
||||
|
||||
+24
-99
@@ -89,7 +89,7 @@ test('loads defaults when config is missing', () => {
|
||||
assert.equal(config.startupWarmups.mecab, true);
|
||||
assert.equal(config.startupWarmups.yomitanExtension, 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.openCharacterDictionary, 'CommandOrControl+Alt+A');
|
||||
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 configPath = path.join(dir, 'config.jsonc');
|
||||
fs.writeFileSync(
|
||||
configPath,
|
||||
`{
|
||||
const originalContent = `{
|
||||
"subtitleStyle": {
|
||||
"fontSize": 42,
|
||||
"fontColor": "#ffffff",
|
||||
@@ -251,63 +249,29 @@ test('migrates legacy subtitle appearance options into css declaration objects o
|
||||
"font-size": "19px"
|
||||
}
|
||||
}
|
||||
}`,
|
||||
'utf-8',
|
||||
);
|
||||
}`;
|
||||
fs.writeFileSync(configPath, originalContent, 'utf-8');
|
||||
|
||||
const service = new ConfigService(dir);
|
||||
const parsed = parseConfigContent(configPath, fs.readFileSync(configPath, 'utf-8')) as {
|
||||
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.equal(fs.readFileSync(configPath, 'utf-8'), originalContent);
|
||||
|
||||
assert.deepEqual(parsed.subtitleStyle.css, {
|
||||
assert.deepEqual(service.getConfig().subtitleStyle.css, {
|
||||
color: '#ffffff',
|
||||
'font-size': '44px',
|
||||
'--subtitle-hover-token-color': '#abcdef',
|
||||
'--subtitle-hover-token-background-color': 'transparent',
|
||||
'text-wrap': 'balance',
|
||||
});
|
||||
assert.equal(Object.hasOwn(parsed.subtitleStyle, 'fontSize'), false);
|
||||
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, {
|
||||
assert.deepEqual(service.getConfig().subtitleStyle.secondary.css, {
|
||||
color: '#bbbbbb',
|
||||
'font-size': '28px',
|
||||
});
|
||||
assert.equal(Object.hasOwn(parsed.subtitleStyle.secondary ?? {}, 'fontSize'), false);
|
||||
assert.equal(Object.hasOwn(parsed.subtitleStyle.secondary ?? {}, 'fontColor'), false);
|
||||
assert.deepEqual(parsed.subtitleSidebar.css, {
|
||||
assert.deepEqual(service.getConfig().subtitleSidebar.css, {
|
||||
'font-family': 'M PLUS 1, sans-serif',
|
||||
color: '#dddddd',
|
||||
'font-size': '19px',
|
||||
'--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', () => {
|
||||
@@ -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'));
|
||||
});
|
||||
|
||||
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 configPath = path.join(dir, 'config.jsonc');
|
||||
fs.writeFileSync(
|
||||
configPath,
|
||||
`{
|
||||
const originalContent = `{
|
||||
"ankiConnect": {
|
||||
"nPlusOne": {
|
||||
"nPlusOne": "#c6a0f6"
|
||||
@@ -2081,23 +2043,15 @@ test('migrates legacy ankiConnect n+1 color value to subtitleStyle', () => {
|
||||
"color": "#a6da95"
|
||||
}
|
||||
}
|
||||
}`,
|
||||
'utf-8',
|
||||
);
|
||||
}`;
|
||||
fs.writeFileSync(configPath, originalContent, 'utf-8');
|
||||
|
||||
const service = new ConfigService(dir);
|
||||
const config = service.getConfig();
|
||||
|
||||
assert.equal(config.subtitleStyle.nPlusOneColor, '#c6a0f6');
|
||||
assert.equal(config.subtitleStyle.knownWordColor, '#a6da95');
|
||||
|
||||
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);
|
||||
assert.equal(fs.readFileSync(configPath, 'utf-8'), originalContent);
|
||||
});
|
||||
|
||||
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;/);
|
||||
});
|
||||
|
||||
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 configPath = path.join(dir, 'config.jsonc');
|
||||
fs.writeFileSync(
|
||||
configPath,
|
||||
`{
|
||||
const originalContent = `{
|
||||
"ankiConnect": {
|
||||
"nPlusOne": {
|
||||
"highlightEnabled": true,
|
||||
@@ -2125,20 +2077,12 @@ test('migrates legacy ankiConnect nPlusOne known-word settings to knownWords', (
|
||||
"knownWord": "#a6da95"
|
||||
}
|
||||
}
|
||||
}`,
|
||||
'utf-8',
|
||||
);
|
||||
}`;
|
||||
fs.writeFileSync(configPath, originalContent, 'utf-8');
|
||||
|
||||
const service = new ConfigService(dir);
|
||||
const config = service.getConfig();
|
||||
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.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'],
|
||||
});
|
||||
assert.equal(config.subtitleStyle.knownWordColor, '#a6da95');
|
||||
assert.equal(parsed.ankiConnect.knownWords.highlightEnabled, true);
|
||||
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.equal(fs.readFileSync(configPath, 'utf-8'), originalContent);
|
||||
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 configPath = path.join(dir, 'config.jsonc');
|
||||
fs.writeFileSync(
|
||||
configPath,
|
||||
`{
|
||||
const originalContent = `{
|
||||
"ankiConnect": {
|
||||
"nPlusOne": {
|
||||
"enabled": true,
|
||||
@@ -2183,19 +2113,14 @@ test('migrates duplicate ankiConnect nPlusOne objects to the modal path', () =>
|
||||
"minSentenceWords": "3"
|
||||
}
|
||||
}
|
||||
}`,
|
||||
'utf-8',
|
||||
);
|
||||
}`;
|
||||
fs.writeFileSync(configPath, originalContent, 'utf-8');
|
||||
|
||||
const service = new ConfigService(dir);
|
||||
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(parsed.ankiConnect.nPlusOne.enabled, true);
|
||||
assert.equal(parsed.ankiConnect.nPlusOne.minSentenceWords, '3');
|
||||
assert.equal(fs.readFileSync(configPath, 'utf-8'), originalContent);
|
||||
});
|
||||
|
||||
test('supports legacy ankiConnect.behavior N+1 settings as fallback', () => {
|
||||
|
||||
@@ -105,7 +105,6 @@ export const CORE_DEFAULT_CONFIG: Pick<
|
||||
primarySubLanguages: ['ja', 'jpn'],
|
||||
},
|
||||
subsync: {
|
||||
defaultMode: 'auto',
|
||||
alass_path: '',
|
||||
ffsubsync_path: '',
|
||||
ffmpeg_path: '',
|
||||
@@ -116,7 +115,7 @@ export const CORE_DEFAULT_CONFIG: Pick<
|
||||
mecab: true,
|
||||
yomitanExtension: true,
|
||||
subtitleDictionaries: true,
|
||||
jellyfinRemoteSession: true,
|
||||
jellyfinRemoteSession: false,
|
||||
},
|
||||
updates: {
|
||||
enabled: true,
|
||||
|
||||
@@ -10,7 +10,7 @@ export const SUBTITLE_DEFAULT_CONFIG: Pick<ResolvedConfig, 'subtitleStyle' | 'su
|
||||
autoPauseVideoOnYomitanPopup: true,
|
||||
hoverTokenColor: '#f4dbd6',
|
||||
hoverTokenBackgroundColor: 'transparent',
|
||||
nameMatchEnabled: true,
|
||||
nameMatchEnabled: false,
|
||||
nameMatchColor: '#f5bde6',
|
||||
fontFamily: 'Hiragino Sans, M PLUS 1, Source Han Sans JP, Noto Sans CJK JP',
|
||||
fontSize: 35,
|
||||
|
||||
@@ -388,13 +388,6 @@ export function buildCoreConfigOptionRegistry(
|
||||
defaultValue: defaultConfig.annotationWebsocket.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',
|
||||
kind: 'boolean',
|
||||
|
||||
@@ -90,7 +90,7 @@ const CORE_TEMPLATE_SECTIONS: ConfigTemplateSection[] = [
|
||||
key: 'secondarySub',
|
||||
},
|
||||
{
|
||||
title: 'Auto Subtitle Sync',
|
||||
title: 'Subtitle Sync',
|
||||
description: ['Subsync engine and executable paths.'],
|
||||
notes: ['Hot-reload: subsync changes apply to the next subtitle sync run.'],
|
||||
key: 'subsync',
|
||||
|
||||
@@ -273,13 +273,6 @@ export function applyCoreDomainConfig(context: ResolveContext): void {
|
||||
}
|
||||
|
||||
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);
|
||||
if (alass !== undefined) resolved.subsync.alass_path = alass;
|
||||
const ffsubsync = asString(src.subsync.ffsubsync_path);
|
||||
|
||||
@@ -162,7 +162,7 @@ test('subtitleStyle nameMatchEnabled falls back on invalid value', () => {
|
||||
|
||||
applySubtitleDomainConfig(context);
|
||||
|
||||
assert.equal(context.resolved.subtitleStyle.nameMatchEnabled, true);
|
||||
assert.equal(context.resolved.subtitleStyle.nameMatchEnabled, false);
|
||||
assert.ok(
|
||||
warnings.some(
|
||||
(warning) =>
|
||||
|
||||
@@ -149,7 +149,6 @@ export class ConfigService {
|
||||
if (!migrated) {
|
||||
return rawConfig;
|
||||
}
|
||||
fs.writeFileSync(configPath, content, 'utf-8');
|
||||
return rawConfig;
|
||||
} catch (error) {
|
||||
console.error(`[ConfigService] legacy config migration failed for ${configPath}:`, error);
|
||||
|
||||
@@ -194,8 +194,8 @@ test('settings registry exposes css declaration editor for subtitle sidebar appe
|
||||
test('settings registry routes playback-related integrations into integrations', () => {
|
||||
assert.equal(field('jimaku.apiBaseUrl').category, 'integrations');
|
||||
assert.equal(field('jimaku.apiBaseUrl').section, 'Jimaku');
|
||||
assert.equal(field('subsync.defaultMode').category, 'integrations');
|
||||
assert.equal(field('subsync.defaultMode').section, 'Subtitle Sync');
|
||||
assert.equal(field('subsync.replace').category, 'integrations');
|
||||
assert.equal(field('subsync.replace').section, 'Subtitle Sync');
|
||||
});
|
||||
|
||||
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.languagePreference',
|
||||
'jimaku.maxEntryResults',
|
||||
'subsync.defaultMode',
|
||||
'subsync.replace',
|
||||
'ankiConnect.behavior.autoUpdateNewCards',
|
||||
'ankiConnect.knownWords.highlightEnabled',
|
||||
'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', () => {
|
||||
for (const path of [
|
||||
'stats.serverPort',
|
||||
|
||||
@@ -242,3 +242,22 @@ test('startAppLifecycle quits macOS config-only launch when all windows close',
|
||||
handler();
|
||||
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']);
|
||||
});
|
||||
|
||||
@@ -166,7 +166,7 @@ export function startAppLifecycle(initialArgs: CliArgs, deps: AppLifecycleServic
|
||||
deps.onWindowAllClosed(() => {
|
||||
if (
|
||||
deps.shouldQuitOnWindowAllClosed() &&
|
||||
(!deps.isDarwinPlatform() || initialArgs.settings)
|
||||
(!deps.isDarwinPlatform() || initialArgs.settings || initialArgs.setup)
|
||||
) {
|
||||
deps.quitApp();
|
||||
}
|
||||
|
||||
@@ -27,7 +27,7 @@ test('classifyConfigHotReloadDiff treats safe nested config paths as hot-reloada
|
||||
next.logging.level = 'debug';
|
||||
next.youtube.primarySubLanguages = ['ja', 'en'];
|
||||
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.knownWords.highlightEnabled = !prev.ankiConnect.knownWords.highlightEnabled;
|
||||
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',
|
||||
'youtube.primarySubLanguages',
|
||||
'jimaku.maxEntryResults',
|
||||
'subsync.defaultMode',
|
||||
'subsync.replace',
|
||||
'ankiConnect.behavior.autoUpdateNewCards',
|
||||
'ankiConnect.knownWords.highlightEnabled',
|
||||
'ankiConnect.knownWords.refreshMinutes',
|
||||
|
||||
@@ -41,7 +41,6 @@ function makeDeps(
|
||||
return {
|
||||
getMpvClient: () => mpvClient,
|
||||
getResolvedConfig: () => ({
|
||||
defaultMode: 'manual',
|
||||
alassPath: '/usr/bin/alass',
|
||||
ffsubsyncPath: '/usr/bin/ffsubsync',
|
||||
ffmpegPath: '/usr/bin/ffmpeg',
|
||||
@@ -68,7 +67,7 @@ test('triggerSubsyncFromConfig returns early when already in progress', async ()
|
||||
assert.deepEqual(osd, ['Subsync already running']);
|
||||
});
|
||||
|
||||
test('triggerSubsyncFromConfig opens manual picker in manual mode', async () => {
|
||||
test('triggerSubsyncFromConfig opens manual picker', async () => {
|
||||
const osd: string[] = [];
|
||||
let payloadTrackCount = 0;
|
||||
let inProgressState: boolean | null = null;
|
||||
@@ -92,6 +91,31 @@ test('triggerSubsyncFromConfig opens manual picker in manual mode', async () =>
|
||||
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 () => {
|
||||
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 inProgress: boolean[] = [];
|
||||
let payloadTrackCount = 0;
|
||||
|
||||
await triggerSubsyncFromConfig(
|
||||
makeDeps({
|
||||
getResolvedConfig: () => ({
|
||||
defaultMode: 'auto',
|
||||
alassPath: '/missing/alass',
|
||||
ffsubsyncPath: '/missing/ffsubsync',
|
||||
ffmpegPath: '/missing/ffmpeg',
|
||||
@@ -176,16 +200,18 @@ test('triggerSubsyncFromConfig reports path validation failures', async () => {
|
||||
setSubsyncInProgress: (value) => {
|
||||
inProgress.push(value);
|
||||
},
|
||||
openManualPicker: (payload) => {
|
||||
payloadTrackCount = payload.sourceTracks.length;
|
||||
},
|
||||
showMpvOsd: (text) => {
|
||||
osd.push(text);
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
assert.deepEqual(inProgress, [true, false]);
|
||||
assert.ok(
|
||||
osd.some((line) => line.startsWith('Subsync failed: Configured ffmpeg executable not found')),
|
||||
);
|
||||
assert.deepEqual(inProgress, [false]);
|
||||
assert.equal(payloadTrackCount, 1);
|
||||
assert.deepEqual(osd, ['Subsync: choose engine and source']);
|
||||
});
|
||||
|
||||
function writeExecutableScript(filePath: string, content: string): void {
|
||||
@@ -260,7 +286,6 @@ test('runSubsyncManual constructs ffsubsync command and returns success', async
|
||||
},
|
||||
}),
|
||||
getResolvedConfig: () => ({
|
||||
defaultMode: 'manual',
|
||||
alassPath,
|
||||
ffsubsyncPath,
|
||||
ffmpegPath,
|
||||
@@ -326,7 +351,6 @@ test('runSubsyncManual writes deterministic _retimed filename when replace is fa
|
||||
},
|
||||
}),
|
||||
getResolvedConfig: () => ({
|
||||
defaultMode: 'manual',
|
||||
alassPath,
|
||||
ffsubsyncPath,
|
||||
ffmpegPath,
|
||||
@@ -382,7 +406,6 @@ test('runSubsyncManual reports ffsubsync command failures with details', async (
|
||||
},
|
||||
}),
|
||||
getResolvedConfig: () => ({
|
||||
defaultMode: 'manual',
|
||||
alassPath,
|
||||
ffsubsyncPath,
|
||||
ffmpegPath,
|
||||
@@ -448,7 +471,6 @@ test('runSubsyncManual constructs alass command and returns failure on non-zero
|
||||
},
|
||||
}),
|
||||
getResolvedConfig: () => ({
|
||||
defaultMode: 'manual',
|
||||
alassPath,
|
||||
ffsubsyncPath,
|
||||
ffmpegPath,
|
||||
@@ -520,7 +542,6 @@ test('runSubsyncManual keeps internal alass source file alive until sync finishe
|
||||
},
|
||||
}),
|
||||
getResolvedConfig: () => ({
|
||||
defaultMode: 'manual',
|
||||
alassPath,
|
||||
ffsubsyncPath,
|
||||
ffmpegPath,
|
||||
@@ -577,7 +598,6 @@ test('runSubsyncManual resolves string sid values from mpv stream properties', a
|
||||
},
|
||||
}),
|
||||
getResolvedConfig: () => ({
|
||||
defaultMode: 'manual',
|
||||
alassPath,
|
||||
ffsubsyncPath,
|
||||
ffmpegPath,
|
||||
|
||||
@@ -15,9 +15,6 @@ import {
|
||||
SubsyncResolvedConfig,
|
||||
} from '../../subsync/utils';
|
||||
import { isRemoteMediaPath } from '../../jimaku/utils';
|
||||
import { createLogger } from '../../logger';
|
||||
|
||||
const logger = createLogger('main:subsync');
|
||||
|
||||
interface FileExtractionResult {
|
||||
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(
|
||||
request: SubsyncManualRunRequest,
|
||||
deps: SubsyncCoreDeps,
|
||||
@@ -448,17 +394,9 @@ export async function triggerSubsyncFromConfig(deps: TriggerSubsyncFromConfigDep
|
||||
return;
|
||||
}
|
||||
|
||||
const resolved = deps.getResolvedConfig();
|
||||
try {
|
||||
if (resolved.defaultMode === 'manual') {
|
||||
await openSubsyncManualPicker(deps);
|
||||
deps.showMpvOsd('Subsync: choose engine and source');
|
||||
return;
|
||||
}
|
||||
|
||||
deps.setSubsyncInProgress(true);
|
||||
const result = await deps.runWithSubsyncSpinner(() => runSubsyncAutoInternal(deps));
|
||||
deps.showMpvOsd(result.message);
|
||||
await openSubsyncManualPicker(deps);
|
||||
deps.showMpvOsd('Subsync: choose engine and source');
|
||||
} catch (error) {
|
||||
deps.showMpvOsd(`Subsync failed: ${(error as Error).message}`);
|
||||
} 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)']);
|
||||
});
|
||||
|
||||
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 () => {
|
||||
let shown:
|
||||
| {
|
||||
|
||||
@@ -46,7 +46,11 @@ async function maybeFocusAppForDialog(deps: UpdateDialogPresenterDeps): Promise<
|
||||
|
||||
export function createUpdateDialogPresenter(deps: UpdateDialogPresenterDeps) {
|
||||
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);
|
||||
};
|
||||
|
||||
|
||||
@@ -214,7 +214,7 @@
|
||||
<div id="subsyncModal" class="modal hidden" aria-hidden="true">
|
||||
<div class="modal-content subsync-modal-content">
|
||||
<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>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
|
||||
@@ -215,7 +215,7 @@ export function createRendererState(): RendererState {
|
||||
|
||||
knownWordColor: '#a6da95',
|
||||
nPlusOneColor: '#c6a0f6',
|
||||
nameMatchEnabled: true,
|
||||
nameMatchEnabled: false,
|
||||
nameMatchColor: '#f5bde6',
|
||||
jlptN1Color: '#ed8796',
|
||||
jlptN2Color: '#f5a97f',
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
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({
|
||||
isKnown: true,
|
||||
isNPlusOneTarget: true,
|
||||
@@ -270,6 +270,7 @@ test('computeWordClass applies name-match class ahead of known, n+1, frequency,
|
||||
|
||||
assert.equal(
|
||||
computeWordClass(token, {
|
||||
nameMatchEnabled: true,
|
||||
enabled: true,
|
||||
topX: 100,
|
||||
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', () => {
|
||||
const token = createToken({
|
||||
surface: 'アクア',
|
||||
|
||||
@@ -93,7 +93,7 @@ const DEFAULT_FREQUENCY_RENDER_SETTINGS: FrequencyRenderSettings = {
|
||||
singleColor: '#f5a97f',
|
||||
bandedColors: ['#ed8796', '#f5a97f', '#f9e2af', '#8bd5ca', '#8aadf4'],
|
||||
};
|
||||
const DEFAULT_NAME_MATCH_ENABLED = true;
|
||||
const DEFAULT_NAME_MATCH_ENABLED = false;
|
||||
|
||||
function hasPrioritizedNameMatch(
|
||||
token: MergedToken,
|
||||
@@ -724,7 +724,7 @@ export function createSubtitleRenderer(ctx: RendererContext) {
|
||||
if (style.fontStyle) ctx.dom.subtitleRoot.style.fontStyle = style.fontStyle;
|
||||
const knownWordColor = style.knownWordColor ?? ctx.state.knownWordColor ?? '#a6da95';
|
||||
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 hoverTokenColor = sanitizeSubtitleHoverTokenColor(style.hoverTokenColor);
|
||||
const hoverTokenBackgroundColor = sanitizeSubtitleHoverTokenBackgroundColor(
|
||||
|
||||
@@ -2,7 +2,7 @@ import * as fs from 'fs';
|
||||
import * as childProcess from 'child_process';
|
||||
import * as path from 'path';
|
||||
import { DEFAULT_CONFIG } from '../config';
|
||||
import { SubsyncConfig, SubsyncMode } from '../types';
|
||||
import { SubsyncConfig } from '../types';
|
||||
|
||||
export interface MpvTrack {
|
||||
id?: number;
|
||||
@@ -17,7 +17,6 @@ export interface MpvTrack {
|
||||
}
|
||||
|
||||
export interface SubsyncResolvedConfig {
|
||||
defaultMode: SubsyncMode;
|
||||
alassPath: string;
|
||||
ffsubsyncPath: string;
|
||||
ffmpegPath: string;
|
||||
@@ -89,7 +88,6 @@ export function getSubsyncConfig(config: SubsyncConfig | undefined): SubsyncReso
|
||||
};
|
||||
|
||||
return {
|
||||
defaultMode: config?.defaultMode ?? DEFAULT_CONFIG.subsync.defaultMode,
|
||||
alassPath: resolvePath(config?.alass_path, DEFAULT_SUBSYNC_EXECUTABLE_PATHS.alass),
|
||||
ffsubsyncPath: resolvePath(config?.ffsubsync_path, DEFAULT_SUBSYNC_EXECUTABLE_PATHS.ffsubsync),
|
||||
ffmpegPath: resolvePath(config?.ffmpeg_path, DEFAULT_SUBSYNC_EXECUTABLE_PATHS.ffmpeg),
|
||||
|
||||
@@ -67,10 +67,7 @@ export interface MpvConfig {
|
||||
aniskipButtonKey?: string;
|
||||
}
|
||||
|
||||
export type SubsyncMode = 'auto' | 'manual';
|
||||
|
||||
export interface SubsyncConfig {
|
||||
defaultMode?: SubsyncMode;
|
||||
alass_path?: string;
|
||||
ffsubsync_path?: string;
|
||||
ffmpeg_path?: string;
|
||||
|
||||
Reference in New Issue
Block a user