mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-05-26 00:55:16 -07:00
Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
fed1bd3b42
|
|||
|
661e54144d
|
|||
|
a53237f1ce
|
|||
|
355d7d95b2
|
|||
|
47f92129af
|
|||
|
525cb7e1fd
|
|||
|
02a5d95542
|
|||
|
166015897d
|
@@ -4,7 +4,7 @@
|
|||||||
|
|
||||||
# SubMiner
|
# SubMiner
|
||||||
|
|
||||||
Integrates Yomitan with mpv - look up words, mine to Anki, and track your immersion without leaving the player.
|
Integrates Yomitan and mpv - on-screen lookups, mine to Anki, and track immersion without leaving the player
|
||||||
|
|
||||||
[Installation](#quick-start) · [Requirements](#requirements) · [Usage](https://docs.subminer.moe/usage) · [Documentation](https://docs.subminer.moe)
|
[Installation](#quick-start) · [Requirements](#requirements) · [Usage](https://docs.subminer.moe/usage) · [Documentation](https://docs.subminer.moe)
|
||||||
|
|
||||||
@@ -23,7 +23,7 @@ Integrates Yomitan with mpv - look up words, mine to Anki, and track your immers
|
|||||||
|
|
||||||
### Dictionary Lookups
|
### Dictionary Lookups
|
||||||
|
|
||||||
Yomitan runs inside the overlay. Trigger a lookup on any word for full dictionary popups — definitions, pitch accent, frequency data — without ever leaving mpv.
|
Hover over any word and trigger a lookup to get the full Yomitan popup - definitions, pitch accent, and frequency data - without ever leaving mpv.
|
||||||
|
|
||||||
<div align="center">
|
<div align="center">
|
||||||
<img src="docs-site/public/screenshots/yomitan-lookup.png" width="800" alt="Yomitan dictionary popup over annotated subtitles in mpv">
|
<img src="docs-site/public/screenshots/yomitan-lookup.png" width="800" alt="Yomitan dictionary popup over annotated subtitles in mpv">
|
||||||
@@ -43,7 +43,7 @@ Create an Anki card with the sentence, audio clip, screenshot, and machine trans
|
|||||||
|
|
||||||
### Reading Annotations
|
### Reading Annotations
|
||||||
|
|
||||||
Real-time subtitle annotations with frequency highlighting, JLPT tags, N+1 targeting, and a character name dictionary. Known words fade back; new words stand out. Grammar-only tokens render as plain text so you focus on what matters.
|
Real-time subtitle annotations with frequency highlighting, JLPT tags, N+1 targeting, and a character name dictionary. Grammar-only tokens and particles render as plain text so you focus on what matters.
|
||||||
|
|
||||||
<div align="center">
|
<div align="center">
|
||||||
<img src="docs-site/public/screenshots/annotations.png" width="800" alt="Annotated subtitles with frequency coloring, JLPT underlines, and N+1 targets">
|
<img src="docs-site/public/screenshots/annotations.png" width="800" alt="Annotated subtitles with frequency coloring, JLPT underlines, and N+1 targets">
|
||||||
@@ -53,7 +53,7 @@ Real-time subtitle annotations with frequency highlighting, JLPT tags, N+1 targe
|
|||||||
|
|
||||||
### Immersion Dashboard
|
### Immersion Dashboard
|
||||||
|
|
||||||
Local stats dashboard — watch time, anime library, vocabulary growth, mining throughput, session history, and trends. All stored locally, no third-party tracking.
|
Local stats dashboard tracking watch time, vocabulary growth, mining throughput, session history, and trends. All stored locally, no third-party tracking.
|
||||||
|
|
||||||
<div align="center">
|
<div align="center">
|
||||||
<img src="docs-site/public/screenshots/stats-overview.png" width="800" alt="Stats dashboard showing watch time, cards mined, streaks, and tracking data">
|
<img src="docs-site/public/screenshots/stats-overview.png" width="800" alt="Stats dashboard showing watch time, cards mined, streaks, and tracking data">
|
||||||
@@ -92,11 +92,11 @@ 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>
|
||||||
<td>Annotated subtitle feed for external clients (texthooker pages, custom tools)</td>
|
<td>Plain subtitle feed plus a dedicated annotated feed for texthooker pages and custom tools</td>
|
||||||
</tr>
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
|
|
||||||
@@ -110,16 +110,17 @@ Browse sibling episode files and the active mpv queue in one overlay modal. Open
|
|||||||
|
|
||||||
## Requirements
|
## Requirements
|
||||||
|
|
||||||
Only **mpv** is required. Everything else is optional but enhances the experience.
|
Only **mpv** and Anki+AnkiConnect are required. Everything else is optional but enhances the experience.
|
||||||
|
|
||||||
| Dependency | Status | What it does |
|
| Dependency | Status | What it does |
|
||||||
| -------------------- | ----------- | ------------------------------------------------- |
|
| -------------------- | ----------- | ---------------------------------------- |
|
||||||
| mpv | Required | The video player SubMiner overlays on |
|
| mpv | Required | The video player SubMiner overlays on |
|
||||||
| ffmpeg | Recommended | Audio clips & screenshots for Anki cards |
|
| Anki + AnkiConnect | Required | Card creation from the Yomitan popup |
|
||||||
| MeCab + mecab-ipadic | Recommended | More precise N+1, JLPT, and frequency annotations |
|
| ffmpeg | Recommended | Audio clips & screenshots for Anki cards |
|
||||||
| yt-dlp | Optional | YouTube playback |
|
| MeCab + mecab-ipadic | Recommended | More precise annotations and filtering |
|
||||||
| fzf / rofi | Optional | Video picker in the launcher |
|
| yt-dlp | Optional | YouTube playback |
|
||||||
| alass / ffsubsync | Optional | Subtitle sync |
|
| fzf / rofi | Optional | Video picker in the launcher |
|
||||||
|
| alass / ffsubsync | Optional | Subtitle sync |
|
||||||
|
|
||||||
<details>
|
<details>
|
||||||
<summary><b>Platform-specific install commands</b></summary>
|
<summary><b>Platform-specific install commands</b></summary>
|
||||||
@@ -196,25 +197,24 @@ See the [build-from-source guide](https://docs.subminer.moe/installation#from-so
|
|||||||
Run SubMiner and the first-run setup wizard will guide you through importing Yomitan dictionaries and optionally installing the `subminer` command-line launcher.
|
Run SubMiner and the first-run setup wizard will guide you through importing Yomitan dictionaries and optionally installing the `subminer` command-line launcher.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Linux (AUR)
|
# Linux
|
||||||
subminer app --setup
|
subminer app --setup
|
||||||
|
|
||||||
# macOS — open SubMiner.app, or:
|
# macOS — open SubMiner.app, or:
|
||||||
subminer app --setup
|
subminer app --setup
|
||||||
```
|
```
|
||||||
|
|
||||||
On **Windows**, just run `SubMiner.exe` — setup opens automatically on first launch.
|
On **Windows**, just run `SubMiner.exe` and the setup will open automatically on first launch.
|
||||||
|
|
||||||
### 3. Play
|
### 3. Mine
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
subminer video.mkv # play video with overlay
|
subminer video.mkv # launch mpv with SubMiner
|
||||||
subminer stats # open immersion dashboard
|
subminer /path/to/dir # pick a file with fzf
|
||||||
subminer config # open configuration window
|
subminer -R /path/to/dir # pick a file with rofi (Linux only)
|
||||||
subminer --config # open configuration window via flag
|
|
||||||
```
|
```
|
||||||
|
|
||||||
On **Windows**, use the **SubMiner mpv** shortcut created during setup — double-click it or drag a video file onto it.
|
On **Windows**, use the **SubMiner mpv** shortcut created during setup. Double-click it or drag a video file onto it.
|
||||||
|
|
||||||
## Documentation
|
## Documentation
|
||||||
|
|
||||||
|
|||||||
-193
@@ -1,193 +0,0 @@
|
|||||||
"use strict";
|
|
||||||
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
||||||
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
||||||
};
|
|
||||||
Object.defineProperty(exports, "__esModule", { value: true });
|
|
||||||
exports.resolveMacAppBundlePath = resolveMacAppBundlePath;
|
|
||||||
exports.isMacApplicationsFolderBundle = isMacApplicationsFolderBundle;
|
|
||||||
exports.isKnownLinuxPackageManagedAppImage = isKnownLinuxPackageManagedAppImage;
|
|
||||||
exports.isNativeUpdaterSupported = isNativeUpdaterSupported;
|
|
||||||
exports.configureAutoUpdater = configureAutoUpdater;
|
|
||||||
exports.createElectronAppUpdater = createElectronAppUpdater;
|
|
||||||
const node_fs_1 = require("node:fs");
|
|
||||||
const node_child_process_1 = require("node:child_process");
|
|
||||||
const node_os_1 = __importDefault(require("node:os"));
|
|
||||||
const node_path_1 = __importDefault(require("node:path"));
|
|
||||||
const node_util_1 = require("node:util");
|
|
||||||
const electron_updater_1 = require("electron-updater");
|
|
||||||
const release_assets_1 = require("./release-assets");
|
|
||||||
const updaterErrorListeners = new WeakMap();
|
|
||||||
const execFileAsync = (0, node_util_1.promisify)(node_child_process_1.execFile);
|
|
||||||
function resolveMacAppBundlePath(execPath) {
|
|
||||||
const marker = '.app/Contents/MacOS/';
|
|
||||||
const markerIndex = execPath.indexOf(marker);
|
|
||||||
if (markerIndex < 0)
|
|
||||||
return null;
|
|
||||||
return execPath.slice(0, markerIndex + '.app'.length);
|
|
||||||
}
|
|
||||||
async function readMacCodeSignature(appBundlePath) {
|
|
||||||
try {
|
|
||||||
const result = await execFileAsync('/usr/bin/codesign', ['-dv', '--verbose=4', appBundlePath], {
|
|
||||||
encoding: 'utf8',
|
|
||||||
});
|
|
||||||
return `${result.stdout ?? ''}\n${result.stderr ?? ''}`;
|
|
||||||
}
|
|
||||||
catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
function realpathOrOriginal(filePath) {
|
|
||||||
try {
|
|
||||||
return (0, node_fs_1.realpathSync)(filePath);
|
|
||||||
}
|
|
||||||
catch {
|
|
||||||
return filePath;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
function isSameOrInsideDirectory(parentPath, candidatePath) {
|
|
||||||
const relative = node_path_1.default.relative(parentPath, candidatePath);
|
|
||||||
return (relative === '' ||
|
|
||||||
(relative.length > 0 && !relative.startsWith('..') && !node_path_1.default.isAbsolute(relative)));
|
|
||||||
}
|
|
||||||
function isMacApplicationsFolderBundle(appBundlePath, homeDir = node_os_1.default.homedir()) {
|
|
||||||
const resolvedBundlePath = node_path_1.default.resolve(appBundlePath);
|
|
||||||
return (isSameOrInsideDirectory('/Applications', resolvedBundlePath) ||
|
|
||||||
isSameOrInsideDirectory(node_path_1.default.join(homeDir, 'Applications'), resolvedBundlePath));
|
|
||||||
}
|
|
||||||
function isKnownLinuxPackageManagedAppImage(appImagePath) {
|
|
||||||
return realpathOrOriginal(appImagePath) === '/opt/SubMiner/SubMiner.AppImage';
|
|
||||||
}
|
|
||||||
async function isNativeUpdaterSupported(options) {
|
|
||||||
if (!options.isPackaged) {
|
|
||||||
options.log?.('Skipping native updater because this build is not packaged.');
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
if (options.platform === 'linux') {
|
|
||||||
options.log?.('Skipping native Linux updater because Linux tray checks use GitHub release assets.');
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
if (options.platform !== 'darwin') {
|
|
||||||
options.log?.('Skipping native updater because this platform uses GitHub metadata checks.');
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
const appBundlePath = resolveMacAppBundlePath(options.execPath);
|
|
||||||
if (!appBundlePath) {
|
|
||||||
options.log?.('Skipping native macOS updater because the app bundle path could not be resolved.');
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
if (!isMacApplicationsFolderBundle(appBundlePath, options.homeDir)) {
|
|
||||||
options.log?.('Skipping native macOS updater because the app is not installed in an Applications folder.');
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
const signature = await (options.readCodeSignature ?? readMacCodeSignature)(appBundlePath);
|
|
||||||
if (!signature) {
|
|
||||||
options.log?.('Skipping native macOS updater because the app code signature could not be read.');
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
if (/Signature=adhoc\b/.test(signature) || /TeamIdentifier=not set\b/.test(signature)) {
|
|
||||||
options.log?.('Skipping native macOS updater because this build is ad-hoc signed.');
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
function configureAutoUpdater(updater, log = () => { }, channel = 'stable') {
|
|
||||||
updater.autoDownload = false;
|
|
||||||
// On macOS this avoids invoking Squirrel until the explicit restart/install step.
|
|
||||||
updater.autoInstallOnAppQuit = false;
|
|
||||||
updater.allowPrerelease = channel === 'prerelease';
|
|
||||||
updater.allowDowngrade = false;
|
|
||||||
updater.logger = {
|
|
||||||
info: () => { },
|
|
||||||
debug: () => { },
|
|
||||||
warn: (message) => log(message),
|
|
||||||
error: (message) => log(message),
|
|
||||||
};
|
|
||||||
const previousErrorListener = updaterErrorListeners.get(updater);
|
|
||||||
if (previousErrorListener) {
|
|
||||||
if (updater.off) {
|
|
||||||
updater.off('error', previousErrorListener);
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
updater.removeListener?.('error', previousErrorListener);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (updater.on) {
|
|
||||||
const errorListener = (error) => {
|
|
||||||
const message = error instanceof Error ? error.message : String(error);
|
|
||||||
log(`Updater error event: ${message}`);
|
|
||||||
};
|
|
||||||
updater.on('error', errorListener);
|
|
||||||
updaterErrorListeners.set(updater, errorListener);
|
|
||||||
}
|
|
||||||
return updater;
|
|
||||||
}
|
|
||||||
function createElectronAppUpdater(options) {
|
|
||||||
const getChannel = options.getChannel ?? (() => 'stable');
|
|
||||||
const updater = configureAutoUpdater(options.updater ?? electron_updater_1.autoUpdater, options.log, getChannel());
|
|
||||||
if (options.configureHttpExecutor) {
|
|
||||||
// electron-updater has no public executor hook; keep the macOS cURL override localized.
|
|
||||||
updater.httpExecutor = options.configureHttpExecutor();
|
|
||||||
}
|
|
||||||
if (options.disableDifferentialDownload !== undefined) {
|
|
||||||
updater.disableDifferentialDownload = options.disableDifferentialDownload;
|
|
||||||
}
|
|
||||||
let nativeUpdaterSupported = null;
|
|
||||||
async function getNativeUpdaterSupported() {
|
|
||||||
if (!options.isNativeUpdaterSupported)
|
|
||||||
return true;
|
|
||||||
if (nativeUpdaterSupported === null) {
|
|
||||||
nativeUpdaterSupported = Promise.resolve(options.isNativeUpdaterSupported());
|
|
||||||
}
|
|
||||||
return nativeUpdaterSupported;
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
async checkForUpdates(channel) {
|
|
||||||
if (!options.isPackaged) {
|
|
||||||
return {
|
|
||||||
available: false,
|
|
||||||
version: options.currentVersion,
|
|
||||||
canUpdate: false,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
if (!(await getNativeUpdaterSupported())) {
|
|
||||||
options.log('Skipping native app update check because native updater is unsupported.');
|
|
||||||
return {
|
|
||||||
available: false,
|
|
||||||
version: options.currentVersion,
|
|
||||||
canUpdate: false,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
configureAutoUpdater(updater, options.log, channel ?? getChannel());
|
|
||||||
const result = await updater.checkForUpdates();
|
|
||||||
const version = result?.updateInfo?.version ?? options.currentVersion;
|
|
||||||
return {
|
|
||||||
available: (0, release_assets_1.compareSemverLike)(version, options.currentVersion) > 0,
|
|
||||||
version,
|
|
||||||
canUpdate: true,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
async downloadUpdate() {
|
|
||||||
if (!options.isPackaged) {
|
|
||||||
options.log('Skipping app update download because this build is not packaged.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!(await getNativeUpdaterSupported())) {
|
|
||||||
options.log('Skipping app update download because native updater is unsupported.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
await updater.downloadUpdate();
|
|
||||||
},
|
|
||||||
async quitAndInstall() {
|
|
||||||
if (!options.isPackaged) {
|
|
||||||
options.log('Skipping app update install because this build is not packaged.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!(await getNativeUpdaterSupported())) {
|
|
||||||
options.log('Skipping app update install because native updater is unsupported.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
updater.quitAndInstall(false, true);
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
//# sourceMappingURL=app-updater.js.map
|
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
type: fixed
|
type: fixed
|
||||||
area: launcher
|
area: launcher
|
||||||
|
|
||||||
- Reused an already-running background SubMiner app for launcher-opened videos, preserving warmups and keeping the tray app alive after playback closes.
|
- Reused an already-running background SubMiner app for launcher-opened videos, closed launcher-owned tray apps after playback ends, and reapplied preferred subtitles for warm launches.
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
type: fixed
|
||||||
|
area: config
|
||||||
|
|
||||||
|
- Preserved user config files during legacy config compatibility handling.
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
type: changed
|
type: changed
|
||||||
area: config
|
area: config
|
||||||
|
|
||||||
- Reorganized the Configuration window into clearer Appearance, Behavior, Anki, input, and integration sections with learned keybinding controls and AnkiConnect-backed deck, field, and note-type pickers.
|
- Reorganized the Settings window into clearer Appearance, Behavior, Anki, input, and integration sections with learned keybinding controls and AnkiConnect-backed deck, field, and note-type pickers.
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
type: fixed
|
type: fixed
|
||||||
area: config
|
area: config
|
||||||
|
|
||||||
- Fixed Configuration window search so it searches across all categories, narrows on multi-word terms, hides settings owned by richer editors, and no longer shows the Open File button.
|
- Fixed Settings window search so it searches across all categories, narrows on multi-word terms, hides settings owned by richer editors, and no longer shows the Open File button.
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
type: added
|
type: added
|
||||||
area: config
|
area: config
|
||||||
|
|
||||||
- Added a dedicated Configuration window with launcher entry points via `subminer --config` and `subminer config`.
|
- Added a dedicated Settings window with launcher entry points via `subminer --settings` and `subminer settings`.
|
||||||
- Fixed the Configuration window preload so launcher-opened windows can initialize even when Electron sandboxing is active.
|
- Fixed the Settings window preload so launcher-opened windows can initialize even when Electron sandboxing is active.
|
||||||
- Kept config-window startup lightweight by skipping AniList token refresh and automatic update polling.
|
- Kept settings-window startup lightweight by skipping AniList token refresh and automatic update polling.
|
||||||
- Marked safe live config options in the Configuration window and expanded hot reload for stats keys, logging level, Jimaku, Subsync, YouTube language defaults, Anki media/sentence/misc field mappings, sentence card model, and selected Anki annotation/runtime options.
|
- Marked safe live config options in the Settings window and expanded hot reload for stats keys, logging level, Jimaku, Subsync, YouTube language defaults, Anki media/sentence/misc field mappings, sentence card model, and selected Anki annotation/runtime options.
|
||||||
- Hid AI and translation fields from the Configuration window while keeping them supported in config files.
|
- Hid AI and translation fields from the Settings window while keeping them supported in config files.
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
type: changed
|
||||||
|
area: config
|
||||||
|
|
||||||
|
- Defaulted Jellyfin remote-session startup warmup and character-name subtitle highlighting to off.
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
type: fixed
|
||||||
|
area: launcher
|
||||||
|
|
||||||
|
- Kept launcher-opened videos paused when attaching to an already-running background app until subtitle priming and tokenization readiness complete.
|
||||||
|
- Moved mpv plugin subtitle auto-selection to pre-load so launch-time subtitle choices are not reset after the video opens.
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
type: changed
|
||||||
|
area: config
|
||||||
|
|
||||||
|
- Reorganized each known-words deck row in the Settings window into a card with the deck name on its own header line so longer deck names stay readable instead of being truncated.
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
type: fixed
|
type: fixed
|
||||||
area: launcher
|
area: launcher
|
||||||
|
|
||||||
- Suppressed Electron macOS menu diagnostics from `subminer config` launcher output.
|
- Suppressed Electron macOS menu diagnostics from `subminer settings` launcher output.
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
type: changed
|
||||||
|
area: setup
|
||||||
|
|
||||||
|
- Setup: Removed the bundled mpv runtime plugin readiness card; legacy mpv plugin removal still appears when needed.
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
type: changed
|
||||||
|
area: updater
|
||||||
|
|
||||||
|
- Linux tray "Check for Updates" now installs the new AppImage automatically via `electron-updater`, matching the macOS and Windows tray flow, instead of stopping at a "manual update required" dialog. AppImages managed by a system package (AUR `/opt/SubMiner/SubMiner.AppImage`) and non-AppImage launches (no `APPIMAGE` env) still fall back to the GitHub-asset flow.
|
||||||
|
- Routed `electron-updater` HTTP through `/usr/bin/curl` on Linux and disabled differential downloads, matching the macOS path, so background update checks stay off Electron's network service.
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
type: fixed
|
||||||
|
area: launcher
|
||||||
|
|
||||||
|
- macOS `subminer settings` launches now exit cleanly after the settings window is closed, returning control to the terminal without requiring Ctrl+C.
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
type: fixed
|
||||||
|
area: updater
|
||||||
|
|
||||||
|
- macOS update dialogs triggered by `subminer -u` now reliably appear in the foreground. SubMiner now shows the dock icon and activates itself via `osascript` (LaunchServices) before opening the modal alert; `app.focus({ steal: true })` alone was unreliable when SubMiner was reached through single-instance forwarding from the CLI-spawned child, leaving the dialog stranded behind other apps with a bouncing dock icon.
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
type: fixed
|
||||||
|
area: updater
|
||||||
|
|
||||||
|
- Routed macOS supplemental GitHub release lookups through `/usr/bin/curl` instead of Electron `net.fetch`, eliminating the last Electron-networking path from background update checks and avoiding the network-service crashes seen in earlier prereleases.
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
type: fixed
|
||||||
|
area: websocket
|
||||||
|
|
||||||
|
- WebSocket: Kept the regular subtitle websocket plain-text only; annotation spans and token metadata now stay on the annotation websocket.
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
type: changed
|
||||||
|
area: launcher
|
||||||
|
breaking: true
|
||||||
|
|
||||||
|
- Renamed the SubMiner Configuration window to the Settings window across the UI, tray menu, docs, and CLI verbiage.
|
||||||
|
- Replaced the `--config` flag and `subminer config` (no action) entry points with `--settings` and `subminer settings`. The `subminer config` subcommand now only accepts `path` or `show`.
|
||||||
|
- Removed the `--settings` alias that previously opened the bundled Yomitan settings popup. Use `--yomitan` to open Yomitan settings.
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
type: changed
|
||||||
|
area: config
|
||||||
|
|
||||||
|
- Settings: reorganized playback, shortcut, WebSocket, tracking, Jellyfin, character dictionary, and Discord presence controls in the settings modal.
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
type: added
|
||||||
|
area: setup
|
||||||
|
|
||||||
|
- Setup: Added an Open SubMiner Settings button to first-run setup and moved Finish setup to the right-side action slot.
|
||||||
@@ -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.
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
type: fixed
|
type: fixed
|
||||||
area: config
|
area: config
|
||||||
|
|
||||||
- Fixed live Configuration window saves so primary and secondary subtitle CSS declarations apply immediately to open video overlays.
|
- Fixed live Settings window saves so primary and secondary subtitle CSS declarations apply immediately to open video overlays.
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -439,7 +438,7 @@
|
|||||||
"autoOpen": false, // Automatically open the subtitle sidebar once during overlay startup. Values: true | false
|
"autoOpen": false, // Automatically open the subtitle sidebar once during overlay startup. Values: true | false
|
||||||
"layout": "overlay", // Render the subtitle sidebar as a floating overlay or reserve space inside mpv. Values: overlay | embedded
|
"layout": "overlay", // Render the subtitle sidebar as a floating overlay or reserve space inside mpv. Values: overlay | embedded
|
||||||
"toggleKey": "Backslash", // KeyboardEvent.code used to toggle the subtitle sidebar open and closed.
|
"toggleKey": "Backslash", // KeyboardEvent.code used to toggle the subtitle sidebar open and closed.
|
||||||
"pauseVideoOnHover": false, // Pause mpv while hovering the subtitle sidebar, then resume on leave. Values: true | false
|
"pauseVideoOnHover": true, // Pause mpv while hovering the subtitle sidebar, then resume on leave. Values: true | false
|
||||||
"autoScroll": true, // Auto-scroll the active subtitle cue into view while playback advances. Values: true | false
|
"autoScroll": true, // Auto-scroll the active subtitle cue into view while playback advances. Values: true | false
|
||||||
"css": {
|
"css": {
|
||||||
"font-family": "Hiragino Sans, M PLUS 1, Source Han Sans JP, Noto Sans CJK JP", // Font family setting.
|
"font-family": "Hiragino Sans, M PLUS 1, Source Han Sans JP, Noto Sans CJK JP", // Font family setting.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
+11
-13
@@ -37,7 +37,7 @@ Then customize as needed using the sections below.
|
|||||||
|
|
||||||
## Settings
|
## Settings
|
||||||
|
|
||||||
SubMiner includes a dedicated **Settings** window accessible from the tray menu, the app `--config` flag, or launcher commands such as `subminer --config` and `subminer config`. It is the primary way to configure SubMiner — all changes are written directly to `config.jsonc`, so manual file editing is not required for most users.
|
SubMiner includes a dedicated **Settings** window accessible from the tray menu, the app `--settings` flag, or launcher commands such as `subminer --settings` and `subminer settings`. It is the primary way to configure SubMiner — all changes are written directly to `config.jsonc`, so manual file editing is not required for most users.
|
||||||
|
|
||||||
The Settings window groups options by workflow instead of mirroring the raw config-file shape:
|
The Settings window groups options by workflow instead of mirroring the raw config-file shape:
|
||||||
|
|
||||||
@@ -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,11 +271,11 @@ 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
|
||||||
|
|
||||||
The overlay includes a built-in WebSocket server that broadcasts subtitle text to connected clients (such as texthooker-ui) for external processing.
|
The overlay includes a built-in WebSocket server that broadcasts plain subtitle text to connected clients for external processing.
|
||||||
|
|
||||||
For endpoint details, payload examples, and client patterns, see [WebSocket / Texthooker API & Integration](/websocket-texthooker-api).
|
For endpoint details, payload examples, and client patterns, see [WebSocket / Texthooker API & Integration](/websocket-texthooker-api).
|
||||||
|
|
||||||
@@ -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`) |
|
||||||
@@ -443,7 +443,7 @@ Configure the parsed-subtitle sidebar modal.
|
|||||||
"autoOpen": false,
|
"autoOpen": false,
|
||||||
"layout": "overlay",
|
"layout": "overlay",
|
||||||
"toggleKey": "Backslash",
|
"toggleKey": "Backslash",
|
||||||
"pauseVideoOnHover": false,
|
"pauseVideoOnHover": true,
|
||||||
"autoScroll": true,
|
"autoScroll": true,
|
||||||
"fontFamily": "\"M PLUS 1\", \"Noto Sans CJK JP\", sans-serif",
|
"fontFamily": "\"M PLUS 1\", \"Noto Sans CJK JP\", sans-serif",
|
||||||
"fontSize": 16
|
"fontSize": 16
|
||||||
@@ -457,7 +457,7 @@ Configure the parsed-subtitle sidebar modal.
|
|||||||
| `autoOpen` | boolean | Open sidebar automatically on overlay startup (`false` by default) |
|
| `autoOpen` | boolean | Open sidebar automatically on overlay startup (`false` by default) |
|
||||||
| `layout` | string | `"overlay"` floats over mpv; `"embedded"` reserves right-side player space to mimic browser-like layout |
|
| `layout` | string | `"overlay"` floats over mpv; `"embedded"` reserves right-side player space to mimic browser-like layout |
|
||||||
| `toggleKey` | string | `KeyboardEvent.code` used to open/close the sidebar (default: `"Backslash"`) |
|
| `toggleKey` | string | `KeyboardEvent.code` used to open/close the sidebar (default: `"Backslash"`) |
|
||||||
| `pauseVideoOnHover` | boolean | Pause playback while hovering the sidebar cue list |
|
| `pauseVideoOnHover` | boolean | Pause playback while hovering the sidebar cue list (`true` by default) |
|
||||||
| `autoScroll` | boolean | Keep the active cue in view while playback advances |
|
| `autoScroll` | boolean | Keep the active cue in view while playback advances |
|
||||||
| `maxWidth` | number | Maximum sidebar width in CSS pixels (default: `420`) |
|
| `maxWidth` | number | Maximum sidebar width in CSS pixels (default: `420`) |
|
||||||
| `opacity` | number | Sidebar opacity between `0` and `1` (default: `0.95`) |
|
| `opacity` | number | Sidebar opacity between `0` and `1` (default: `0.95`) |
|
||||||
@@ -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
@@ -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
@@ -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:
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -300,9 +300,9 @@ subminer --update
|
|||||||
|
|
||||||
SubMiner verifies AppImage, launcher, and rofi theme downloads against `SHA256SUMS.txt`. If the binary is in a protected path, SubMiner shows the exact command to run rather than elevating itself.
|
SubMiner verifies AppImage, launcher, and rofi theme downloads against `SHA256SUMS.txt`. If the binary is in a protected path, SubMiner shows the exact command to run rather than elevating itself.
|
||||||
|
|
||||||
On Linux, `subminer -u` performs the AppImage update from the launcher process directly.
|
The tray "Check for Updates" entry installs the new app automatically on Linux, macOS, and Windows. On Linux it replaces the running `.AppImage` in place via `electron-updater`; AppImages managed by a system package (for example the AUR `/opt/SubMiner/SubMiner.AppImage`) are skipped so the package manager stays in charge.
|
||||||
|
|
||||||
On macOS, tray update checks can also update the app automatically through Electron's built-in updater.
|
`subminer -u` also performs the AppImage update directly from the launcher process, which is useful when SubMiner is not currently running.
|
||||||
|
|
||||||
## How It All Fits Together
|
## How It All Fits Together
|
||||||
|
|
||||||
|
|||||||
@@ -77,6 +77,7 @@ subminer stats -b # start background stats daemon
|
|||||||
| `subminer stats -b` | Start or reuse background stats daemon (non-blocking) |
|
| `subminer stats -b` | Start or reuse background stats daemon (non-blocking) |
|
||||||
| `subminer stats cleanup` | Backfill vocabulary metadata and prune stale rows |
|
| `subminer stats cleanup` | Backfill vocabulary metadata and prune stale rows |
|
||||||
| `subminer doctor` | Dependency + config + socket diagnostics |
|
| `subminer doctor` | Dependency + config + socket diagnostics |
|
||||||
|
| `subminer settings` | Open the SubMiner settings window |
|
||||||
| `subminer config path` | Print active config file path |
|
| `subminer config path` | Print active config file path |
|
||||||
| `subminer config show` | Print active config contents |
|
| `subminer config show` | Print active config contents |
|
||||||
| `subminer mpv status` | Check mpv socket readiness |
|
| `subminer mpv status` | Check mpv socket readiness |
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -439,7 +438,7 @@
|
|||||||
"autoOpen": false, // Automatically open the subtitle sidebar once during overlay startup. Values: true | false
|
"autoOpen": false, // Automatically open the subtitle sidebar once during overlay startup. Values: true | false
|
||||||
"layout": "overlay", // Render the subtitle sidebar as a floating overlay or reserve space inside mpv. Values: overlay | embedded
|
"layout": "overlay", // Render the subtitle sidebar as a floating overlay or reserve space inside mpv. Values: overlay | embedded
|
||||||
"toggleKey": "Backslash", // KeyboardEvent.code used to toggle the subtitle sidebar open and closed.
|
"toggleKey": "Backslash", // KeyboardEvent.code used to toggle the subtitle sidebar open and closed.
|
||||||
"pauseVideoOnHover": false, // Pause mpv while hovering the subtitle sidebar, then resume on leave. Values: true | false
|
"pauseVideoOnHover": true, // Pause mpv while hovering the subtitle sidebar, then resume on leave. Values: true | false
|
||||||
"autoScroll": true, // Auto-scroll the active subtitle cue into view while playback advances. Values: true | false
|
"autoScroll": true, // Auto-scroll the active subtitle cue into view while playback advances. Values: true | false
|
||||||
"css": {
|
"css": {
|
||||||
"font-family": "Hiragino Sans, M PLUS 1, Source Han Sans JP, Noto Sans CJK JP", // Font family setting.
|
"font-family": "Hiragino Sans, M PLUS 1, Source Han Sans JP, Noto Sans CJK JP", // Font family setting.
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ Enable and configure the sidebar under `subtitleSidebar` in your config file:
|
|||||||
"autoOpen": false,
|
"autoOpen": false,
|
||||||
"layout": "overlay",
|
"layout": "overlay",
|
||||||
"toggleKey": "Backslash",
|
"toggleKey": "Backslash",
|
||||||
"pauseVideoOnHover": false,
|
"pauseVideoOnHover": true,
|
||||||
"autoScroll": true,
|
"autoScroll": true,
|
||||||
"fontFamily": "\"M PLUS 1\", \"Noto Sans CJK JP\", sans-serif",
|
"fontFamily": "\"M PLUS 1\", \"Noto Sans CJK JP\", sans-serif",
|
||||||
"fontSize": 16
|
"fontSize": 16
|
||||||
@@ -47,7 +47,7 @@ Enable and configure the sidebar under `subtitleSidebar` in your config file:
|
|||||||
| `autoOpen` | boolean | `false` | Open the sidebar automatically on overlay startup |
|
| `autoOpen` | boolean | `false` | Open the sidebar automatically on overlay startup |
|
||||||
| `layout` | string | `"overlay"` | `"overlay"` floats over mpv; `"embedded"` reserves right-side player space |
|
| `layout` | string | `"overlay"` | `"overlay"` floats over mpv; `"embedded"` reserves right-side player space |
|
||||||
| `toggleKey` | string | `"Backslash"` | `KeyboardEvent.code` for the toggle shortcut |
|
| `toggleKey` | string | `"Backslash"` | `KeyboardEvent.code` for the toggle shortcut |
|
||||||
| `pauseVideoOnHover` | boolean | `false` | Pause playback while hovering the cue list |
|
| `pauseVideoOnHover` | boolean | `true` | Pause playback while hovering the cue list |
|
||||||
| `autoScroll` | boolean | `true` | Keep the active cue in view during playback |
|
| `autoScroll` | boolean | `true` | Keep the active cue in view during playback |
|
||||||
| `maxWidth` | number | `420` | Maximum sidebar width in CSS pixels |
|
| `maxWidth` | number | `420` | Maximum sidebar width in CSS pixels |
|
||||||
| `opacity` | number | `0.95` | Sidebar opacity between `0` and `1` |
|
| `opacity` | number | `0.95` | Sidebar opacity between `0` and `1` |
|
||||||
|
|||||||
@@ -205,7 +205,7 @@ If you installed from the AppImage and see this error, the package may be incomp
|
|||||||
**Yomitan lookup popup does not appear when hovering words or triggering lookup**
|
**Yomitan lookup popup does not appear when hovering words or triggering lookup**
|
||||||
|
|
||||||
- Verify Yomitan loaded successfully — check the terminal output for "Loaded Yomitan extension".
|
- Verify Yomitan loaded successfully — check the terminal output for "Loaded Yomitan extension".
|
||||||
- Yomitan requires dictionaries to be installed. Open Yomitan settings (`Alt+Shift+Y` or `SubMiner.AppImage --settings`) and confirm at least one dictionary is imported.
|
- Yomitan requires dictionaries to be installed. Open Yomitan settings (`Alt+Shift+Y` or `SubMiner.AppImage --yomitan`) and confirm at least one dictionary is imported.
|
||||||
- If `yomitan.externalProfilePath` is set, import/check dictionaries in the external app/profile instead. SubMiner treats that profile as read-only and does not open its own Yomitan settings window.
|
- If `yomitan.externalProfilePath` is set, import/check dictionaries in the external app/profile instead. SubMiner treats that profile as read-only and does not open its own Yomitan settings window.
|
||||||
- If the overlay shows subtitles but hover lookup never resolves on tokens, the tokenizer may have failed. See the MeCab section below.
|
- If the overlay shows subtitles but hover lookup never resolves on tokens, the tokenizer may have failed. See the MeCab section below.
|
||||||
|
|
||||||
@@ -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).
|
||||||
|
|||||||
+5
-3
@@ -131,7 +131,8 @@ SubMiner.AppImage --toggle-primary-subtitle-bar # Toggle primary subtitle
|
|||||||
SubMiner.AppImage --start --dev # Enable app/dev mode only
|
SubMiner.AppImage --start --dev # Enable app/dev mode only
|
||||||
SubMiner.AppImage --start --debug # Alias for --dev
|
SubMiner.AppImage --start --debug # Alias for --dev
|
||||||
SubMiner.AppImage --start --log-level debug # Force verbose logging without app/dev mode
|
SubMiner.AppImage --start --log-level debug # Force verbose logging without app/dev mode
|
||||||
SubMiner.AppImage --settings # Open Yomitan settings
|
SubMiner.AppImage --yomitan # Open Yomitan settings
|
||||||
|
SubMiner.AppImage --settings # Open SubMiner settings window
|
||||||
SubMiner.AppImage --jellyfin # Open Jellyfin setup window
|
SubMiner.AppImage --jellyfin # Open Jellyfin setup window
|
||||||
SubMiner.AppImage --jellyfin-login --jellyfin-server http://127.0.0.1:8096 --jellyfin-username me --jellyfin-password 'secret'
|
SubMiner.AppImage --jellyfin-login --jellyfin-server http://127.0.0.1:8096 --jellyfin-username me --jellyfin-password 'secret'
|
||||||
SubMiner.AppImage --jellyfin-logout # Clear stored Jellyfin token/session data
|
SubMiner.AppImage --jellyfin-logout # Clear stored Jellyfin token/session data
|
||||||
@@ -184,7 +185,8 @@ This flow requires `mpv.exe` to be discoverable. Leave `mpv.executablePath` blan
|
|||||||
|
|
||||||
- `subminer jellyfin` / `subminer jf`: Jellyfin-focused workflow aliases.
|
- `subminer jellyfin` / `subminer jf`: Jellyfin-focused workflow aliases.
|
||||||
- `subminer doctor`: health checks for core dependencies and runtime paths.
|
- `subminer doctor`: health checks for core dependencies and runtime paths.
|
||||||
- `subminer config`: config helpers (`path`, `show`).
|
- `subminer settings`: open the SubMiner settings window (also `subminer --settings`).
|
||||||
|
- `subminer config`: config file helpers (`path`, `show`).
|
||||||
- `subminer mpv`: mpv helpers (`status`, `socket`, `idle`).
|
- `subminer mpv`: mpv helpers (`status`, `socket`, `idle`).
|
||||||
- `subminer dictionary <path>`: generates a Yomitan-importable character dictionary ZIP from a file/directory target.
|
- `subminer dictionary <path>`: generates a Yomitan-importable character dictionary ZIP from a file/directory target.
|
||||||
- Use `subminer dictionary --candidates <path>` and `subminer dictionary --select <id> <path>` to correct AniList character-dictionary matches for a whole series.
|
- Use `subminer dictionary --candidates <path>` and `subminer dictionary --select <id> <path>` to correct AniList character-dictionary matches for a whole series.
|
||||||
@@ -264,7 +266,7 @@ secondary-sub-visibility=no
|
|||||||
|
|
||||||
SubMiner includes a bundled Yomitan extension for overlay word lookup. This bundled extension is separate from any Yomitan browser extension you may have installed.
|
SubMiner includes a bundled Yomitan extension for overlay word lookup. This bundled extension is separate from any Yomitan browser extension you may have installed.
|
||||||
|
|
||||||
For SubMiner overlay lookups to work, open Yomitan settings (`subminer app --settings` or `SubMiner.AppImage --settings`) and import at least one dictionary in the bundled Yomitan instance.
|
For SubMiner overlay lookups to work, open Yomitan settings (`subminer app --yomitan` or `SubMiner.AppImage --yomitan`) and import at least one dictionary in the bundled Yomitan instance.
|
||||||
|
|
||||||
If you also use Yomitan in a browser, configure that browser profile separately; it does not inherit dictionaries or settings from the bundled instance.
|
If you also use Yomitan in a browser, configure that browser profile separately; it does not inherit dictionaries or settings from the bundled instance.
|
||||||
|
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ If you use the [mpv plugin](/mpv-plugin), it can also start a texthooker-only he
|
|||||||
|
|
||||||
### 1. Subtitle WebSocket
|
### 1. Subtitle WebSocket
|
||||||
|
|
||||||
Use the basic subtitle websocket when you only need the current subtitle line and a ready-to-render HTML sentence string.
|
Use the basic subtitle websocket when you only need the current subtitle line as plain text.
|
||||||
|
|
||||||
- **Default URL:** `ws://127.0.0.1:6677`
|
- **Default URL:** `ws://127.0.0.1:6677`
|
||||||
- **Transport:** local WebSocket server bound to `127.0.0.1`
|
- **Transport:** local WebSocket server bound to `127.0.0.1`
|
||||||
@@ -64,6 +64,36 @@ When a client connects, SubMiner immediately sends the latest subtitle payload i
|
|||||||
|
|
||||||
#### Message shape
|
#### Message shape
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"version": 1,
|
||||||
|
"text": "無事",
|
||||||
|
"sentence": "無事",
|
||||||
|
"tokens": []
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Field reference
|
||||||
|
|
||||||
|
| Field | Type | Notes |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `version` | number | Current websocket payload version. Today this is `1`. |
|
||||||
|
| `text` | string | Raw subtitle text. |
|
||||||
|
| `sentence` | string | Plain subtitle text with line breaks represented as `<br>`. No annotation spans or attributes. |
|
||||||
|
| `tokens` | array | Always empty on the basic subtitle websocket. |
|
||||||
|
|
||||||
|
### 2. Annotation WebSocket
|
||||||
|
|
||||||
|
Use the annotation websocket for custom clients that want the same structured token payload the bundled texthooker UI consumes.
|
||||||
|
|
||||||
|
- **Default URL:** `ws://127.0.0.1:6678`
|
||||||
|
- **Payload shape:** JSON payload with `text`, rendered `sentence` HTML, and token metadata
|
||||||
|
- **Primary difference:** this stream is intended to stay on even when the basic websocket auto-disables because `mpv_websocket` is installed
|
||||||
|
|
||||||
|
In practice, if you are building a new client, prefer `annotationWebsocket` unless you specifically need compatibility with an existing `websocket` consumer.
|
||||||
|
|
||||||
|
#### Message shape
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"version": 1,
|
"version": 1,
|
||||||
@@ -91,16 +121,7 @@ When a client connects, SubMiner immediately sends the latest subtitle payload i
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
#### Field reference
|
Each annotation token may include:
|
||||||
|
|
||||||
| Field | Type | Notes |
|
|
||||||
| --- | --- | --- |
|
|
||||||
| `version` | number | Current websocket payload version. Today this is `1`. |
|
|
||||||
| `text` | string | Raw subtitle text. |
|
|
||||||
| `sentence` | string | HTML string with `<span>` wrappers and `data-*` attributes for client rendering. |
|
|
||||||
| `tokens` | array | Token metadata; empty when the subtitle is not tokenized yet. |
|
|
||||||
|
|
||||||
Each token may include:
|
|
||||||
|
|
||||||
| Token field | Type | Notes |
|
| Token field | Type | Notes |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
@@ -119,16 +140,6 @@ Each token may include:
|
|||||||
| `frequencyRankLabel` | string or `null` | Preformatted rank label for UIs |
|
| `frequencyRankLabel` | string or `null` | Preformatted rank label for UIs |
|
||||||
| `jlptLevelLabel` | string or `null` | Preformatted JLPT label for UIs |
|
| `jlptLevelLabel` | string or `null` | Preformatted JLPT label for UIs |
|
||||||
|
|
||||||
### 2. Annotation WebSocket
|
|
||||||
|
|
||||||
Use the annotation websocket for custom clients that want the same structured token payload the bundled texthooker UI consumes.
|
|
||||||
|
|
||||||
- **Default URL:** `ws://127.0.0.1:6678`
|
|
||||||
- **Payload shape:** same JSON contract as the basic subtitle websocket
|
|
||||||
- **Primary difference:** this stream is intended to stay on even when the basic websocket auto-disables because `mpv_websocket` is installed
|
|
||||||
|
|
||||||
In practice, if you are building a new client, prefer `annotationWebsocket` unless you specifically need compatibility with an existing `websocket` consumer.
|
|
||||||
|
|
||||||
### 3. HTML markup conventions
|
### 3. HTML markup conventions
|
||||||
|
|
||||||
The `sentence` field is pre-rendered HTML generated by SubMiner. Depending on token state, it can include classes such as:
|
The `sentence` field is pre-rendered HTML generated by SubMiner. Depending on token state, it can include classes such as:
|
||||||
|
|||||||
@@ -6,8 +6,8 @@ export function runAppPassthroughCommand(context: LauncherCommandContext): boole
|
|||||||
if (!appPath) {
|
if (!appPath) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
if (args.configSettings) {
|
if (args.settings) {
|
||||||
runAppCommandWithInherit(appPath, ['--config']);
|
runAppCommandWithInherit(appPath, ['--settings']);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
if (!args.appPassthrough) {
|
if (!args.appPassthrough) {
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
import test from 'node:test';
|
import test from 'node:test';
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import { EventEmitter } from 'node:events';
|
import { EventEmitter } from 'node:events';
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import os from 'node:os';
|
||||||
|
import path from 'node:path';
|
||||||
import type { LauncherCommandContext } from './context.js';
|
import type { LauncherCommandContext } from './context.js';
|
||||||
import { runPlaybackCommandWithDeps } from './playback-command.js';
|
import { runPlaybackCommandWithDeps } from './playback-command.js';
|
||||||
import { state } from '../mpv.js';
|
import { state } from '../mpv.js';
|
||||||
@@ -53,7 +56,7 @@ function createContext(): LauncherCommandContext {
|
|||||||
doctor: false,
|
doctor: false,
|
||||||
doctorRefreshKnownWords: false,
|
doctorRefreshKnownWords: false,
|
||||||
version: false,
|
version: false,
|
||||||
configSettings: false,
|
settings: false,
|
||||||
configPath: false,
|
configPath: false,
|
||||||
configShow: false,
|
configShow: false,
|
||||||
mpvIdle: false,
|
mpvIdle: false,
|
||||||
@@ -151,6 +154,7 @@ test('plugin auto-start playback leaves app lifetime to managed-playback owner',
|
|||||||
...context.args,
|
...context.args,
|
||||||
target: '/tmp/movie.mkv',
|
target: '/tmp/movie.mkv',
|
||||||
targetKind: 'file',
|
targetKind: 'file',
|
||||||
|
useTexthooker: true,
|
||||||
};
|
};
|
||||||
context.pluginRuntimeConfig = {
|
context.pluginRuntimeConfig = {
|
||||||
socketPath: '/tmp/subminer.sock',
|
socketPath: '/tmp/subminer.sock',
|
||||||
@@ -206,3 +210,172 @@ test('plugin auto-start playback leaves app lifetime to managed-playback owner',
|
|||||||
state.overlayManagedByLauncher = false;
|
state.overlayManagedByLauncher = false;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('plugin auto-start playback attaches a warm background app through the launcher', async () => {
|
||||||
|
const context = createContext();
|
||||||
|
context.args = {
|
||||||
|
...context.args,
|
||||||
|
target: '/tmp/movie.mkv',
|
||||||
|
targetKind: 'file',
|
||||||
|
useTexthooker: true,
|
||||||
|
};
|
||||||
|
context.pluginRuntimeConfig = {
|
||||||
|
socketPath: '/tmp/subminer.sock',
|
||||||
|
binaryPath: '',
|
||||||
|
backend: 'auto',
|
||||||
|
autoStart: true,
|
||||||
|
autoStartVisibleOverlay: true,
|
||||||
|
autoStartPauseUntilReady: true,
|
||||||
|
texthookerEnabled: true,
|
||||||
|
aniskipEnabled: true,
|
||||||
|
aniskipButtonKey: 'TAB',
|
||||||
|
};
|
||||||
|
const calls: string[] = [];
|
||||||
|
const receivedStartMpvOptions: Record<string, unknown>[] = [];
|
||||||
|
|
||||||
|
await runPlaybackCommandWithDeps(context, {
|
||||||
|
ensurePlaybackSetupReady: async () => {},
|
||||||
|
chooseTarget: async () => ({ target: context.args.target, kind: 'file' }),
|
||||||
|
checkDependencies: () => {},
|
||||||
|
registerCleanup: () => {},
|
||||||
|
startMpv: async (
|
||||||
|
_target,
|
||||||
|
_targetKind,
|
||||||
|
_args,
|
||||||
|
_socketPath,
|
||||||
|
_appPath,
|
||||||
|
_preloadedSubtitles,
|
||||||
|
options,
|
||||||
|
) => {
|
||||||
|
calls.push('startMpv');
|
||||||
|
if (options) {
|
||||||
|
receivedStartMpvOptions.push(options as Record<string, unknown>);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
waitForUnixSocketReady: async () => true,
|
||||||
|
startOverlay: async (_appPath, _args, _socketPath, extraAppArgs = []) => {
|
||||||
|
calls.push(`startOverlay:${extraAppArgs.join(' ')}`);
|
||||||
|
},
|
||||||
|
launchAppCommandDetached: () => {},
|
||||||
|
log: () => {},
|
||||||
|
cleanupPlaybackSession: async () => {},
|
||||||
|
getMpvProc: () => null,
|
||||||
|
isAppControlServerAvailable: async () => true,
|
||||||
|
} as Parameters<typeof runPlaybackCommandWithDeps>[1] & {
|
||||||
|
isAppControlServerAvailable: () => Promise<boolean>;
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.deepEqual(calls, ['startMpv', 'startOverlay:--show-visible-overlay --texthooker']);
|
||||||
|
assert.equal(receivedStartMpvOptions[0]?.startPaused, true);
|
||||||
|
assert.equal(
|
||||||
|
(receivedStartMpvOptions[0]?.runtimePluginConfig as { autoStart?: boolean } | undefined)
|
||||||
|
?.autoStart,
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('plugin auto-start attach mode reuses launcher-resolved config dir for app control', async () => {
|
||||||
|
const context = createContext();
|
||||||
|
const originalXdgConfigHome = process.env.XDG_CONFIG_HOME;
|
||||||
|
const xdgConfigHome = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-test-xdg-'));
|
||||||
|
const expectedConfigDir = path.join(xdgConfigHome, 'SubMiner');
|
||||||
|
fs.mkdirSync(expectedConfigDir, { recursive: true });
|
||||||
|
fs.writeFileSync(path.join(expectedConfigDir, 'config.jsonc'), '{}');
|
||||||
|
context.args = {
|
||||||
|
...context.args,
|
||||||
|
target: '/tmp/movie.mkv',
|
||||||
|
targetKind: 'file',
|
||||||
|
useTexthooker: true,
|
||||||
|
};
|
||||||
|
context.pluginRuntimeConfig = {
|
||||||
|
socketPath: '/tmp/subminer.sock',
|
||||||
|
binaryPath: '',
|
||||||
|
backend: 'auto',
|
||||||
|
autoStart: true,
|
||||||
|
autoStartVisibleOverlay: true,
|
||||||
|
autoStartPauseUntilReady: true,
|
||||||
|
texthookerEnabled: true,
|
||||||
|
aniskipEnabled: true,
|
||||||
|
aniskipButtonKey: 'TAB',
|
||||||
|
};
|
||||||
|
let availabilityConfigDir: string | undefined;
|
||||||
|
let overlayConfigDir: string | undefined;
|
||||||
|
|
||||||
|
try {
|
||||||
|
process.env.XDG_CONFIG_HOME = xdgConfigHome;
|
||||||
|
|
||||||
|
await runPlaybackCommandWithDeps(context, {
|
||||||
|
ensurePlaybackSetupReady: async () => {},
|
||||||
|
chooseTarget: async () => ({ target: context.args.target, kind: 'file' }),
|
||||||
|
checkDependencies: () => {},
|
||||||
|
registerCleanup: () => {},
|
||||||
|
startMpv: async () => {},
|
||||||
|
waitForUnixSocketReady: async () => true,
|
||||||
|
startOverlay: async (_appPath, _args, _socketPath, _extraAppArgs = [], configDir) => {
|
||||||
|
overlayConfigDir = configDir;
|
||||||
|
},
|
||||||
|
launchAppCommandDetached: () => {},
|
||||||
|
log: () => {},
|
||||||
|
cleanupPlaybackSession: async () => {},
|
||||||
|
getMpvProc: () => null,
|
||||||
|
isAppControlServerAvailable: async (_logLevel, configDir) => {
|
||||||
|
availabilityConfigDir = configDir;
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(availabilityConfigDir, expectedConfigDir);
|
||||||
|
assert.equal(overlayConfigDir, expectedConfigDir);
|
||||||
|
} finally {
|
||||||
|
if (originalXdgConfigHome === undefined) {
|
||||||
|
delete process.env.XDG_CONFIG_HOME;
|
||||||
|
} else {
|
||||||
|
process.env.XDG_CONFIG_HOME = originalXdgConfigHome;
|
||||||
|
}
|
||||||
|
fs.rmSync(xdgConfigHome, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('plugin auto-start attach mode omits texthooker flag when CLI texthooker is disabled', async () => {
|
||||||
|
const context = createContext();
|
||||||
|
context.args = {
|
||||||
|
...context.args,
|
||||||
|
target: '/tmp/movie.mkv',
|
||||||
|
targetKind: 'file',
|
||||||
|
};
|
||||||
|
context.pluginRuntimeConfig = {
|
||||||
|
socketPath: '/tmp/subminer.sock',
|
||||||
|
binaryPath: '',
|
||||||
|
backend: 'auto',
|
||||||
|
autoStart: true,
|
||||||
|
autoStartVisibleOverlay: true,
|
||||||
|
autoStartPauseUntilReady: true,
|
||||||
|
texthookerEnabled: true,
|
||||||
|
aniskipEnabled: true,
|
||||||
|
aniskipButtonKey: 'TAB',
|
||||||
|
};
|
||||||
|
const calls: string[] = [];
|
||||||
|
|
||||||
|
await runPlaybackCommandWithDeps(context, {
|
||||||
|
ensurePlaybackSetupReady: async () => {},
|
||||||
|
chooseTarget: async () => ({ target: context.args.target, kind: 'file' }),
|
||||||
|
checkDependencies: () => {},
|
||||||
|
registerCleanup: () => {},
|
||||||
|
startMpv: async () => {
|
||||||
|
calls.push('startMpv');
|
||||||
|
},
|
||||||
|
waitForUnixSocketReady: async () => true,
|
||||||
|
startOverlay: async (_appPath, _args, _socketPath, extraAppArgs = []) => {
|
||||||
|
calls.push(`startOverlay:${extraAppArgs.join(' ')}`);
|
||||||
|
},
|
||||||
|
launchAppCommandDetached: () => {},
|
||||||
|
log: () => {},
|
||||||
|
cleanupPlaybackSession: async () => {},
|
||||||
|
getMpvProc: () => null,
|
||||||
|
isAppControlServerAvailable: async () => true,
|
||||||
|
} as Parameters<typeof runPlaybackCommandWithDeps>[1] & {
|
||||||
|
isAppControlServerAvailable: () => Promise<boolean>;
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.deepEqual(calls, ['startMpv', 'startOverlay:--show-visible-overlay']);
|
||||||
|
});
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
cleanupPlaybackSession,
|
cleanupPlaybackSession,
|
||||||
launchAppCommandDetached,
|
launchAppCommandDetached,
|
||||||
resolveLauncherRuntimePluginPath,
|
resolveLauncherRuntimePluginPath,
|
||||||
|
isRunningAppControlServerAvailable,
|
||||||
startMpv,
|
startMpv,
|
||||||
startOverlay,
|
startOverlay,
|
||||||
state,
|
state,
|
||||||
@@ -29,6 +30,13 @@ import { hasLauncherExternalYomitanProfileConfig } from '../config.js';
|
|||||||
const SETUP_WAIT_TIMEOUT_MS = 10 * 60 * 1000;
|
const SETUP_WAIT_TIMEOUT_MS = 10 * 60 * 1000;
|
||||||
const SETUP_POLL_INTERVAL_MS = 500;
|
const SETUP_POLL_INTERVAL_MS = 500;
|
||||||
|
|
||||||
|
function getLauncherConfigDir(): string {
|
||||||
|
return getDefaultConfigDir({
|
||||||
|
xdgConfigHome: process.env.XDG_CONFIG_HOME,
|
||||||
|
homeDir: os.homedir(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function checkDependencies(args: Args): void {
|
function checkDependencies(args: Args): void {
|
||||||
const missing: string[] = [];
|
const missing: string[] = [];
|
||||||
|
|
||||||
@@ -99,10 +107,7 @@ async function ensurePlaybackSetupReady(context: LauncherCommandContext): Promis
|
|||||||
const { args, appPath } = context;
|
const { args, appPath } = context;
|
||||||
if (!appPath) return;
|
if (!appPath) return;
|
||||||
|
|
||||||
const configDir = getDefaultConfigDir({
|
const configDir = getLauncherConfigDir();
|
||||||
xdgConfigHome: process.env.XDG_CONFIG_HOME,
|
|
||||||
homeDir: os.homedir(),
|
|
||||||
});
|
|
||||||
const statePath = getSetupStatePath(configDir);
|
const statePath = getSetupStatePath(configDir);
|
||||||
const ready = await ensureLauncherSetupReady({
|
const ready = await ensureLauncherSetupReady({
|
||||||
readSetupState: () => readSetupState(statePath),
|
readSetupState: () => readSetupState(statePath),
|
||||||
@@ -146,6 +151,7 @@ export async function runPlaybackCommand(context: LauncherCommandContext): Promi
|
|||||||
waitForUnixSocketReady,
|
waitForUnixSocketReady,
|
||||||
startOverlay,
|
startOverlay,
|
||||||
launchAppCommandDetached,
|
launchAppCommandDetached,
|
||||||
|
isAppControlServerAvailable: isRunningAppControlServerAvailable,
|
||||||
log,
|
log,
|
||||||
cleanupPlaybackSession,
|
cleanupPlaybackSession,
|
||||||
getMpvProc: () => state.mpvProc,
|
getMpvProc: () => state.mpvProc,
|
||||||
@@ -164,6 +170,7 @@ type PlaybackCommandDeps = {
|
|||||||
waitForUnixSocketReady: typeof waitForUnixSocketReady;
|
waitForUnixSocketReady: typeof waitForUnixSocketReady;
|
||||||
startOverlay: typeof startOverlay;
|
startOverlay: typeof startOverlay;
|
||||||
launchAppCommandDetached: typeof launchAppCommandDetached;
|
launchAppCommandDetached: typeof launchAppCommandDetached;
|
||||||
|
isAppControlServerAvailable?: (logLevel: Args['logLevel'], configDir: string) => Promise<boolean>;
|
||||||
log: typeof log;
|
log: typeof log;
|
||||||
cleanupPlaybackSession: typeof cleanupPlaybackSession;
|
cleanupPlaybackSession: typeof cleanupPlaybackSession;
|
||||||
getMpvProc: () => typeof state.mpvProc;
|
getMpvProc: () => typeof state.mpvProc;
|
||||||
@@ -208,11 +215,23 @@ export async function runPlaybackCommandWithDeps(
|
|||||||
const isYoutubeUrl = selectedTarget.kind === 'url' && isYoutubeTarget(selectedTarget.target);
|
const isYoutubeUrl = selectedTarget.kind === 'url' && isYoutubeTarget(selectedTarget.target);
|
||||||
const isAppOwnedYoutubeFlow = isYoutubeUrl;
|
const isAppOwnedYoutubeFlow = isYoutubeUrl;
|
||||||
const youtubeMode = args.youtubeMode ?? 'download';
|
const youtubeMode = args.youtubeMode ?? 'download';
|
||||||
|
const configDir = getLauncherConfigDir();
|
||||||
|
|
||||||
if (isYoutubeUrl) {
|
if (isYoutubeUrl) {
|
||||||
deps.log('info', args.logLevel, 'YouTube subtitle flow: app-owned picker after mpv bootstrap');
|
deps.log('info', args.logLevel, 'YouTube subtitle flow: app-owned picker after mpv bootstrap');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const pluginAutoStartEnabled = pluginRuntimeConfig.autoStart;
|
||||||
|
const shouldLauncherAttachRunningApp =
|
||||||
|
pluginAutoStartEnabled &&
|
||||||
|
!args.startOverlay &&
|
||||||
|
!args.autoStartOverlay &&
|
||||||
|
!isAppOwnedYoutubeFlow &&
|
||||||
|
((await deps.isAppControlServerAvailable?.(args.logLevel, configDir)) ?? false);
|
||||||
|
const effectivePluginRuntimeConfig = shouldLauncherAttachRunningApp
|
||||||
|
? { ...pluginRuntimeConfig, autoStart: false }
|
||||||
|
: pluginRuntimeConfig;
|
||||||
|
|
||||||
const shouldPauseUntilOverlayReady =
|
const shouldPauseUntilOverlayReady =
|
||||||
pluginRuntimeConfig.autoStart &&
|
pluginRuntimeConfig.autoStart &&
|
||||||
pluginRuntimeConfig.autoStartVisibleOverlay &&
|
pluginRuntimeConfig.autoStartVisibleOverlay &&
|
||||||
@@ -238,16 +257,19 @@ export async function runPlaybackCommandWithDeps(
|
|||||||
disableYoutubeSubtitleAutoLoad: isAppOwnedYoutubeFlow,
|
disableYoutubeSubtitleAutoLoad: isAppOwnedYoutubeFlow,
|
||||||
runtimePluginPath: resolveLauncherRuntimePluginPath({ appPath, scriptPath }),
|
runtimePluginPath: resolveLauncherRuntimePluginPath({ appPath, scriptPath }),
|
||||||
runtimePluginConfig: {
|
runtimePluginConfig: {
|
||||||
...pluginRuntimeConfig,
|
...effectivePluginRuntimeConfig,
|
||||||
backend: args.backend,
|
backend: args.backend,
|
||||||
texthookerEnabled: args.useTexthooker && pluginRuntimeConfig.texthookerEnabled,
|
texthookerEnabled: args.useTexthooker && effectivePluginRuntimeConfig.texthookerEnabled,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
const ready = await deps.waitForUnixSocketReady(mpvSocketPath, 10000);
|
const ready = await deps.waitForUnixSocketReady(mpvSocketPath, 10000);
|
||||||
const pluginAutoStartEnabled = pluginRuntimeConfig.autoStart;
|
const shouldStartOverlay =
|
||||||
const shouldStartOverlay = args.startOverlay || args.autoStartOverlay || isAppOwnedYoutubeFlow;
|
args.startOverlay ||
|
||||||
|
args.autoStartOverlay ||
|
||||||
|
isAppOwnedYoutubeFlow ||
|
||||||
|
shouldLauncherAttachRunningApp;
|
||||||
if (shouldStartOverlay) {
|
if (shouldStartOverlay) {
|
||||||
if (ready) {
|
if (ready) {
|
||||||
deps.log('info', args.logLevel, 'MPV IPC socket ready, starting SubMiner overlay');
|
deps.log('info', args.logLevel, 'MPV IPC socket ready, starting SubMiner overlay');
|
||||||
@@ -258,14 +280,19 @@ export async function runPlaybackCommandWithDeps(
|
|||||||
'MPV IPC socket not ready after timeout, starting SubMiner overlay anyway',
|
'MPV IPC socket not ready after timeout, starting SubMiner overlay anyway',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
await deps.startOverlay(
|
const extraAppArgs = isAppOwnedYoutubeFlow
|
||||||
appPath,
|
? ['--youtube-play', selectedTarget.target, '--youtube-mode', youtubeMode]
|
||||||
args,
|
: shouldLauncherAttachRunningApp
|
||||||
mpvSocketPath,
|
? [
|
||||||
isAppOwnedYoutubeFlow
|
pluginRuntimeConfig.autoStartVisibleOverlay
|
||||||
? ['--youtube-play', selectedTarget.target, '--youtube-mode', youtubeMode]
|
? '--show-visible-overlay'
|
||||||
: [],
|
: '--hide-visible-overlay',
|
||||||
);
|
...(args.useTexthooker && effectivePluginRuntimeConfig.texthookerEnabled
|
||||||
|
? ['--texthooker']
|
||||||
|
: []),
|
||||||
|
]
|
||||||
|
: [];
|
||||||
|
await deps.startOverlay(appPath, args, mpvSocketPath, extraAppArgs, configDir);
|
||||||
} else if (pluginAutoStartEnabled) {
|
} else if (pluginAutoStartEnabled) {
|
||||||
if (ready) {
|
if (ready) {
|
||||||
deps.log('info', args.logLevel, 'MPV IPC socket ready, relying on mpv plugin auto-start');
|
deps.log('info', args.logLevel, 'MPV IPC socket ready, relying on mpv plugin auto-start');
|
||||||
|
|||||||
@@ -124,6 +124,7 @@ test('applyInvocationsToArgs maps config and jellyfin invocation state', () => {
|
|||||||
action: 'show',
|
action: 'show',
|
||||||
logLevel: 'warn',
|
logLevel: 'warn',
|
||||||
},
|
},
|
||||||
|
settingsInvocation: null,
|
||||||
mpvInvocation: null,
|
mpvInvocation: null,
|
||||||
appInvocation: null,
|
appInvocation: null,
|
||||||
dictionaryTriggered: false,
|
dictionaryTriggered: false,
|
||||||
@@ -159,13 +160,14 @@ test('applyInvocationsToArgs maps config and jellyfin invocation state', () => {
|
|||||||
assert.equal(parsed.logLevel, 'warn');
|
assert.equal(parsed.logLevel, 'warn');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('applyInvocationsToArgs maps bare config invocation to settings window', () => {
|
test('applyInvocationsToArgs maps settings invocation to settings window', () => {
|
||||||
const parsed = createDefaultArgs({});
|
const parsed = createDefaultArgs({});
|
||||||
|
|
||||||
applyInvocationsToArgs(parsed, {
|
applyInvocationsToArgs(parsed, {
|
||||||
jellyfinInvocation: null,
|
jellyfinInvocation: null,
|
||||||
configInvocation: {
|
configInvocation: null,
|
||||||
action: undefined,
|
settingsInvocation: {
|
||||||
|
logLevel: undefined,
|
||||||
},
|
},
|
||||||
mpvInvocation: null,
|
mpvInvocation: null,
|
||||||
appInvocation: null,
|
appInvocation: null,
|
||||||
@@ -190,16 +192,54 @@ test('applyInvocationsToArgs maps bare config invocation to settings window', ()
|
|||||||
texthookerOpenBrowser: false,
|
texthookerOpenBrowser: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
assert.equal(parsed.configSettings, true);
|
assert.equal(parsed.settings, true);
|
||||||
assert.equal(parsed.configPath, false);
|
assert.equal(parsed.configPath, false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('applyInvocationsToArgs fails when config invocation has no action', () => {
|
||||||
|
const parsed = createDefaultArgs({});
|
||||||
|
|
||||||
|
const error = withProcessExitIntercept(() => {
|
||||||
|
applyInvocationsToArgs(parsed, {
|
||||||
|
jellyfinInvocation: null,
|
||||||
|
configInvocation: {
|
||||||
|
action: undefined,
|
||||||
|
},
|
||||||
|
settingsInvocation: null,
|
||||||
|
mpvInvocation: null,
|
||||||
|
appInvocation: null,
|
||||||
|
dictionaryTriggered: false,
|
||||||
|
dictionaryTarget: null,
|
||||||
|
dictionaryLogLevel: null,
|
||||||
|
dictionaryCandidates: false,
|
||||||
|
dictionarySelect: false,
|
||||||
|
dictionaryAnilistId: null,
|
||||||
|
statsTriggered: false,
|
||||||
|
statsBackground: false,
|
||||||
|
statsStop: false,
|
||||||
|
statsCleanup: false,
|
||||||
|
statsCleanupVocab: false,
|
||||||
|
statsCleanupLifetime: false,
|
||||||
|
statsLogLevel: null,
|
||||||
|
doctorTriggered: false,
|
||||||
|
doctorLogLevel: null,
|
||||||
|
doctorRefreshKnownWords: false,
|
||||||
|
texthookerTriggered: false,
|
||||||
|
texthookerLogLevel: null,
|
||||||
|
texthookerOpenBrowser: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(error.code, 1);
|
||||||
|
});
|
||||||
|
|
||||||
test('applyInvocationsToArgs maps texthooker browser-open request', () => {
|
test('applyInvocationsToArgs maps texthooker browser-open request', () => {
|
||||||
const parsed = createDefaultArgs({});
|
const parsed = createDefaultArgs({});
|
||||||
|
|
||||||
applyInvocationsToArgs(parsed, {
|
applyInvocationsToArgs(parsed, {
|
||||||
jellyfinInvocation: null,
|
jellyfinInvocation: null,
|
||||||
configInvocation: null,
|
configInvocation: null,
|
||||||
|
settingsInvocation: null,
|
||||||
mpvInvocation: null,
|
mpvInvocation: null,
|
||||||
appInvocation: null,
|
appInvocation: null,
|
||||||
dictionaryTriggered: false,
|
dictionaryTriggered: false,
|
||||||
|
|||||||
@@ -158,7 +158,7 @@ export function createDefaultArgs(
|
|||||||
doctorRefreshKnownWords: false,
|
doctorRefreshKnownWords: false,
|
||||||
version: false,
|
version: false,
|
||||||
update: false,
|
update: false,
|
||||||
configSettings: false,
|
settings: false,
|
||||||
configPath: false,
|
configPath: false,
|
||||||
configShow: false,
|
configShow: false,
|
||||||
mpvIdle: false,
|
mpvIdle: false,
|
||||||
@@ -222,7 +222,7 @@ export function applyRootOptionsToArgs(
|
|||||||
if (options.rofi === true) parsed.useRofi = true;
|
if (options.rofi === true) parsed.useRofi = true;
|
||||||
if (options.update === true) parsed.update = true;
|
if (options.update === true) parsed.update = true;
|
||||||
if (options.version === true) parsed.version = true;
|
if (options.version === true) parsed.version = true;
|
||||||
if (options.config === true) parsed.configSettings = true;
|
if (options.settings === true) parsed.settings = true;
|
||||||
if (options.startOverlay === true) parsed.autoStartOverlay = true;
|
if (options.startOverlay === true) parsed.autoStartOverlay = true;
|
||||||
if (options.texthooker === false) parsed.useTexthooker = false;
|
if (options.texthooker === false) parsed.useTexthooker = false;
|
||||||
if (typeof options.args === 'string') parsed.mpvArgs = options.args;
|
if (typeof options.args === 'string') parsed.mpvArgs = options.args;
|
||||||
@@ -311,10 +311,19 @@ export function applyInvocationsToArgs(parsed: Args, invocations: CliInvocations
|
|||||||
parsed.logLevel = parseLogLevel(invocations.configInvocation.logLevel);
|
parsed.logLevel = parseLogLevel(invocations.configInvocation.logLevel);
|
||||||
}
|
}
|
||||||
const action = (invocations.configInvocation.action || '').toLowerCase();
|
const action = (invocations.configInvocation.action || '').toLowerCase();
|
||||||
if (!action) parsed.configSettings = true;
|
if (action === 'path') parsed.configPath = true;
|
||||||
else if (action === 'path') parsed.configPath = true;
|
|
||||||
else if (action === 'show') parsed.configShow = true;
|
else if (action === 'show') parsed.configShow = true;
|
||||||
else fail(`Unknown config action: ${invocations.configInvocation.action}`);
|
else
|
||||||
|
fail(
|
||||||
|
`Unknown config action: ${invocations.configInvocation.action || '(none)'}. Expected path or show.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (invocations.settingsInvocation) {
|
||||||
|
if (invocations.settingsInvocation.logLevel) {
|
||||||
|
parsed.logLevel = parseLogLevel(invocations.settingsInvocation.logLevel);
|
||||||
|
}
|
||||||
|
parsed.settings = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (invocations.mpvInvocation) {
|
if (invocations.mpvInvocation) {
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ export interface CommandActionInvocation {
|
|||||||
export interface CliInvocations {
|
export interface CliInvocations {
|
||||||
jellyfinInvocation: JellyfinInvocation | null;
|
jellyfinInvocation: JellyfinInvocation | null;
|
||||||
configInvocation: CommandActionInvocation | null;
|
configInvocation: CommandActionInvocation | null;
|
||||||
|
settingsInvocation: CommandActionInvocation | null;
|
||||||
mpvInvocation: CommandActionInvocation | null;
|
mpvInvocation: CommandActionInvocation | null;
|
||||||
appInvocation: { appArgs: string[] } | null;
|
appInvocation: { appArgs: string[] } | null;
|
||||||
dictionaryTriggered: boolean;
|
dictionaryTriggered: boolean;
|
||||||
@@ -58,7 +59,7 @@ function applyRootOptions(program: Command): void {
|
|||||||
.option('--start', 'Explicitly start overlay')
|
.option('--start', 'Explicitly start overlay')
|
||||||
.option('--log-level <level>', 'Log level')
|
.option('--log-level <level>', 'Log level')
|
||||||
.option('-v, --version', 'Show SubMiner version')
|
.option('-v, --version', 'Show SubMiner version')
|
||||||
.option('--config', 'Open configuration window')
|
.option('--settings', 'Open settings window')
|
||||||
.option('-u, --update', 'Check for updates')
|
.option('-u, --update', 'Check for updates')
|
||||||
.option('-R, --rofi', 'Use rofi picker')
|
.option('-R, --rofi', 'Use rofi picker')
|
||||||
.option('-S, --start-overlay', 'Auto-start overlay')
|
.option('-S, --start-overlay', 'Auto-start overlay')
|
||||||
@@ -88,6 +89,7 @@ function getTopLevelCommand(argv: string[]): { name: string; index: number } | n
|
|||||||
'jf',
|
'jf',
|
||||||
'doctor',
|
'doctor',
|
||||||
'config',
|
'config',
|
||||||
|
'settings',
|
||||||
'mpv',
|
'mpv',
|
||||||
'dictionary',
|
'dictionary',
|
||||||
'dict',
|
'dict',
|
||||||
@@ -138,6 +140,7 @@ export function parseCliPrograms(
|
|||||||
} {
|
} {
|
||||||
let jellyfinInvocation: JellyfinInvocation | null = null;
|
let jellyfinInvocation: JellyfinInvocation | null = null;
|
||||||
let configInvocation: CommandActionInvocation | null = null;
|
let configInvocation: CommandActionInvocation | null = null;
|
||||||
|
let settingsInvocation: CommandActionInvocation | null = null;
|
||||||
let mpvInvocation: CommandActionInvocation | null = null;
|
let mpvInvocation: CommandActionInvocation | null = null;
|
||||||
let appInvocation: { appArgs: string[] } | null = null;
|
let appInvocation: { appArgs: string[] } | null = null;
|
||||||
let dictionaryTriggered = false;
|
let dictionaryTriggered = false;
|
||||||
@@ -293,7 +296,7 @@ export function parseCliPrograms(
|
|||||||
|
|
||||||
commandProgram
|
commandProgram
|
||||||
.command('config')
|
.command('config')
|
||||||
.description('Config helpers')
|
.description('Config file helpers (path|show)')
|
||||||
.argument('[action]', 'path|show')
|
.argument('[action]', 'path|show')
|
||||||
.option('--log-level <level>', 'Log level')
|
.option('--log-level <level>', 'Log level')
|
||||||
.action((action: string | undefined, options: Record<string, unknown>) => {
|
.action((action: string | undefined, options: Record<string, unknown>) => {
|
||||||
@@ -303,6 +306,16 @@ export function parseCliPrograms(
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
commandProgram
|
||||||
|
.command('settings')
|
||||||
|
.description('Open SubMiner settings window')
|
||||||
|
.option('--log-level <level>', 'Log level')
|
||||||
|
.action((options: Record<string, unknown>) => {
|
||||||
|
settingsInvocation = {
|
||||||
|
logLevel: typeof options.logLevel === 'string' ? options.logLevel : undefined,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
commandProgram
|
commandProgram
|
||||||
.command('mpv')
|
.command('mpv')
|
||||||
.description('MPV helpers')
|
.description('MPV helpers')
|
||||||
@@ -356,6 +369,7 @@ export function parseCliPrograms(
|
|||||||
invocations: {
|
invocations: {
|
||||||
jellyfinInvocation,
|
jellyfinInvocation,
|
||||||
configInvocation,
|
configInvocation,
|
||||||
|
settingsInvocation,
|
||||||
mpvInvocation,
|
mpvInvocation,
|
||||||
appInvocation,
|
appInvocation,
|
||||||
dictionaryTriggered,
|
dictionaryTriggered,
|
||||||
|
|||||||
@@ -232,7 +232,7 @@ test('doctor refresh-known-words forwards app refresh command without requiring
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test('launcher config option forwards app configuration window command', () => {
|
test('launcher settings option forwards app settings window command', () => {
|
||||||
withTempDir((root) => {
|
withTempDir((root) => {
|
||||||
const homeDir = path.join(root, 'home');
|
const homeDir = path.join(root, 'home');
|
||||||
const xdgConfigHome = path.join(root, 'xdg');
|
const xdgConfigHome = path.join(root, 'xdg');
|
||||||
@@ -249,14 +249,14 @@ test('launcher config option forwards app configuration window command', () => {
|
|||||||
SUBMINER_APPIMAGE_PATH: appPath,
|
SUBMINER_APPIMAGE_PATH: appPath,
|
||||||
SUBMINER_TEST_CAPTURE: capturePath,
|
SUBMINER_TEST_CAPTURE: capturePath,
|
||||||
};
|
};
|
||||||
const result = runLauncher(['--config'], env);
|
const result = runLauncher(['--settings'], env);
|
||||||
|
|
||||||
assert.equal(result.status, 0);
|
assert.equal(result.status, 0);
|
||||||
assert.equal(fs.readFileSync(capturePath, 'utf8'), '--config\n');
|
assert.equal(fs.readFileSync(capturePath, 'utf8'), '--settings\n');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test('launcher config command forwards app configuration window command', () => {
|
test('launcher settings command forwards app settings window command', () => {
|
||||||
withTempDir((root) => {
|
withTempDir((root) => {
|
||||||
const homeDir = path.join(root, 'home');
|
const homeDir = path.join(root, 'home');
|
||||||
const xdgConfigHome = path.join(root, 'xdg');
|
const xdgConfigHome = path.join(root, 'xdg');
|
||||||
@@ -273,14 +273,14 @@ test('launcher config command forwards app configuration window command', () =>
|
|||||||
SUBMINER_APPIMAGE_PATH: appPath,
|
SUBMINER_APPIMAGE_PATH: appPath,
|
||||||
SUBMINER_TEST_CAPTURE: capturePath,
|
SUBMINER_TEST_CAPTURE: capturePath,
|
||||||
};
|
};
|
||||||
const result = runLauncher(['config'], env);
|
const result = runLauncher(['settings'], env);
|
||||||
|
|
||||||
assert.equal(result.status, 0);
|
assert.equal(result.status, 0);
|
||||||
assert.equal(fs.readFileSync(capturePath, 'utf8'), '--config\n');
|
assert.equal(fs.readFileSync(capturePath, 'utf8'), '--settings\n');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test('launcher config command suppresses known Electron macOS menu diagnostics', () => {
|
test('launcher settings command suppresses known Electron macOS menu diagnostics', () => {
|
||||||
withTempDir((root) => {
|
withTempDir((root) => {
|
||||||
const homeDir = path.join(root, 'home');
|
const homeDir = path.join(root, 'home');
|
||||||
const xdgConfigHome = path.join(root, 'xdg');
|
const xdgConfigHome = path.join(root, 'xdg');
|
||||||
@@ -301,7 +301,7 @@ test('launcher config command suppresses known Electron macOS menu diagnostics',
|
|||||||
...makeTestEnv(homeDir, xdgConfigHome),
|
...makeTestEnv(homeDir, xdgConfigHome),
|
||||||
SUBMINER_APPIMAGE_PATH: appPath,
|
SUBMINER_APPIMAGE_PATH: appPath,
|
||||||
};
|
};
|
||||||
const result = runLauncher(['config'], env);
|
const result = runLauncher(['settings'], env);
|
||||||
|
|
||||||
assert.equal(result.status, 0);
|
assert.equal(result.status, 0);
|
||||||
assert.equal(result.stderr, 'real stderr line\n');
|
assert.equal(result.stderr, 'real stderr line\n');
|
||||||
|
|||||||
+265
-1
@@ -6,6 +6,7 @@ import os from 'node:os';
|
|||||||
import net from 'node:net';
|
import net from 'node:net';
|
||||||
import { EventEmitter } from 'node:events';
|
import { EventEmitter } from 'node:events';
|
||||||
import type { Args } from './types';
|
import type { Args } from './types';
|
||||||
|
import { getAppControlSocketPath } from '../src/shared/app-control';
|
||||||
import {
|
import {
|
||||||
buildConfiguredMpvDefaultArgs,
|
buildConfiguredMpvDefaultArgs,
|
||||||
buildMpvBackendArgs,
|
buildMpvBackendArgs,
|
||||||
@@ -569,7 +570,7 @@ function makeArgs(overrides: Partial<Args> = {}): Args {
|
|||||||
doctor: false,
|
doctor: false,
|
||||||
doctorRefreshKnownWords: false,
|
doctorRefreshKnownWords: false,
|
||||||
version: false,
|
version: false,
|
||||||
configSettings: false,
|
settings: false,
|
||||||
configPath: false,
|
configPath: false,
|
||||||
configShow: false,
|
configShow: false,
|
||||||
mpvIdle: false,
|
mpvIdle: false,
|
||||||
@@ -655,6 +656,48 @@ test('startOverlay captures app stdout and stderr into app log', async () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('startOverlay starts launcher-owned playback in background managed mode', async () => {
|
||||||
|
const { dir, socketPath } = createTempSocketPath();
|
||||||
|
const appPath = path.join(dir, 'fake-subminer.sh');
|
||||||
|
const appInvocationsPath = path.join(dir, 'app-invocations.log');
|
||||||
|
fs.writeFileSync(
|
||||||
|
appPath,
|
||||||
|
[
|
||||||
|
'#!/bin/sh',
|
||||||
|
`printf '%s\\n' "$@" >> ${JSON.stringify(appInvocationsPath)}`,
|
||||||
|
'if [ "$1" = "--app-ping" ]; then exit 1; fi',
|
||||||
|
'exit 0',
|
||||||
|
'',
|
||||||
|
].join('\n'),
|
||||||
|
);
|
||||||
|
fs.chmodSync(appPath, 0o755);
|
||||||
|
fs.writeFileSync(socketPath, '');
|
||||||
|
const originalCreateConnection = net.createConnection;
|
||||||
|
try {
|
||||||
|
net.createConnection = (() => {
|
||||||
|
const socket = new EventEmitter() as net.Socket;
|
||||||
|
socket.destroy = (() => socket) as net.Socket['destroy'];
|
||||||
|
socket.setTimeout = (() => socket) as net.Socket['setTimeout'];
|
||||||
|
setTimeout(() => socket.emit('connect'), 10);
|
||||||
|
return socket;
|
||||||
|
}) as typeof net.createConnection;
|
||||||
|
|
||||||
|
await startOverlay(appPath, makeArgs(), socketPath);
|
||||||
|
|
||||||
|
const invocationText = fs.readFileSync(appInvocationsPath, 'utf8');
|
||||||
|
assert.match(invocationText, /--background/);
|
||||||
|
assert.match(invocationText, /--managed-playback/);
|
||||||
|
assert.equal(state.overlayManagedByLauncher, true);
|
||||||
|
assert.equal(state.appPath, appPath);
|
||||||
|
} finally {
|
||||||
|
net.createConnection = originalCreateConnection;
|
||||||
|
state.overlayProc = null;
|
||||||
|
state.overlayManagedByLauncher = false;
|
||||||
|
state.appPath = '';
|
||||||
|
fs.rmSync(dir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
test('startOverlay borrows an already-running background app instead of owning its lifecycle', async () => {
|
test('startOverlay borrows an already-running background app instead of owning its lifecycle', async () => {
|
||||||
const { dir, socketPath } = createTempSocketPath();
|
const { dir, socketPath } = createTempSocketPath();
|
||||||
const appPath = path.join(dir, 'fake-subminer.sh');
|
const appPath = path.join(dir, 'fake-subminer.sh');
|
||||||
@@ -686,6 +729,7 @@ test('startOverlay borrows an already-running background app instead of owning i
|
|||||||
const invocationText = fs.readFileSync(appInvocationsPath, 'utf8');
|
const invocationText = fs.readFileSync(appInvocationsPath, 'utf8');
|
||||||
assert.match(invocationText, /--app-ping/);
|
assert.match(invocationText, /--app-ping/);
|
||||||
assert.match(invocationText, /--start/);
|
assert.match(invocationText, /--start/);
|
||||||
|
assert.doesNotMatch(invocationText, /--background/);
|
||||||
assert.equal(state.overlayManagedByLauncher, false);
|
assert.equal(state.overlayManagedByLauncher, false);
|
||||||
assert.equal(state.appPath, '');
|
assert.equal(state.appPath, '');
|
||||||
} finally {
|
} finally {
|
||||||
@@ -697,6 +741,226 @@ test('startOverlay borrows an already-running background app instead of owning i
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('startOverlay attaches through the running app control socket without spawning another app command', async () => {
|
||||||
|
if (process.platform === 'win32') return;
|
||||||
|
|
||||||
|
const { dir, socketPath } = createTempSocketPath();
|
||||||
|
const controlSocketPath = path.join(dir, 'control.sock');
|
||||||
|
const appPath = path.join(dir, 'fake-subminer.sh');
|
||||||
|
const appInvocationsPath = path.join(dir, 'app-invocations.log');
|
||||||
|
const receivedControlArgv: string[][] = [];
|
||||||
|
const originalControlSocket = process.env.SUBMINER_APP_CONTROL_SOCKET;
|
||||||
|
|
||||||
|
fs.writeFileSync(
|
||||||
|
appPath,
|
||||||
|
[
|
||||||
|
'#!/bin/sh',
|
||||||
|
`printf '%s\\n' "$@" >> ${JSON.stringify(appInvocationsPath)}`,
|
||||||
|
'if [ "$1" = "--app-ping" ]; then exit 0; fi',
|
||||||
|
'exit 0',
|
||||||
|
'',
|
||||||
|
].join('\n'),
|
||||||
|
);
|
||||||
|
fs.chmodSync(appPath, 0o755);
|
||||||
|
|
||||||
|
const mpvServer = net.createServer((socket) => socket.end());
|
||||||
|
const controlServer = net.createServer((socket) => {
|
||||||
|
let buffer = '';
|
||||||
|
socket.on('data', (chunk) => {
|
||||||
|
buffer += chunk.toString('utf8');
|
||||||
|
const newlineMatch = buffer.match(/\r?\n/);
|
||||||
|
if (!newlineMatch || newlineMatch.index === undefined) return;
|
||||||
|
const line = buffer.slice(0, newlineMatch.index).trim();
|
||||||
|
buffer = buffer.slice(newlineMatch.index + newlineMatch[0].length);
|
||||||
|
if (!line) return;
|
||||||
|
const payload = JSON.parse(line) as { argv?: unknown };
|
||||||
|
if (Array.isArray(payload.argv)) {
|
||||||
|
receivedControlArgv.push(
|
||||||
|
payload.argv.filter((value): value is string => typeof value === 'string'),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
socket.end(JSON.stringify({ ok: true }) + '\n');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
process.env.SUBMINER_APP_CONTROL_SOCKET = controlSocketPath;
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
mpvServer.once('error', reject);
|
||||||
|
mpvServer.listen(socketPath, resolve);
|
||||||
|
});
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
controlServer.once('error', reject);
|
||||||
|
controlServer.listen(controlSocketPath, resolve);
|
||||||
|
});
|
||||||
|
|
||||||
|
await startOverlay(appPath, makeArgs(), socketPath);
|
||||||
|
|
||||||
|
const invocationText = fs.existsSync(appInvocationsPath)
|
||||||
|
? fs.readFileSync(appInvocationsPath, 'utf8')
|
||||||
|
: '';
|
||||||
|
assert.equal(invocationText, '');
|
||||||
|
assert.equal(receivedControlArgv.length, 1);
|
||||||
|
assert.deepEqual(receivedControlArgv[0]?.slice(0, 7), [
|
||||||
|
'--start',
|
||||||
|
'--managed-playback',
|
||||||
|
'--backend',
|
||||||
|
'x11',
|
||||||
|
'--socket',
|
||||||
|
socketPath,
|
||||||
|
'--log-level',
|
||||||
|
]);
|
||||||
|
assert.equal(state.overlayManagedByLauncher, false);
|
||||||
|
assert.equal(state.appPath, '');
|
||||||
|
} finally {
|
||||||
|
if (originalControlSocket === undefined) {
|
||||||
|
delete process.env.SUBMINER_APP_CONTROL_SOCKET;
|
||||||
|
} else {
|
||||||
|
process.env.SUBMINER_APP_CONTROL_SOCKET = originalControlSocket;
|
||||||
|
}
|
||||||
|
await new Promise<void>((resolve) => mpvServer.close(() => resolve()));
|
||||||
|
await new Promise<void>((resolve) => controlServer.close(() => resolve()));
|
||||||
|
state.overlayProc = null;
|
||||||
|
state.overlayManagedByLauncher = false;
|
||||||
|
state.appPath = '';
|
||||||
|
fs.rmSync(dir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('startOverlay uses caller config dir for app control socket discovery', async () => {
|
||||||
|
if (process.platform === 'win32') return;
|
||||||
|
|
||||||
|
const { dir, socketPath } = createTempSocketPath();
|
||||||
|
const configDir = path.join(dir, 'launcher-config');
|
||||||
|
const controlSocketPath = getAppControlSocketPath({ configDir, platform: 'linux' });
|
||||||
|
const appPath = path.join(dir, 'fake-subminer.sh');
|
||||||
|
const appInvocationsPath = path.join(dir, 'app-invocations.log');
|
||||||
|
const receivedControlArgv: string[][] = [];
|
||||||
|
const originalControlSocket = process.env.SUBMINER_APP_CONTROL_SOCKET;
|
||||||
|
|
||||||
|
fs.writeFileSync(
|
||||||
|
appPath,
|
||||||
|
[
|
||||||
|
'#!/bin/sh',
|
||||||
|
`printf '%s\\n' "$@" >> ${JSON.stringify(appInvocationsPath)}`,
|
||||||
|
'if [ "$1" = "--app-ping" ]; then exit 0; fi',
|
||||||
|
'exit 0',
|
||||||
|
'',
|
||||||
|
].join('\n'),
|
||||||
|
);
|
||||||
|
fs.chmodSync(appPath, 0o755);
|
||||||
|
|
||||||
|
const mpvServer = net.createServer((socket) => socket.end());
|
||||||
|
const controlServer = net.createServer((socket) => {
|
||||||
|
let buffer = '';
|
||||||
|
socket.on('data', (chunk) => {
|
||||||
|
buffer += chunk.toString('utf8');
|
||||||
|
const newlineIndex = buffer.indexOf('\n');
|
||||||
|
if (newlineIndex < 0) return;
|
||||||
|
const payload = JSON.parse(buffer.slice(0, newlineIndex)) as { argv?: unknown };
|
||||||
|
if (Array.isArray(payload.argv)) {
|
||||||
|
receivedControlArgv.push(
|
||||||
|
payload.argv.filter((value): value is string => typeof value === 'string'),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
socket.end(JSON.stringify({ ok: true }) + '\n');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
delete process.env.SUBMINER_APP_CONTROL_SOCKET;
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
mpvServer.once('error', reject);
|
||||||
|
mpvServer.listen(socketPath, resolve);
|
||||||
|
});
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
controlServer.once('error', reject);
|
||||||
|
controlServer.listen(controlSocketPath, resolve);
|
||||||
|
});
|
||||||
|
|
||||||
|
await startOverlay(appPath, makeArgs(), socketPath, [], configDir);
|
||||||
|
|
||||||
|
const invocationText = fs.existsSync(appInvocationsPath)
|
||||||
|
? fs.readFileSync(appInvocationsPath, 'utf8')
|
||||||
|
: '';
|
||||||
|
assert.equal(invocationText, '');
|
||||||
|
assert.equal(receivedControlArgv.length, 1);
|
||||||
|
assert.deepEqual(receivedControlArgv[0]?.slice(0, 6), [
|
||||||
|
'--start',
|
||||||
|
'--managed-playback',
|
||||||
|
'--backend',
|
||||||
|
'x11',
|
||||||
|
'--socket',
|
||||||
|
socketPath,
|
||||||
|
]);
|
||||||
|
} finally {
|
||||||
|
if (originalControlSocket === undefined) {
|
||||||
|
delete process.env.SUBMINER_APP_CONTROL_SOCKET;
|
||||||
|
} else {
|
||||||
|
process.env.SUBMINER_APP_CONTROL_SOCKET = originalControlSocket;
|
||||||
|
}
|
||||||
|
await new Promise<void>((resolve) => mpvServer.close(() => resolve()));
|
||||||
|
await new Promise<void>((resolve) => controlServer.close(() => resolve()));
|
||||||
|
state.overlayProc = null;
|
||||||
|
state.overlayManagedByLauncher = false;
|
||||||
|
state.appPath = '';
|
||||||
|
fs.rmSync(dir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('startOverlay falls back to legacy app startup when control command fails', async () => {
|
||||||
|
if (process.platform === 'win32') return;
|
||||||
|
|
||||||
|
const { dir, socketPath } = createTempSocketPath();
|
||||||
|
const controlSocketPath = path.join(dir, 'control.sock');
|
||||||
|
const appPath = path.join(dir, 'fake-subminer.sh');
|
||||||
|
const appInvocationsPath = path.join(dir, 'app-invocations.log');
|
||||||
|
const originalControlSocket = process.env.SUBMINER_APP_CONTROL_SOCKET;
|
||||||
|
|
||||||
|
fs.writeFileSync(
|
||||||
|
appPath,
|
||||||
|
[
|
||||||
|
'#!/bin/sh',
|
||||||
|
`printf '%s\\n' "$@" >> ${JSON.stringify(appInvocationsPath)}`,
|
||||||
|
'if [ "$1" = "--app-ping" ]; then exit 0; fi',
|
||||||
|
'exit 0',
|
||||||
|
'',
|
||||||
|
].join('\n'),
|
||||||
|
);
|
||||||
|
fs.chmodSync(appPath, 0o755);
|
||||||
|
|
||||||
|
const controlServer = net.createServer((socket) => {
|
||||||
|
socket.on('data', () => {
|
||||||
|
socket.end(JSON.stringify({ ok: false, error: 'boom' }) + '\n');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
process.env.SUBMINER_APP_CONTROL_SOCKET = controlSocketPath;
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
controlServer.once('error', reject);
|
||||||
|
controlServer.listen(controlSocketPath, resolve);
|
||||||
|
});
|
||||||
|
|
||||||
|
await startOverlay(appPath, makeArgs(), socketPath);
|
||||||
|
|
||||||
|
const invocationText = fs.readFileSync(appInvocationsPath, 'utf8');
|
||||||
|
assert.match(invocationText, /--app-ping/);
|
||||||
|
assert.match(invocationText, /--start/);
|
||||||
|
} finally {
|
||||||
|
if (originalControlSocket === undefined) {
|
||||||
|
delete process.env.SUBMINER_APP_CONTROL_SOCKET;
|
||||||
|
} else {
|
||||||
|
process.env.SUBMINER_APP_CONTROL_SOCKET = originalControlSocket;
|
||||||
|
}
|
||||||
|
await new Promise<void>((resolve) => controlServer.close(() => resolve()));
|
||||||
|
state.overlayProc = null;
|
||||||
|
state.overlayManagedByLauncher = false;
|
||||||
|
state.appPath = '';
|
||||||
|
fs.rmSync(dir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
test('startOverlay keeps lifecycle ownership for its already-managed app', async () => {
|
test('startOverlay keeps lifecycle ownership for its already-managed app', async () => {
|
||||||
const { dir, socketPath } = createTempSocketPath();
|
const { dir, socketPath } = createTempSocketPath();
|
||||||
const appPath = path.join(dir, 'fake-subminer.sh');
|
const appPath = path.join(dir, 'fake-subminer.sh');
|
||||||
|
|||||||
+80
-4
@@ -4,6 +4,11 @@ import os from 'node:os';
|
|||||||
import net from 'node:net';
|
import net from 'node:net';
|
||||||
import { spawn, spawnSync } from 'node:child_process';
|
import { spawn, spawnSync } from 'node:child_process';
|
||||||
import { buildMpvLaunchModeArgs } from '../src/shared/mpv-launch-mode.js';
|
import { buildMpvLaunchModeArgs } from '../src/shared/mpv-launch-mode.js';
|
||||||
|
import {
|
||||||
|
isAppControlServerAvailable as checkAppControlServerAvailable,
|
||||||
|
sendAppControlCommand,
|
||||||
|
} from '../src/shared/app-control-client.js';
|
||||||
|
import { getDefaultConfigDir } from '../src/shared/setup-state.js';
|
||||||
import {
|
import {
|
||||||
detectInstalledMpvPlugin,
|
detectInstalledMpvPlugin,
|
||||||
type InstalledMpvPluginDetection,
|
type InstalledMpvPluginDetection,
|
||||||
@@ -1001,22 +1006,73 @@ export async function startOverlay(
|
|||||||
args: Args,
|
args: Args,
|
||||||
socketPath: string,
|
socketPath: string,
|
||||||
extraAppArgs: string[] = [],
|
extraAppArgs: string[] = [],
|
||||||
|
configDir: string = getLauncherConfigDir(),
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const backend = detectBackend(args.backend);
|
const backend = detectBackend(args.backend);
|
||||||
log('info', args.logLevel, `Starting SubMiner overlay (backend: ${backend})...`);
|
log('info', args.logLevel, `Starting SubMiner overlay (backend: ${backend})...`);
|
||||||
const appAlreadyRunning = isAppAlreadyRunning(appPath, args.logLevel);
|
const alreadyManagedByLauncher = state.overlayManagedByLauncher && state.appPath === appPath;
|
||||||
|
|
||||||
const overlayArgs = ['--start', '--backend', backend, '--socket', socketPath, ...extraAppArgs];
|
const overlayArgs = [
|
||||||
|
'--start',
|
||||||
|
'--managed-playback',
|
||||||
|
'--backend',
|
||||||
|
backend,
|
||||||
|
'--socket',
|
||||||
|
socketPath,
|
||||||
|
...extraAppArgs,
|
||||||
|
];
|
||||||
if (args.logLevel !== 'info') overlayArgs.push('--log-level', args.logLevel);
|
if (args.logLevel !== 'info') overlayArgs.push('--log-level', args.logLevel);
|
||||||
if (args.useTexthooker) overlayArgs.push('--texthooker');
|
if (args.useTexthooker) overlayArgs.push('--texthooker');
|
||||||
|
|
||||||
const target = resolveAppSpawnTarget(appPath, overlayArgs);
|
const controlResult = await sendAppControlCommand(overlayArgs, {
|
||||||
|
configDir,
|
||||||
|
});
|
||||||
|
if (controlResult.ok) {
|
||||||
|
log('debug', args.logLevel, 'Attached to running SubMiner app via control socket');
|
||||||
|
if (alreadyManagedByLauncher) {
|
||||||
|
markOverlayManagedByLauncher(appPath);
|
||||||
|
} else {
|
||||||
|
clearOverlayManagedByLauncher();
|
||||||
|
state.overlayProc = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const socketReady = await waitForUnixSocketReady(
|
||||||
|
socketPath,
|
||||||
|
OVERLAY_START_SOCKET_READY_TIMEOUT_MS,
|
||||||
|
);
|
||||||
|
if (!socketReady) {
|
||||||
|
log(
|
||||||
|
'debug',
|
||||||
|
args.logLevel,
|
||||||
|
'Overlay start continuing before mpv socket readiness was confirmed',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (controlResult.unavailable !== true) {
|
||||||
|
log(
|
||||||
|
'warn',
|
||||||
|
args.logLevel,
|
||||||
|
`Running SubMiner app control command failed: ${controlResult.error ?? 'unknown error'}`,
|
||||||
|
);
|
||||||
|
if (!alreadyManagedByLauncher) {
|
||||||
|
clearOverlayManagedByLauncher();
|
||||||
|
state.overlayProc = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const appAlreadyRunning = isAppAlreadyRunning(appPath, args.logLevel);
|
||||||
|
const borrowingExistingApp = appAlreadyRunning && !alreadyManagedByLauncher;
|
||||||
|
const spawnOverlayArgs = [...overlayArgs];
|
||||||
|
if (!borrowingExistingApp) spawnOverlayArgs.unshift('--background');
|
||||||
|
|
||||||
|
const target = resolveAppSpawnTarget(appPath, spawnOverlayArgs);
|
||||||
state.overlayProc = spawn(target.command, target.args, {
|
state.overlayProc = spawn(target.command, target.args, {
|
||||||
stdio: ['ignore', 'pipe', 'pipe'],
|
stdio: ['ignore', 'pipe', 'pipe'],
|
||||||
env: buildAppEnv(process.env, target.env),
|
env: buildAppEnv(process.env, target.env),
|
||||||
});
|
});
|
||||||
attachAppProcessLogging(state.overlayProc);
|
attachAppProcessLogging(state.overlayProc);
|
||||||
if (appAlreadyRunning && !(state.overlayManagedByLauncher && state.appPath === appPath)) {
|
if (borrowingExistingApp) {
|
||||||
log(
|
log(
|
||||||
'debug',
|
'debug',
|
||||||
args.logLevel,
|
args.logLevel,
|
||||||
@@ -1045,6 +1101,26 @@ export async function startOverlay(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getLauncherConfigDir(): string {
|
||||||
|
return getDefaultConfigDir({
|
||||||
|
xdgConfigHome: process.env.XDG_CONFIG_HOME,
|
||||||
|
homeDir: os.homedir(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function isRunningAppControlServerAvailable(
|
||||||
|
logLevel: LogLevel,
|
||||||
|
configDir: string = getLauncherConfigDir(),
|
||||||
|
): Promise<boolean> {
|
||||||
|
const available = await checkAppControlServerAvailable({
|
||||||
|
configDir,
|
||||||
|
});
|
||||||
|
if (available) {
|
||||||
|
log('debug', logLevel, 'Running SubMiner app control socket detected');
|
||||||
|
}
|
||||||
|
return available;
|
||||||
|
}
|
||||||
|
|
||||||
export function markOverlayManagedByLauncher(appPath?: string): void {
|
export function markOverlayManagedByLauncher(appPath?: string): void {
|
||||||
if (appPath) {
|
if (appPath) {
|
||||||
state.appPath = appPath;
|
state.appPath = appPath;
|
||||||
|
|||||||
@@ -57,10 +57,10 @@ test('parseArgs captures mpv args string', () => {
|
|||||||
assert.equal(parsed.mpvArgs, '--pause=yes --title="movie night"');
|
assert.equal(parsed.mpvArgs, '--pause=yes --title="movie night"');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('parseArgs maps root config window option', () => {
|
test('parseArgs maps root settings window option', () => {
|
||||||
const parsed = parseArgs(['--config'], 'subminer', {});
|
const parsed = parseArgs(['--settings'], 'subminer', {});
|
||||||
|
|
||||||
assert.equal(parsed.configSettings, true);
|
assert.equal(parsed.settings, true);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('parseArgs maps root update flags without conflicting with jellyfin username', () => {
|
test('parseArgs maps root update flags without conflicting with jellyfin username', () => {
|
||||||
@@ -107,10 +107,10 @@ test('parseArgs maps config show action', () => {
|
|||||||
assert.equal(parsed.configPath, false);
|
assert.equal(parsed.configPath, false);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('parseArgs maps bare config command to settings window', () => {
|
test('parseArgs maps settings command to settings window', () => {
|
||||||
const parsed = parseArgs(['config'], 'subminer', {});
|
const parsed = parseArgs(['settings'], 'subminer', {});
|
||||||
|
|
||||||
assert.equal(parsed.configSettings, true);
|
assert.equal(parsed.settings, true);
|
||||||
assert.equal(parsed.configPath, false);
|
assert.equal(parsed.configPath, false);
|
||||||
assert.equal(parsed.configShow, false);
|
assert.equal(parsed.configShow, false);
|
||||||
});
|
});
|
||||||
@@ -119,7 +119,7 @@ test('parseArgs maps config path action to config path output', () => {
|
|||||||
const parsed = parseArgs(['config', 'path'], 'subminer', {});
|
const parsed = parseArgs(['config', 'path'], 'subminer', {});
|
||||||
|
|
||||||
assert.equal(parsed.configPath, true);
|
assert.equal(parsed.configPath, true);
|
||||||
assert.equal(parsed.configSettings, false);
|
assert.equal(parsed.settings, false);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('parseArgs rejects removed config open and launch actions', () => {
|
test('parseArgs rejects removed config open and launch actions', () => {
|
||||||
@@ -134,6 +134,14 @@ test('parseArgs rejects removed config open and launch actions', () => {
|
|||||||
assert.equal(exit.code, 1);
|
assert.equal(exit.code, 1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('parseArgs requires an explicit action for the config subcommand', () => {
|
||||||
|
const exit = withProcessExitIntercept(() => {
|
||||||
|
parseArgs(['config'], 'subminer', {});
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(exit.code, 1);
|
||||||
|
});
|
||||||
|
|
||||||
test('parseArgs maps mpv idle action', () => {
|
test('parseArgs maps mpv idle action', () => {
|
||||||
const parsed = parseArgs(['mpv', 'idle'], 'subminer', {});
|
const parsed = parseArgs(['mpv', 'idle'], 'subminer', {});
|
||||||
|
|
||||||
|
|||||||
+128
-29
@@ -238,6 +238,94 @@ async function waitForJsonLines(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function waitForFile(filePath: string, timeoutMs = 1500): Promise<void> {
|
||||||
|
const deadline = Date.now() + timeoutMs;
|
||||||
|
while (Date.now() < deadline) {
|
||||||
|
if (fs.existsSync(filePath)) return;
|
||||||
|
await new Promise<void>((resolve) => setTimeout(resolve, 50));
|
||||||
|
}
|
||||||
|
throw new Error(`Timed out waiting for file ${filePath} after ${timeoutMs}ms`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function startFakeControlServer(
|
||||||
|
smokeCase: SmokeCase,
|
||||||
|
): Promise<{ socketPath: string; logPath: string; stop: () => Promise<void> }> {
|
||||||
|
const socketPath = path.join(smokeCase.socketDir, 'app-control.sock');
|
||||||
|
const logPath = path.join(smokeCase.artifactsDir, 'fake-control.log');
|
||||||
|
const readyPath = path.join(smokeCase.artifactsDir, 'fake-control.ready');
|
||||||
|
const scriptPath = path.join(smokeCase.artifactsDir, 'fake-control-server.js');
|
||||||
|
|
||||||
|
fs.writeFileSync(
|
||||||
|
scriptPath,
|
||||||
|
`const fs = require('node:fs');
|
||||||
|
const net = require('node:net');
|
||||||
|
const path = require('node:path');
|
||||||
|
|
||||||
|
const socketPath = ${JSON.stringify(socketPath)};
|
||||||
|
const logPath = ${JSON.stringify(logPath)};
|
||||||
|
const readyPath = ${JSON.stringify(readyPath)};
|
||||||
|
try { fs.rmSync(socketPath, { force: true }); } catch {}
|
||||||
|
fs.mkdirSync(path.dirname(socketPath), { recursive: true });
|
||||||
|
|
||||||
|
const server = net.createServer((socket) => {
|
||||||
|
let buffer = '';
|
||||||
|
socket.on('data', (chunk) => {
|
||||||
|
buffer += chunk.toString('utf8');
|
||||||
|
let handledLine = false;
|
||||||
|
while (true) {
|
||||||
|
const newlineMatch = buffer.match(/\\r?\\n/);
|
||||||
|
if (!newlineMatch || newlineMatch.index === undefined) break;
|
||||||
|
const line = buffer.slice(0, newlineMatch.index).trim();
|
||||||
|
buffer = buffer.slice(newlineMatch.index + newlineMatch[0].length);
|
||||||
|
if (!line) continue;
|
||||||
|
fs.appendFileSync(logPath, line + '\\n');
|
||||||
|
handledLine = true;
|
||||||
|
}
|
||||||
|
if (handledLine) {
|
||||||
|
socket.end(JSON.stringify({ ok: true }) + '\\n');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
server.listen(socketPath, () => {
|
||||||
|
fs.writeFileSync(readyPath, 'ready');
|
||||||
|
});
|
||||||
|
|
||||||
|
const shutdown = () => {
|
||||||
|
server.close(() => {
|
||||||
|
try { fs.rmSync(socketPath, { force: true }); } catch {}
|
||||||
|
process.exit(0);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
process.on('SIGTERM', shutdown);
|
||||||
|
process.on('SIGINT', shutdown);
|
||||||
|
setInterval(() => {}, 1000);
|
||||||
|
`,
|
||||||
|
);
|
||||||
|
|
||||||
|
const proc = spawn(process.execPath, [scriptPath], { stdio: 'ignore' });
|
||||||
|
await waitForFile(readyPath);
|
||||||
|
|
||||||
|
return {
|
||||||
|
socketPath,
|
||||||
|
logPath,
|
||||||
|
stop: async () => {
|
||||||
|
if (proc.exitCode !== null || proc.signalCode !== null) return;
|
||||||
|
proc.kill('SIGTERM');
|
||||||
|
await new Promise<void>((resolve) => {
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
proc.kill('SIGKILL');
|
||||||
|
resolve();
|
||||||
|
}, 1000);
|
||||||
|
proc.once('close', () => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
test('launcher smoke fixture seeds completed setup state', () => {
|
test('launcher smoke fixture seeds completed setup state', () => {
|
||||||
const smokeCase = createSmokeCase('setup-state');
|
const smokeCase = createSmokeCase('setup-state');
|
||||||
try {
|
try {
|
||||||
@@ -295,7 +383,7 @@ test('launcher mpv status returns ready when socket is connectable', async () =>
|
|||||||
});
|
});
|
||||||
|
|
||||||
test(
|
test(
|
||||||
'launcher start-overlay run forwards socket/backend and keeps background app alive after mpv exits',
|
'launcher start-overlay run forwards socket/backend and stops owned background app after mpv exits',
|
||||||
{ timeout: LONG_SMOKE_TEST_TIMEOUT_MS },
|
{ timeout: LONG_SMOKE_TEST_TIMEOUT_MS },
|
||||||
async () => {
|
async () => {
|
||||||
await withSmokeCase('overlay-start-stop', async (smokeCase) => {
|
await withSmokeCase('overlay-start-stop', async (smokeCase) => {
|
||||||
@@ -330,7 +418,9 @@ test(
|
|||||||
|
|
||||||
const appStartArgs = appStartEntries[0]?.argv;
|
const appStartArgs = appStartEntries[0]?.argv;
|
||||||
assert.equal(Array.isArray(appStartArgs), true);
|
assert.equal(Array.isArray(appStartArgs), true);
|
||||||
|
assert.equal((appStartArgs as string[]).includes('--background'), true);
|
||||||
assert.equal((appStartArgs as string[]).includes('--start'), true);
|
assert.equal((appStartArgs as string[]).includes('--start'), true);
|
||||||
|
assert.equal((appStartArgs as string[]).includes('--managed-playback'), true);
|
||||||
assert.equal((appStartArgs as string[]).includes('--backend'), true);
|
assert.equal((appStartArgs as string[]).includes('--backend'), true);
|
||||||
assert.equal((appStartArgs as string[]).includes('x11'), true);
|
assert.equal((appStartArgs as string[]).includes('x11'), true);
|
||||||
assert.equal((appStartArgs as string[]).includes('--socket'), true);
|
assert.equal((appStartArgs as string[]).includes('--socket'), true);
|
||||||
@@ -351,44 +441,53 @@ test(
|
|||||||
);
|
);
|
||||||
|
|
||||||
test(
|
test(
|
||||||
'launcher start-overlay borrows a running background app and does not stop it after mpv exits',
|
'launcher start-overlay attaches to a running background app without spawning another app command',
|
||||||
{ timeout: LONG_SMOKE_TEST_TIMEOUT_MS },
|
{ timeout: LONG_SMOKE_TEST_TIMEOUT_MS },
|
||||||
async () => {
|
async () => {
|
||||||
await withSmokeCase('overlay-borrow-background', async (smokeCase) => {
|
await withSmokeCase('overlay-borrow-background', async (smokeCase) => {
|
||||||
|
const controlServer = await startFakeControlServer(smokeCase);
|
||||||
const env = {
|
const env = {
|
||||||
...makeTestEnv(smokeCase),
|
...makeTestEnv(smokeCase),
|
||||||
SUBMINER_FAKE_APP_RUNNING: '1',
|
SUBMINER_FAKE_APP_RUNNING: '1',
|
||||||
|
SUBMINER_APP_CONTROL_SOCKET: controlServer.socketPath,
|
||||||
};
|
};
|
||||||
const result = runLauncher(
|
try {
|
||||||
smokeCase,
|
const result = runLauncher(
|
||||||
['--backend', 'x11', '--start-overlay', smokeCase.videoPath],
|
smokeCase,
|
||||||
env,
|
['--backend', 'x11', '--start-overlay', smokeCase.videoPath],
|
||||||
'overlay-borrow-background',
|
env,
|
||||||
);
|
'overlay-borrow-background',
|
||||||
|
);
|
||||||
|
|
||||||
const appLogPath = path.join(smokeCase.artifactsDir, 'fake-app.log');
|
const appLogPath = path.join(smokeCase.artifactsDir, 'fake-app.log');
|
||||||
const appStartPath = path.join(smokeCase.artifactsDir, 'fake-app-start.log');
|
const appStartPath = path.join(smokeCase.artifactsDir, 'fake-app-start.log');
|
||||||
const appStopPath = path.join(smokeCase.artifactsDir, 'fake-app-stop.log');
|
const appStopPath = path.join(smokeCase.artifactsDir, 'fake-app-stop.log');
|
||||||
await waitForJsonLines(appStartPath, 1);
|
await waitForJsonLines(controlServer.logPath, 1);
|
||||||
|
|
||||||
const appEntries = readJsonLines(appLogPath);
|
const appEntries = readJsonLines(appLogPath);
|
||||||
const appStartEntries = readJsonLines(appStartPath);
|
const appStartEntries = readJsonLines(appStartPath);
|
||||||
const appStopEntries = readJsonLines(appStopPath);
|
const appStopEntries = readJsonLines(appStopPath);
|
||||||
const mpvEntries = readJsonLines(path.join(smokeCase.artifactsDir, 'fake-mpv.log'));
|
const controlEntries = readJsonLines(controlServer.logPath);
|
||||||
const mpvError = mpvEntries.find(
|
const mpvEntries = readJsonLines(path.join(smokeCase.artifactsDir, 'fake-mpv.log'));
|
||||||
(entry): entry is { error: string } => typeof entry.error === 'string',
|
const mpvError = mpvEntries.find(
|
||||||
)?.error;
|
(entry): entry is { error: string } => typeof entry.error === 'string',
|
||||||
const unixSocketDenied =
|
)?.error;
|
||||||
typeof mpvError === 'string' && /eperm|operation not permitted/i.test(mpvError);
|
const unixSocketDenied =
|
||||||
|
typeof mpvError === 'string' && /eperm|operation not permitted/i.test(mpvError);
|
||||||
|
|
||||||
assert.equal(result.status, unixSocketDenied ? 3 : 0);
|
assert.equal(result.status, unixSocketDenied ? 3 : 0);
|
||||||
assert.ok(
|
assert.equal(appEntries.length, 0);
|
||||||
appEntries.some(
|
assert.equal(appStartEntries.length, 0);
|
||||||
(entry) => Array.isArray(entry.argv) && (entry.argv as string[]).includes('--app-ping'),
|
assert.equal(appStopEntries.length, 0);
|
||||||
),
|
assert.equal(controlEntries.length, 1);
|
||||||
);
|
const controlArgs = controlEntries[0]?.argv;
|
||||||
assert.equal(appStartEntries.length, 1);
|
assert.equal(Array.isArray(controlArgs), true);
|
||||||
assert.equal(appStopEntries.length, 0);
|
assert.equal((controlArgs as string[]).includes('--background'), false);
|
||||||
|
assert.equal((controlArgs as string[]).includes('--start'), true);
|
||||||
|
assert.equal((controlArgs as string[]).includes('--managed-playback'), true);
|
||||||
|
} finally {
|
||||||
|
await controlServer.stop();
|
||||||
|
}
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
+1
-1
@@ -133,7 +133,7 @@ export interface Args {
|
|||||||
doctorRefreshKnownWords: boolean;
|
doctorRefreshKnownWords: boolean;
|
||||||
version: boolean;
|
version: boolean;
|
||||||
update?: boolean;
|
update?: boolean;
|
||||||
configSettings: boolean;
|
settings: boolean;
|
||||||
configPath: boolean;
|
configPath: boolean;
|
||||||
configShow: boolean;
|
configShow: boolean;
|
||||||
mpvIdle: boolean;
|
mpvIdle: boolean;
|
||||||
|
|||||||
+2
-2
@@ -50,8 +50,8 @@
|
|||||||
"test:plugin:src": "lua scripts/test-plugin-lua-compat.lua && lua scripts/test-plugin-start-gate.lua && lua scripts/test-plugin-session-bindings.lua && lua scripts/test-plugin-binary-windows.lua",
|
"test:plugin:src": "lua scripts/test-plugin-lua-compat.lua && lua scripts/test-plugin-start-gate.lua && lua scripts/test-plugin-session-bindings.lua && lua scripts/test-plugin-binary-windows.lua",
|
||||||
"test:launcher:smoke:src": "bun test launcher/smoke.e2e.test.ts",
|
"test:launcher:smoke:src": "bun test launcher/smoke.e2e.test.ts",
|
||||||
"test:launcher:src": "bun test launcher/config.test.ts launcher/config-domain-parsers.test.ts launcher/config/cli-parser-builder.test.ts launcher/config/args-normalizer.test.ts launcher/mpv.test.ts launcher/picker.test.ts launcher/parse-args.test.ts launcher/main.test.ts launcher/commands/command-modules.test.ts launcher/commands/update-command.test.ts launcher/smoke.e2e.test.ts && bun run test:plugin:src",
|
"test:launcher:src": "bun test launcher/config.test.ts launcher/config-domain-parsers.test.ts launcher/config/cli-parser-builder.test.ts launcher/config/args-normalizer.test.ts launcher/mpv.test.ts launcher/picker.test.ts launcher/parse-args.test.ts launcher/main.test.ts launcher/commands/command-modules.test.ts launcher/commands/update-command.test.ts launcher/smoke.e2e.test.ts && bun run test:plugin:src",
|
||||||
"test:core:src": "bun test src/preload-settings.test.ts src/settings/settings-anki-controls.test.ts src/settings/settings-model.test.ts src/settings/settings-field-layout.test.ts src/cli/args.test.ts src/cli/help.test.ts src/shared/setup-state.test.ts src/core/services/cli-command.test.ts src/core/services/ipc.test.ts src/core/services/anki-jimaku-ipc.test.ts src/core/services/field-grouping-overlay.test.ts src/core/services/numeric-shortcut-session.test.ts src/core/services/secondary-subtitle.test.ts src/core/services/mpv-render-metrics.test.ts src/core/services/overlay-content-measurement.test.ts src/core/services/mpv-control.test.ts src/core/services/mpv.test.ts src/core/services/runtime-options-ipc.test.ts src/core/services/runtime-config.test.ts src/core/services/yomitan-extension-paths.test.ts src/core/services/yomitan-extension-loader.test.ts src/core/services/yomitan-settings.test.ts src/core/services/config-hot-reload.test.ts src/core/services/discord-presence.test.ts src/core/services/tokenizer.test.ts src/core/services/tokenizer/annotation-stage.test.ts src/core/services/tokenizer/parser-selection-stage.test.ts src/core/services/tokenizer/parser-enrichment-stage.test.ts src/core/services/subsync.test.ts src/core/services/overlay-bridge.test.ts src/core/services/overlay-shortcut-handler.test.ts src/core/services/stats-window.test.ts src/core/services/__tests__/stats-server.test.ts src/main/runtime/stats-server-routing.test.ts src/core/services/mining.test.ts src/core/services/anki-jimaku.test.ts src/core/services/jimaku-download-path.test.ts src/core/services/jellyfin.test.ts src/core/services/jellyfin-remote.test.ts src/core/services/immersion-tracker-service.test.ts src/core/services/overlay-runtime-init.test.ts src/core/services/app-ready.test.ts src/core/services/startup-bootstrap.test.ts src/core/services/subtitle-processing-controller.test.ts src/main/runtime/current-subtitle-snapshot.test.ts src/main/runtime/autoplay-tokenization-warm-release.test.ts src/main/runtime/autoplay-subtitle-primer.test.ts src/core/services/anilist/anilist-update-queue.test.ts src/core/services/anilist/rate-limiter.test.ts src/core/services/jlpt-token-filter.test.ts src/core/services/subtitle-position.test.ts src/core/utils/shortcut-config.test.ts src/main/runtime/startup-mode-flags.test.ts src/main/runtime/first-run-setup-plugin.test.ts src/main/runtime/first-run-setup-service.test.ts src/main/runtime/first-run-setup-window.test.ts src/main/runtime/command-line-launcher.test.ts src/main/runtime/tray-runtime.test.ts src/main/runtime/tray-main-actions.test.ts src/main/runtime/tray-main-deps.test.ts src/main/runtime/tray-runtime-handlers.test.ts src/main/runtime/cli-command-context-main-deps.test.ts src/main/runtime/app-ready-main-deps.test.ts src/main/runtime/update/appimage-updater.test.ts src/main/runtime/update/fetch-adapter.test.ts src/main/runtime/update/release-metadata-policy.test.ts src/main/runtime/update/update-dialogs.test.ts src/main/runtime/update/support-assets.test.ts src/renderer/error-recovery.test.ts src/renderer/subtitle-render.test.ts src/renderer/handlers/mouse.test.ts src/renderer/handlers/keyboard.test.ts src/renderer/modals/jimaku.test.ts src/subsync/utils.test.ts src/main/anilist-url-guard.test.ts src/window-trackers/hyprland-tracker.test.ts src/window-trackers/x11-tracker.test.ts src/window-trackers/windows-helper.test.ts src/window-trackers/windows-tracker.test.ts launcher/config.test.ts launcher/config-domain-parsers.test.ts launcher/config/cli-parser-builder.test.ts launcher/config/args-normalizer.test.ts launcher/parse-args.test.ts launcher/main.test.ts launcher/commands/command-modules.test.ts launcher/commands/update-command.test.ts launcher/setup-gate.test.ts stats/src/lib/api-client.test.ts stats/src/hooks/useExcludedWords.test.ts",
|
"test:core:src": "bun test src/preload-settings.test.ts src/settings/settings-anki-controls.test.ts src/settings/settings-model.test.ts src/settings/settings-field-layout.test.ts src/cli/args.test.ts src/cli/help.test.ts src/shared/setup-state.test.ts src/core/services/cli-command.test.ts src/core/services/ipc.test.ts src/core/services/anki-jimaku-ipc.test.ts src/core/services/field-grouping-overlay.test.ts src/core/services/numeric-shortcut-session.test.ts src/core/services/secondary-subtitle.test.ts src/core/services/mpv-render-metrics.test.ts src/core/services/overlay-content-measurement.test.ts src/core/services/mpv-control.test.ts src/core/services/mpv.test.ts src/core/services/runtime-options-ipc.test.ts src/core/services/runtime-config.test.ts src/core/services/yomitan-extension-paths.test.ts src/core/services/yomitan-extension-loader.test.ts src/core/services/yomitan-settings.test.ts src/core/services/config-hot-reload.test.ts src/core/services/discord-presence.test.ts src/core/services/tokenizer.test.ts src/core/services/tokenizer/annotation-stage.test.ts src/core/services/tokenizer/parser-selection-stage.test.ts src/core/services/tokenizer/parser-enrichment-stage.test.ts src/core/services/subsync.test.ts src/core/services/overlay-bridge.test.ts src/core/services/overlay-shortcut-handler.test.ts src/core/services/stats-window.test.ts src/core/services/__tests__/stats-server.test.ts src/main/runtime/stats-server-routing.test.ts src/core/services/mining.test.ts src/core/services/anki-jimaku.test.ts src/core/services/jimaku-download-path.test.ts src/core/services/jellyfin.test.ts src/core/services/jellyfin-remote.test.ts src/core/services/immersion-tracker-service.test.ts src/core/services/overlay-runtime-init.test.ts src/core/services/app-ready.test.ts src/core/services/startup-bootstrap.test.ts src/core/services/subtitle-processing-controller.test.ts src/main/runtime/current-subtitle-snapshot.test.ts src/main/runtime/autoplay-tokenization-warm-release.test.ts src/main/runtime/autoplay-subtitle-primer.test.ts src/core/services/anilist/anilist-update-queue.test.ts src/core/services/anilist/rate-limiter.test.ts src/core/services/jlpt-token-filter.test.ts src/core/services/subtitle-position.test.ts src/core/utils/shortcut-config.test.ts src/main/runtime/startup-mode-flags.test.ts src/main/runtime/first-run-setup-plugin.test.ts src/main/runtime/first-run-setup-service.test.ts src/main/runtime/first-run-setup-window.test.ts src/main/runtime/command-line-launcher.test.ts src/main/runtime/tray-runtime.test.ts src/main/runtime/tray-main-actions.test.ts src/main/runtime/tray-main-deps.test.ts src/main/runtime/tray-runtime-handlers.test.ts src/main/runtime/cli-command-context-main-deps.test.ts src/main/runtime/app-ready-main-deps.test.ts src/main/runtime/update/appimage-updater.test.ts src/main/runtime/update/fetch-adapter.test.ts src/main/runtime/update/release-metadata-policy.test.ts src/main/runtime/update/update-dialogs.test.ts src/main/runtime/update/support-assets.test.ts src/renderer/error-recovery.test.ts src/renderer/subtitle-render.test.ts src/renderer/subtitle-render-word-class.test.ts src/renderer/handlers/mouse.test.ts src/renderer/handlers/keyboard.test.ts src/renderer/modals/jimaku.test.ts src/subsync/utils.test.ts src/main/anilist-url-guard.test.ts src/window-trackers/hyprland-tracker.test.ts src/window-trackers/x11-tracker.test.ts src/window-trackers/windows-helper.test.ts src/window-trackers/windows-tracker.test.ts launcher/config.test.ts launcher/config-domain-parsers.test.ts launcher/config/cli-parser-builder.test.ts launcher/config/args-normalizer.test.ts launcher/parse-args.test.ts launcher/main.test.ts launcher/commands/command-modules.test.ts launcher/commands/update-command.test.ts launcher/setup-gate.test.ts stats/src/lib/api-client.test.ts stats/src/hooks/useExcludedWords.test.ts",
|
||||||
"test:core:dist": "bun test dist/preload-settings.test.js dist/settings/settings-anki-controls.test.js dist/settings/settings-model.test.js dist/settings/settings-field-layout.test.js dist/cli/args.test.js dist/cli/help.test.js dist/core/services/cli-command.test.js dist/core/services/ipc.test.js dist/core/services/anki-jimaku-ipc.test.js dist/core/services/field-grouping-overlay.test.js dist/core/services/numeric-shortcut-session.test.js dist/core/services/secondary-subtitle.test.js dist/core/services/mpv-render-metrics.test.js dist/core/services/overlay-content-measurement.test.js dist/core/services/mpv-control.test.js dist/core/services/mpv.test.js dist/core/services/runtime-options-ipc.test.js dist/core/services/runtime-config.test.js dist/core/services/yomitan-extension-paths.test.js dist/core/services/config-hot-reload.test.js dist/core/services/discord-presence.test.js dist/core/services/tokenizer.test.js dist/core/services/tokenizer/annotation-stage.test.js dist/core/services/tokenizer/parser-selection-stage.test.js dist/core/services/tokenizer/parser-enrichment-stage.test.js dist/core/services/subsync.test.js dist/core/services/overlay-bridge.test.js dist/core/services/overlay-manager.test.js dist/core/services/overlay-shortcut-handler.test.js dist/core/services/mining.test.js dist/core/services/anki-jimaku.test.js dist/core/services/jimaku-download-path.test.js dist/core/services/jellyfin.test.js dist/core/services/jellyfin-remote.test.js dist/core/services/immersion-tracker-service.test.js dist/core/services/overlay-runtime-init.test.js dist/core/services/app-ready.test.js dist/core/services/startup-bootstrap.test.js dist/core/services/subtitle-processing-controller.test.js dist/main/runtime/current-subtitle-snapshot.test.js dist/main/runtime/autoplay-tokenization-warm-release.test.js dist/main/runtime/autoplay-subtitle-primer.test.js dist/core/services/anilist/anilist-token-store.test.js dist/core/services/anilist/anilist-update-queue.test.js dist/core/services/anilist/rate-limiter.test.js dist/core/services/jlpt-token-filter.test.js dist/core/services/subtitle-position.test.js dist/renderer/error-recovery.test.js dist/renderer/subtitle-render.test.js dist/renderer/handlers/mouse.test.js dist/renderer/handlers/keyboard.test.js dist/renderer/modals/jimaku.test.js dist/subsync/utils.test.js dist/main/anilist-url-guard.test.js dist/window-trackers/hyprland-tracker.test.js dist/window-trackers/x11-tracker.test.js dist/window-trackers/windows-helper.test.js dist/window-trackers/windows-tracker.test.js",
|
"test:core:dist": "bun test dist/preload-settings.test.js dist/settings/settings-anki-controls.test.js dist/settings/settings-model.test.js dist/settings/settings-field-layout.test.js dist/cli/args.test.js dist/cli/help.test.js dist/core/services/cli-command.test.js dist/core/services/ipc.test.js dist/core/services/anki-jimaku-ipc.test.js dist/core/services/field-grouping-overlay.test.js dist/core/services/numeric-shortcut-session.test.js dist/core/services/secondary-subtitle.test.js dist/core/services/mpv-render-metrics.test.js dist/core/services/overlay-content-measurement.test.js dist/core/services/mpv-control.test.js dist/core/services/mpv.test.js dist/core/services/runtime-options-ipc.test.js dist/core/services/runtime-config.test.js dist/core/services/yomitan-extension-paths.test.js dist/core/services/config-hot-reload.test.js dist/core/services/discord-presence.test.js dist/core/services/tokenizer.test.js dist/core/services/tokenizer/annotation-stage.test.js dist/core/services/tokenizer/parser-selection-stage.test.js dist/core/services/tokenizer/parser-enrichment-stage.test.js dist/core/services/subsync.test.js dist/core/services/overlay-bridge.test.js dist/core/services/overlay-manager.test.js dist/core/services/overlay-shortcut-handler.test.js dist/core/services/mining.test.js dist/core/services/anki-jimaku.test.js dist/core/services/jimaku-download-path.test.js dist/core/services/jellyfin.test.js dist/core/services/jellyfin-remote.test.js dist/core/services/immersion-tracker-service.test.js dist/core/services/overlay-runtime-init.test.js dist/core/services/app-ready.test.js dist/core/services/startup-bootstrap.test.js dist/core/services/subtitle-processing-controller.test.js dist/main/runtime/current-subtitle-snapshot.test.js dist/main/runtime/autoplay-tokenization-warm-release.test.js dist/main/runtime/autoplay-subtitle-primer.test.js dist/core/services/anilist/anilist-token-store.test.js dist/core/services/anilist/anilist-update-queue.test.js dist/core/services/anilist/rate-limiter.test.js dist/core/services/jlpt-token-filter.test.js dist/core/services/subtitle-position.test.js dist/renderer/error-recovery.test.js dist/renderer/subtitle-render.test.js dist/renderer/subtitle-render-word-class.test.js dist/renderer/handlers/mouse.test.js dist/renderer/handlers/keyboard.test.js dist/renderer/modals/jimaku.test.js dist/subsync/utils.test.js dist/main/anilist-url-guard.test.js dist/window-trackers/hyprland-tracker.test.js dist/window-trackers/x11-tracker.test.js dist/window-trackers/windows-helper.test.js dist/window-trackers/windows-tracker.test.js",
|
||||||
"test:core:smoke:dist": "bun test dist/cli/help.test.js dist/core/services/runtime-config.test.js dist/core/services/ipc.test.js dist/core/services/overlay-manager.test.js dist/core/services/anilist/anilist-token-store.test.js dist/core/services/startup-bootstrap.test.js dist/renderer/error-recovery.test.js dist/main/anilist-url-guard.test.js dist/window-trackers/x11-tracker.test.js",
|
"test:core:smoke:dist": "bun test dist/cli/help.test.js dist/core/services/runtime-config.test.js dist/core/services/ipc.test.js dist/core/services/overlay-manager.test.js dist/core/services/anilist/anilist-token-store.test.js dist/core/services/startup-bootstrap.test.js dist/renderer/error-recovery.test.js dist/main/anilist-url-guard.test.js dist/window-trackers/x11-tracker.test.js",
|
||||||
"test:smoke:dist": "bun run test:config:smoke:dist && bun run test:core:smoke:dist",
|
"test:smoke:dist": "bun run test:config:smoke:dist && bun run test:core:smoke:dist",
|
||||||
"test:subtitle:src": "bun test src/core/services/subsync.test.ts src/subsync/utils.test.ts",
|
"test:subtitle:src": "bun test src/core/services/subsync.test.ts src/subsync/utils.test.ts",
|
||||||
|
|||||||
@@ -60,17 +60,31 @@ function M.create(ctx)
|
|||||||
return state.auto_start_retry_generation
|
return state.auto_start_retry_generation
|
||||||
end
|
end
|
||||||
|
|
||||||
local function rearm_managed_subtitle_defaults()
|
local function has_matching_subminer_socket()
|
||||||
if not process.has_matching_mpv_ipc_socket(opts.socket_path) then
|
if not process.has_matching_mpv_ipc_socket(opts.socket_path) then
|
||||||
return false
|
return false
|
||||||
end
|
end
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
|
||||||
|
local function rearm_managed_subtitle_load_defaults()
|
||||||
|
if not has_matching_subminer_socket() then
|
||||||
|
return false
|
||||||
|
end
|
||||||
mp.set_property_native("sub-auto", "fuzzy")
|
mp.set_property_native("sub-auto", "fuzzy")
|
||||||
mp.set_property_native("sid", "auto")
|
mp.set_property_native("sid", "auto")
|
||||||
mp.set_property_native("secondary-sid", "auto")
|
mp.set_property_native("secondary-sid", "auto")
|
||||||
return true
|
return true
|
||||||
end
|
end
|
||||||
|
|
||||||
|
local function refresh_managed_subtitle_autoloading()
|
||||||
|
if not has_matching_subminer_socket() then
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
mp.set_property_native("sub-auto", "fuzzy")
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
|
||||||
local function start_overlay_when_socket_ready(generation, media_identity, same_media_loaded, attempt)
|
local function start_overlay_when_socket_ready(generation, media_identity, same_media_loaded, attempt)
|
||||||
if generation ~= state.auto_start_retry_generation then
|
if generation ~= state.auto_start_retry_generation then
|
||||||
return
|
return
|
||||||
@@ -83,7 +97,7 @@ function M.create(ctx)
|
|||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
|
||||||
local has_matching_socket = rearm_managed_subtitle_defaults()
|
local has_matching_socket = refresh_managed_subtitle_autoloading()
|
||||||
if not has_matching_socket then
|
if not has_matching_socket then
|
||||||
if attempt < AUTO_START_SOCKET_RETRY_MAX_ATTEMPTS then
|
if attempt < AUTO_START_SOCKET_RETRY_MAX_ATTEMPTS then
|
||||||
mp.add_timeout(AUTO_START_SOCKET_RETRY_DELAY_SECONDS, function()
|
mp.add_timeout(AUTO_START_SOCKET_RETRY_DELAY_SECONDS, function()
|
||||||
@@ -109,6 +123,13 @@ function M.create(ctx)
|
|||||||
schedule_aniskip_fetch("overlay-start", 0.8)
|
schedule_aniskip_fetch("overlay-start", 0.8)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
local function on_start_file()
|
||||||
|
if state.pending_reload_media_identity ~= nil then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
rearm_managed_subtitle_load_defaults()
|
||||||
|
end
|
||||||
|
|
||||||
local function on_file_loaded()
|
local function on_file_loaded()
|
||||||
local media_identity = resolve_media_identity()
|
local media_identity = resolve_media_identity()
|
||||||
local retry_generation = next_auto_start_retry_generation()
|
local retry_generation = next_auto_start_retry_generation()
|
||||||
@@ -151,7 +172,7 @@ function M.create(ctx)
|
|||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
|
||||||
rearm_managed_subtitle_defaults()
|
refresh_managed_subtitle_autoloading()
|
||||||
schedule_aniskip_fetch("file-loaded", 0)
|
schedule_aniskip_fetch("file-loaded", 0)
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -165,6 +186,7 @@ function M.create(ctx)
|
|||||||
end
|
end
|
||||||
|
|
||||||
local function register_lifecycle_hooks()
|
local function register_lifecycle_hooks()
|
||||||
|
mp.register_event("start-file", on_start_file)
|
||||||
mp.register_event("file-loaded", on_file_loaded)
|
mp.register_event("file-loaded", on_file_loaded)
|
||||||
mp.register_event("shutdown", on_shutdown)
|
mp.register_event("shutdown", on_shutdown)
|
||||||
mp.register_event("file-loaded", function()
|
mp.register_event("file-loaded", function()
|
||||||
|
|||||||
@@ -207,6 +207,9 @@ function M.create(ctx)
|
|||||||
end
|
end
|
||||||
|
|
||||||
if action == "start" then
|
if action == "start" then
|
||||||
|
if overrides.background ~= false then
|
||||||
|
table.insert(args, "--background")
|
||||||
|
end
|
||||||
table.insert(args, "--managed-playback")
|
table.insert(args, "--managed-playback")
|
||||||
|
|
||||||
local backend = resolve_backend(overrides.backend)
|
local backend = resolve_backend(overrides.backend)
|
||||||
@@ -504,10 +507,13 @@ function M.create(ctx)
|
|||||||
end)
|
end)
|
||||||
end
|
end
|
||||||
|
|
||||||
launch_overlay_with_retry(1)
|
environment.is_subminer_app_running_async(function(app_running)
|
||||||
if texthooker_enabled then
|
overrides.background = not app_running
|
||||||
ensure_texthooker_running(function() end)
|
launch_overlay_with_retry(1)
|
||||||
end
|
if texthooker_enabled then
|
||||||
|
ensure_texthooker_running(function() end)
|
||||||
|
end
|
||||||
|
end, { force_refresh = true })
|
||||||
end
|
end
|
||||||
|
|
||||||
local function start_overlay_from_script_message(...)
|
local function start_overlay_from_script_message(...)
|
||||||
|
|||||||
@@ -757,17 +757,17 @@ do
|
|||||||
assert_true(call ~= nil, "AppImage start should issue an async subprocess")
|
assert_true(call ~= nil, "AppImage start should issue an async subprocess")
|
||||||
assert_true(#call.args == 1 and call.args[1] == appimage_path, "AppImage subprocess should not receive raw CLI flags")
|
assert_true(#call.args == 1 and call.args[1] == appimage_path, "AppImage subprocess should not receive raw CLI flags")
|
||||||
assert_true(env_has(call, "PATH=/usr/bin"), "AppImage subprocess should preserve existing environment")
|
assert_true(env_has(call, "PATH=/usr/bin"), "AppImage subprocess should preserve existing environment")
|
||||||
assert_true(env_has(call, "SUBMINER_APP_ARGC=7"), "AppImage subprocess should transport app arg count")
|
assert_true(env_has(call, "SUBMINER_APP_ARGC=8"), "AppImage subprocess should transport app arg count")
|
||||||
assert_true(env_has(call, "SUBMINER_APP_ARG_0=--start"), "AppImage subprocess should transport --start")
|
assert_true(env_has(call, "SUBMINER_APP_ARG_0=--start"), "AppImage subprocess should transport --start")
|
||||||
assert_true(
|
assert_true(
|
||||||
env_has(call, "SUBMINER_APP_ARG_1=--managed-playback"),
|
env_has(call, "SUBMINER_APP_ARG_1=--background"),
|
||||||
"AppImage subprocess should transport --managed-playback"
|
"AppImage subprocess should transport --background"
|
||||||
)
|
)
|
||||||
assert_true(
|
assert_true(
|
||||||
not env_has(call, "SUBMINER_APP_ARG_1=--background"),
|
env_has(call, "SUBMINER_APP_ARG_2=--managed-playback"),
|
||||||
"AppImage subprocess should not transport --background for video-owned playback"
|
"AppImage subprocess should transport --managed-playback"
|
||||||
)
|
)
|
||||||
assert_true(env_has(call, "SUBMINER_APP_ARG_6=--hide-visible-overlay"), "AppImage subprocess should transport visibility flag")
|
assert_true(env_has(call, "SUBMINER_APP_ARG_7=--hide-visible-overlay"), "AppImage subprocess should transport visibility flag")
|
||||||
assert_true(env_has_prefix(call, "SUBMINER_APP_LOG="), "AppImage subprocess should include app log env")
|
assert_true(env_has_prefix(call, "SUBMINER_APP_LOG="), "AppImage subprocess should include app log env")
|
||||||
assert_true(env_has_prefix(call, "SUBMINER_MPV_LOG="), "AppImage subprocess should include mpv log env")
|
assert_true(env_has_prefix(call, "SUBMINER_MPV_LOG="), "AppImage subprocess should include mpv log env")
|
||||||
assert_true(
|
assert_true(
|
||||||
@@ -1095,18 +1095,54 @@ do
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
assert_true(recorded ~= nil, "plugin failed to load for subtitle rearm scenario: " .. tostring(err))
|
assert_true(recorded ~= nil, "plugin failed to load for subtitle rearm scenario: " .. tostring(err))
|
||||||
fire_event(recorded, "file-loaded")
|
fire_event(recorded, "start-file")
|
||||||
assert_true(
|
assert_true(
|
||||||
has_property_set(recorded.property_sets, "sub-auto", "fuzzy"),
|
has_property_set(recorded.property_sets, "sub-auto", "fuzzy"),
|
||||||
"managed file-loaded should rearm sub-auto for idle mpv sessions"
|
"managed start-file should rearm sub-auto before mpv loads tracks"
|
||||||
)
|
)
|
||||||
assert_true(
|
assert_true(
|
||||||
has_property_set(recorded.property_sets, "sid", "auto"),
|
has_property_set(recorded.property_sets, "sid", "auto"),
|
||||||
"managed file-loaded should rearm primary subtitle selection for idle mpv sessions"
|
"managed start-file should rearm primary subtitle selection before mpv loads tracks"
|
||||||
)
|
)
|
||||||
assert_true(
|
assert_true(
|
||||||
has_property_set(recorded.property_sets, "secondary-sid", "auto"),
|
has_property_set(recorded.property_sets, "secondary-sid", "auto"),
|
||||||
"managed file-loaded should rearm secondary subtitle selection for idle mpv sessions"
|
"managed start-file should rearm secondary subtitle selection before mpv loads tracks"
|
||||||
|
)
|
||||||
|
fire_event(recorded, "file-loaded")
|
||||||
|
assert_true(
|
||||||
|
count_property_set(recorded.property_sets, "sid", "auto") == 1,
|
||||||
|
"managed file-loaded should not reset primary subtitle selection after mpv loads tracks"
|
||||||
|
)
|
||||||
|
assert_true(
|
||||||
|
count_property_set(recorded.property_sets, "secondary-sid", "auto") == 1,
|
||||||
|
"managed file-loaded should not reset secondary subtitle selection after mpv loads tracks"
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
do
|
||||||
|
local recorded, err = run_plugin_scenario({
|
||||||
|
process_list = "",
|
||||||
|
option_overrides = {
|
||||||
|
binary_path = binary_path,
|
||||||
|
auto_start = "no",
|
||||||
|
socket_path = "/tmp/subminer-socket",
|
||||||
|
},
|
||||||
|
input_ipc_server = "/tmp/subminer-socket",
|
||||||
|
media_title = "Random Movie",
|
||||||
|
files = {
|
||||||
|
[binary_path] = true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
assert_true(recorded ~= nil, "plugin failed to load for attached subtitle rearm scenario: " .. tostring(err))
|
||||||
|
fire_event(recorded, "start-file")
|
||||||
|
fire_event(recorded, "file-loaded")
|
||||||
|
assert_true(
|
||||||
|
count_property_set(recorded.property_sets, "sid", "auto") == 1,
|
||||||
|
"attached background app path should select primary subtitle before load only"
|
||||||
|
)
|
||||||
|
assert_true(
|
||||||
|
count_property_set(recorded.property_sets, "secondary-sid", "auto") == 1,
|
||||||
|
"attached background app path should select secondary subtitle before load only"
|
||||||
)
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -1274,12 +1310,12 @@ do
|
|||||||
local start_call = find_start_call(recorded.async_calls)
|
local start_call = find_start_call(recorded.async_calls)
|
||||||
assert_true(start_call ~= nil, "auto-start should issue --start command")
|
assert_true(start_call ~= nil, "auto-start should issue --start command")
|
||||||
assert_true(
|
assert_true(
|
||||||
not call_has_arg(start_call, "--background"),
|
call_has_arg(start_call, "--background"),
|
||||||
"auto-start should not mark video-owned playback as background/tray mode"
|
"auto-start should launch SubMiner in background/tray mode"
|
||||||
)
|
)
|
||||||
assert_true(
|
assert_true(
|
||||||
call_has_arg(start_call, "--managed-playback"),
|
call_has_arg(start_call, "--managed-playback"),
|
||||||
"auto-start should mark SubMiner as launcher-managed playback"
|
"auto-start should mark SubMiner as managed playback"
|
||||||
)
|
)
|
||||||
assert_true(call_has_arg(start_call, "--texthooker"), "auto-start should include --texthooker on the main --start command when enabled")
|
assert_true(call_has_arg(start_call, "--texthooker"), "auto-start should include --texthooker on the main --start command when enabled")
|
||||||
assert_true(find_control_call(recorded.async_calls, "--texthooker") == nil, "auto-start should not issue a separate texthooker helper command")
|
assert_true(find_control_call(recorded.async_calls, "--texthooker") == nil, "auto-start should not issue a separate texthooker helper command")
|
||||||
@@ -1596,7 +1632,7 @@ do
|
|||||||
[binary_path] = true,
|
[binary_path] = true,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
assert_true(recorded ~= nil, "plugin failed to load for shutdown-preserve-background scenario: " .. tostring(err))
|
assert_true(recorded ~= nil, "plugin failed to load for shutdown-managed-background scenario: " .. tostring(err))
|
||||||
fire_event(recorded, "file-loaded")
|
fire_event(recorded, "file-loaded")
|
||||||
fire_event(recorded, "end-file", { reason = "quit" })
|
fire_event(recorded, "end-file", { reason = "quit" })
|
||||||
assert_true(
|
assert_true(
|
||||||
@@ -1606,7 +1642,7 @@ do
|
|||||||
fire_event(recorded, "shutdown")
|
fire_event(recorded, "shutdown")
|
||||||
assert_true(
|
assert_true(
|
||||||
find_control_call(recorded.async_calls, "--stop") == nil,
|
find_control_call(recorded.async_calls, "--stop") == nil,
|
||||||
"mpv shutdown should not stop the background SubMiner process"
|
"mpv shutdown should leave managed-playback ownership to the app process"
|
||||||
)
|
)
|
||||||
assert_true(
|
assert_true(
|
||||||
find_control_call(recorded.async_calls, "--hide-visible-overlay") == nil,
|
find_control_call(recorded.async_calls, "--hide-visible-overlay") == nil,
|
||||||
@@ -1614,6 +1650,41 @@ do
|
|||||||
)
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
do
|
||||||
|
local recorded, err = run_plugin_scenario({
|
||||||
|
process_list = "/opt/SubMiner/subminer --background\n",
|
||||||
|
option_overrides = {
|
||||||
|
binary_path = binary_path,
|
||||||
|
auto_start = "yes",
|
||||||
|
auto_start_visible_overlay = "yes",
|
||||||
|
socket_path = "/tmp/subminer-socket",
|
||||||
|
},
|
||||||
|
input_ipc_server = "/tmp/subminer-socket",
|
||||||
|
media_title = "Random Movie",
|
||||||
|
files = {
|
||||||
|
[binary_path] = true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
assert_true(recorded ~= nil, "plugin failed to load for shutdown-borrowed-background scenario: " .. tostring(err))
|
||||||
|
fire_event(recorded, "file-loaded")
|
||||||
|
local start_call = find_start_call(recorded.async_calls)
|
||||||
|
assert_true(start_call ~= nil, "auto-start should attach playback to the existing app")
|
||||||
|
assert_true(
|
||||||
|
not call_has_arg(start_call, "--background"),
|
||||||
|
"borrowed app auto-start should not use the background launch wrapper"
|
||||||
|
)
|
||||||
|
assert_true(
|
||||||
|
call_has_arg(start_call, "--managed-playback"),
|
||||||
|
"borrowed app auto-start should still attach managed playback to the existing app"
|
||||||
|
)
|
||||||
|
fire_event(recorded, "end-file", { reason = "quit" })
|
||||||
|
fire_event(recorded, "shutdown")
|
||||||
|
assert_true(
|
||||||
|
find_control_call(recorded.async_calls, "--stop") == nil,
|
||||||
|
"mpv shutdown should leave a pre-existing background SubMiner process running"
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
do
|
do
|
||||||
local recorded, err = run_plugin_scenario({
|
local recorded, err = run_plugin_scenario({
|
||||||
process_list = "",
|
process_list = "",
|
||||||
@@ -1633,6 +1704,14 @@ do
|
|||||||
fire_event(recorded, "file-loaded")
|
fire_event(recorded, "file-loaded")
|
||||||
local start_call = find_start_call(recorded.async_calls)
|
local start_call = find_start_call(recorded.async_calls)
|
||||||
assert_true(start_call ~= nil, "auto-start should issue --start command")
|
assert_true(start_call ~= nil, "auto-start should issue --start command")
|
||||||
|
assert_true(
|
||||||
|
call_has_arg(start_call, "--background"),
|
||||||
|
"auto-start should launch SubMiner in background mode"
|
||||||
|
)
|
||||||
|
assert_true(
|
||||||
|
call_has_arg(start_call, "--managed-playback"),
|
||||||
|
"auto-start should mark SubMiner as managed playback"
|
||||||
|
)
|
||||||
assert_true(
|
assert_true(
|
||||||
call_has_arg(start_call, "--hide-visible-overlay"),
|
call_has_arg(start_call, "--hide-visible-overlay"),
|
||||||
"auto-start with visible overlay disabled should include --hide-visible-overlay on --start"
|
"auto-start with visible overlay disabled should include --hide-visible-overlay on --start"
|
||||||
|
|||||||
+18
-20
@@ -7,7 +7,7 @@ import {
|
|||||||
isHeadlessInitialCommand,
|
isHeadlessInitialCommand,
|
||||||
isStandaloneTexthookerCommand,
|
isStandaloneTexthookerCommand,
|
||||||
parseArgs,
|
parseArgs,
|
||||||
shouldRunSettingsOnlyStartup,
|
shouldRunYomitanOnlyStartup,
|
||||||
shouldStartApp,
|
shouldStartApp,
|
||||||
} from './args';
|
} from './args';
|
||||||
|
|
||||||
@@ -66,7 +66,7 @@ test('parseArgs captures update command and internal launcher paths', () => {
|
|||||||
assert.equal(hasExplicitCommand(args), true);
|
assert.equal(hasExplicitCommand(args), true);
|
||||||
assert.equal(shouldStartApp(args), true);
|
assert.equal(shouldStartApp(args), true);
|
||||||
assert.equal(commandNeedsOverlayRuntime(args), false);
|
assert.equal(commandNeedsOverlayRuntime(args), false);
|
||||||
assert.equal(shouldRunSettingsOnlyStartup(args), false);
|
assert.equal(shouldRunYomitanOnlyStartup(args), false);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('parseArgs captures launch-mpv targets and keeps it out of app startup', () => {
|
test('parseArgs captures launch-mpv targets and keeps it out of app startup', () => {
|
||||||
@@ -208,35 +208,33 @@ test('hasExplicitCommand and shouldStartApp preserve command intent', () => {
|
|||||||
assert.equal(shouldStartApp(update), true);
|
assert.equal(shouldStartApp(update), true);
|
||||||
assert.equal(isHeadlessInitialCommand(update), true);
|
assert.equal(isHeadlessInitialCommand(update), true);
|
||||||
|
|
||||||
|
const yomitan = parseArgs(['--yomitan']);
|
||||||
|
assert.equal(yomitan.yomitan, true);
|
||||||
|
assert.equal(hasExplicitCommand(yomitan), true);
|
||||||
|
assert.equal(shouldStartApp(yomitan), true);
|
||||||
|
assert.equal(shouldRunYomitanOnlyStartup(yomitan), true);
|
||||||
|
|
||||||
const settings = parseArgs(['--settings']);
|
const settings = parseArgs(['--settings']);
|
||||||
assert.equal(settings.settings, true);
|
assert.equal(settings.settings, true);
|
||||||
assert.equal(hasExplicitCommand(settings), true);
|
assert.equal(hasExplicitCommand(settings), true);
|
||||||
assert.equal(shouldStartApp(settings), true);
|
assert.equal(shouldStartApp(settings), true);
|
||||||
assert.equal(shouldRunSettingsOnlyStartup(settings), true);
|
assert.equal(shouldRunYomitanOnlyStartup(settings), false);
|
||||||
|
assert.equal(commandNeedsOverlayRuntime(settings), false);
|
||||||
|
assert.equal(commandNeedsOverlayStartupPrereqs(settings), false);
|
||||||
|
|
||||||
const configSettings = parseArgs(['--config']);
|
const yomitanWithOverlay = parseArgs(['--yomitan', '--toggle-visible-overlay']);
|
||||||
assert.equal(configSettings.configSettings, true);
|
assert.equal(yomitanWithOverlay.yomitan, true);
|
||||||
assert.equal(hasExplicitCommand(configSettings), true);
|
assert.equal(yomitanWithOverlay.toggleVisibleOverlay, true);
|
||||||
assert.equal(shouldStartApp(configSettings), true);
|
assert.equal(shouldRunYomitanOnlyStartup(yomitanWithOverlay), false);
|
||||||
assert.equal(shouldRunSettingsOnlyStartup(configSettings), false);
|
|
||||||
assert.equal(commandNeedsOverlayRuntime(configSettings), false);
|
|
||||||
assert.equal(commandNeedsOverlayStartupPrereqs(configSettings), false);
|
|
||||||
|
|
||||||
const settingsWithOverlay = parseArgs(['--settings', '--toggle-visible-overlay']);
|
const settingsDoesNotEnableYomitan = parseArgs(['--settings']);
|
||||||
assert.equal(settingsWithOverlay.settings, true);
|
assert.equal(settingsDoesNotEnableYomitan.yomitan, false);
|
||||||
assert.equal(settingsWithOverlay.toggleVisibleOverlay, true);
|
|
||||||
assert.equal(shouldRunSettingsOnlyStartup(settingsWithOverlay), false);
|
|
||||||
|
|
||||||
const yomitanAlias = parseArgs(['--yomitan']);
|
|
||||||
assert.equal(yomitanAlias.settings, true);
|
|
||||||
assert.equal(hasExplicitCommand(yomitanAlias), true);
|
|
||||||
assert.equal(shouldStartApp(yomitanAlias), true);
|
|
||||||
|
|
||||||
const help = parseArgs(['--help']);
|
const help = parseArgs(['--help']);
|
||||||
assert.equal(help.help, true);
|
assert.equal(help.help, true);
|
||||||
assert.equal(hasExplicitCommand(help), true);
|
assert.equal(hasExplicitCommand(help), true);
|
||||||
assert.equal(shouldStartApp(help), false);
|
assert.equal(shouldStartApp(help), false);
|
||||||
assert.equal(shouldRunSettingsOnlyStartup(help), false);
|
assert.equal(shouldRunYomitanOnlyStartup(help), false);
|
||||||
|
|
||||||
const appPing = parseArgs(['--app-ping']);
|
const appPing = parseArgs(['--app-ping']);
|
||||||
assert.equal(appPing.appPing, true);
|
assert.equal(appPing.appPing, true);
|
||||||
|
|||||||
+10
-10
@@ -10,8 +10,8 @@ export interface CliArgs {
|
|||||||
toggle: boolean;
|
toggle: boolean;
|
||||||
toggleVisibleOverlay: boolean;
|
toggleVisibleOverlay: boolean;
|
||||||
togglePrimarySubtitleBar: boolean;
|
togglePrimarySubtitleBar: boolean;
|
||||||
|
yomitan: boolean;
|
||||||
settings: boolean;
|
settings: boolean;
|
||||||
configSettings: boolean;
|
|
||||||
setup: boolean;
|
setup: boolean;
|
||||||
show: boolean;
|
show: boolean;
|
||||||
hide: boolean;
|
hide: boolean;
|
||||||
@@ -117,8 +117,8 @@ export function parseArgs(argv: string[]): CliArgs {
|
|||||||
toggle: false,
|
toggle: false,
|
||||||
toggleVisibleOverlay: false,
|
toggleVisibleOverlay: false,
|
||||||
togglePrimarySubtitleBar: false,
|
togglePrimarySubtitleBar: false,
|
||||||
|
yomitan: false,
|
||||||
settings: false,
|
settings: false,
|
||||||
configSettings: false,
|
|
||||||
setup: false,
|
setup: false,
|
||||||
show: false,
|
show: false,
|
||||||
hide: false,
|
hide: false,
|
||||||
@@ -239,8 +239,8 @@ export function parseArgs(argv: string[]): CliArgs {
|
|||||||
else if (arg === '--toggle') args.toggle = true;
|
else if (arg === '--toggle') args.toggle = true;
|
||||||
else if (arg === '--toggle-visible-overlay') args.toggleVisibleOverlay = true;
|
else if (arg === '--toggle-visible-overlay') args.toggleVisibleOverlay = true;
|
||||||
else if (arg === '--toggle-primary-subtitle-bar') args.togglePrimarySubtitleBar = true;
|
else if (arg === '--toggle-primary-subtitle-bar') args.togglePrimarySubtitleBar = true;
|
||||||
else if (arg === '--settings' || arg === '--yomitan') args.settings = true;
|
else if (arg === '--yomitan') args.yomitan = true;
|
||||||
else if (arg === '--config') args.configSettings = true;
|
else if (arg === '--settings') args.settings = true;
|
||||||
else if (arg === '--setup') args.setup = true;
|
else if (arg === '--setup') args.setup = true;
|
||||||
else if (arg === '--show') args.show = true;
|
else if (arg === '--show') args.show = true;
|
||||||
else if (arg === '--hide') args.hide = true;
|
else if (arg === '--hide') args.hide = true;
|
||||||
@@ -494,8 +494,8 @@ export function hasExplicitCommand(args: CliArgs): boolean {
|
|||||||
args.toggle ||
|
args.toggle ||
|
||||||
args.toggleVisibleOverlay ||
|
args.toggleVisibleOverlay ||
|
||||||
args.togglePrimarySubtitleBar ||
|
args.togglePrimarySubtitleBar ||
|
||||||
|
args.yomitan ||
|
||||||
args.settings ||
|
args.settings ||
|
||||||
args.configSettings ||
|
|
||||||
args.setup ||
|
args.setup ||
|
||||||
args.show ||
|
args.show ||
|
||||||
args.hide ||
|
args.hide ||
|
||||||
@@ -569,8 +569,8 @@ export function isStandaloneTexthookerCommand(args: CliArgs): boolean {
|
|||||||
!args.toggle &&
|
!args.toggle &&
|
||||||
!args.toggleVisibleOverlay &&
|
!args.toggleVisibleOverlay &&
|
||||||
!args.togglePrimarySubtitleBar &&
|
!args.togglePrimarySubtitleBar &&
|
||||||
|
!args.yomitan &&
|
||||||
!args.settings &&
|
!args.settings &&
|
||||||
!args.configSettings &&
|
|
||||||
!args.setup &&
|
!args.setup &&
|
||||||
!args.show &&
|
!args.show &&
|
||||||
!args.hide &&
|
!args.hide &&
|
||||||
@@ -639,8 +639,8 @@ export function shouldStartApp(args: CliArgs): boolean {
|
|||||||
args.toggle ||
|
args.toggle ||
|
||||||
args.toggleVisibleOverlay ||
|
args.toggleVisibleOverlay ||
|
||||||
args.togglePrimarySubtitleBar ||
|
args.togglePrimarySubtitleBar ||
|
||||||
|
args.yomitan ||
|
||||||
args.settings ||
|
args.settings ||
|
||||||
args.configSettings ||
|
|
||||||
args.setup ||
|
args.setup ||
|
||||||
args.copySubtitle ||
|
args.copySubtitle ||
|
||||||
args.copySubtitleMultiple ||
|
args.copySubtitleMultiple ||
|
||||||
@@ -687,16 +687,16 @@ export function shouldStartApp(args: CliArgs): boolean {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function shouldRunSettingsOnlyStartup(args: CliArgs): boolean {
|
export function shouldRunYomitanOnlyStartup(args: CliArgs): boolean {
|
||||||
return (
|
return (
|
||||||
args.settings &&
|
args.yomitan &&
|
||||||
!args.background &&
|
!args.background &&
|
||||||
!args.start &&
|
!args.start &&
|
||||||
!args.stop &&
|
!args.stop &&
|
||||||
!args.toggle &&
|
!args.toggle &&
|
||||||
!args.toggleVisibleOverlay &&
|
!args.toggleVisibleOverlay &&
|
||||||
!args.togglePrimarySubtitleBar &&
|
!args.togglePrimarySubtitleBar &&
|
||||||
!args.configSettings &&
|
!args.settings &&
|
||||||
!args.show &&
|
!args.show &&
|
||||||
!args.hide &&
|
!args.hide &&
|
||||||
!args.setup &&
|
!args.setup &&
|
||||||
|
|||||||
@@ -22,7 +22,8 @@ test('printHelp includes configured texthooker port', () => {
|
|||||||
assert.match(output, /--open-browser\s+Open texthooker in your default browser/);
|
assert.match(output, /--open-browser\s+Open texthooker in your default browser/);
|
||||||
assert.doesNotMatch(output, /--refresh-known-words/);
|
assert.doesNotMatch(output, /--refresh-known-words/);
|
||||||
assert.match(output, /--setup\s+Open first-run setup window/);
|
assert.match(output, /--setup\s+Open first-run setup window/);
|
||||||
assert.match(output, /--config\s+Open configuration window/);
|
assert.match(output, /--settings\s+Open SubMiner settings window/);
|
||||||
|
assert.match(output, /--yomitan\s+Open Yomitan settings window/);
|
||||||
assert.match(output, /--mark-watched\s+Mark current video watched and advance playlist/);
|
assert.match(output, /--mark-watched\s+Mark current video watched and advance playlist/);
|
||||||
assert.match(output, /--anilist-status/);
|
assert.match(output, /--anilist-status/);
|
||||||
assert.match(output, /--anilist-retry-queue/);
|
assert.match(output, /--anilist-retry-queue/);
|
||||||
|
|||||||
+2
-2
@@ -24,8 +24,8 @@ ${B}Overlay${R}
|
|||||||
--toggle-primary-subtitle-bar Toggle primary subtitle bar
|
--toggle-primary-subtitle-bar Toggle primary subtitle bar
|
||||||
--show-visible-overlay Show subtitle overlay
|
--show-visible-overlay Show subtitle overlay
|
||||||
--hide-visible-overlay Hide subtitle overlay
|
--hide-visible-overlay Hide subtitle overlay
|
||||||
--settings Open Yomitan settings window
|
--yomitan Open Yomitan settings window
|
||||||
--config Open configuration window
|
--settings Open SubMiner settings window
|
||||||
--setup Open first-run setup window
|
--setup Open first-run setup window
|
||||||
--auto-start-overlay Auto-hide mpv subs, show overlay on connect
|
--auto-start-overlay Auto-hide mpv subs, show overlay on connect
|
||||||
|
|
||||||
|
|||||||
@@ -30,6 +30,8 @@ const LEGACY_N_PLUS_ONE_PATH_MAP = {
|
|||||||
nPlusOne: 'subtitleStyle.nPlusOneColor',
|
nPlusOne: 'subtitleStyle.nPlusOneColor',
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
|
const hexColorPattern = /^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/;
|
||||||
|
|
||||||
function propertyKey(propertyNode: JsoncNode): string | undefined {
|
function propertyKey(propertyNode: JsoncNode): string | undefined {
|
||||||
return propertyNode.children?.[0]?.value;
|
return propertyNode.children?.[0]?.value;
|
||||||
}
|
}
|
||||||
@@ -82,6 +84,12 @@ function normalizeLegacyDecks(value: unknown): unknown {
|
|||||||
return normalized;
|
return normalized;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function asLegacyColor(value: unknown): string | undefined {
|
||||||
|
if (typeof value !== 'string') return undefined;
|
||||||
|
const text = value.trim();
|
||||||
|
return hexColorPattern.test(text) ? text : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
function buildLegacyNPlusOneMigrationOperations(root: JsoncNode | undefined): {
|
function buildLegacyNPlusOneMigrationOperations(root: JsoncNode | undefined): {
|
||||||
operations: ConfigSettingsPatchOperation[];
|
operations: ConfigSettingsPatchOperation[];
|
||||||
hasLegacy: boolean;
|
hasLegacy: boolean;
|
||||||
@@ -90,9 +98,9 @@ function buildLegacyNPlusOneMigrationOperations(root: JsoncNode | undefined): {
|
|||||||
const ankiConnect = propertyValue(findLastProperty(root, 'ankiConnect'));
|
const ankiConnect = propertyValue(findLastProperty(root, 'ankiConnect'));
|
||||||
const nPlusOneProperties = findProperties(ankiConnect, 'nPlusOne');
|
const nPlusOneProperties = findProperties(ankiConnect, 'nPlusOne');
|
||||||
const nPlusOneObjects = nPlusOneProperties.map(propertyValue).filter(Boolean) as JsoncNode[];
|
const nPlusOneObjects = nPlusOneProperties.map(propertyValue).filter(Boolean) as JsoncNode[];
|
||||||
if (nPlusOneObjects.length === 0) {
|
const knownWords = propertyValue(findLastProperty(ankiConnect, 'knownWords'));
|
||||||
return { operations, hasLegacy: false };
|
const knownWordsColorNode = propertyValue(findLastProperty(knownWords, 'color'));
|
||||||
}
|
const knownWordsColor = knownWordsColorNode ? getNodeValue(knownWordsColorNode) : undefined;
|
||||||
|
|
||||||
const canonicalNPlusOneValues = new Map<string, unknown>();
|
const canonicalNPlusOneValues = new Map<string, unknown>();
|
||||||
const legacyValues = new Map<keyof typeof LEGACY_N_PLUS_ONE_PATH_MAP, unknown>();
|
const legacyValues = new Map<keyof typeof LEGACY_N_PLUS_ONE_PATH_MAP, unknown>();
|
||||||
@@ -144,6 +152,22 @@ function buildLegacyNPlusOneMigrationOperations(root: JsoncNode | undefined): {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const legacyKnownWordsColor = asLegacyColor(knownWordsColor);
|
||||||
|
if (legacyKnownWordsColor !== undefined) {
|
||||||
|
hasLegacy = true;
|
||||||
|
if (!hasPath(root, 'subtitleStyle.knownWordColor')) {
|
||||||
|
operations.push({
|
||||||
|
op: 'set',
|
||||||
|
path: 'subtitleStyle.knownWordColor',
|
||||||
|
value: legacyKnownWordsColor,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
operations.push({
|
||||||
|
op: 'reset',
|
||||||
|
path: 'ankiConnect.knownWords.color',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
return { operations, hasLegacy };
|
return { operations, hasLegacy };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+25
-98
@@ -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');
|
||||||
@@ -101,6 +101,7 @@ test('loads defaults when config is missing', () => {
|
|||||||
assert.equal(config.subtitleStyle.autoPauseVideoOnHover, true);
|
assert.equal(config.subtitleStyle.autoPauseVideoOnHover, true);
|
||||||
assert.equal(config.subtitleStyle.autoPauseVideoOnYomitanPopup, true);
|
assert.equal(config.subtitleStyle.autoPauseVideoOnYomitanPopup, true);
|
||||||
assert.equal(config.subtitleSidebar.enabled, true);
|
assert.equal(config.subtitleSidebar.enabled, true);
|
||||||
|
assert.equal(config.subtitleSidebar.pauseVideoOnHover, true);
|
||||||
assert.equal(config.subtitleStyle.hoverTokenColor, '#f4dbd6');
|
assert.equal(config.subtitleStyle.hoverTokenColor, '#f4dbd6');
|
||||||
assert.equal(config.subtitleStyle.hoverTokenBackgroundColor, 'transparent');
|
assert.equal(config.subtitleStyle.hoverTokenBackgroundColor, 'transparent');
|
||||||
assert.equal(config.subtitleStyle.fontFamily, DEFAULT_SUBTITLE_FONT_FAMILY);
|
assert.equal(config.subtitleStyle.fontFamily, DEFAULT_SUBTITLE_FONT_FAMILY);
|
||||||
@@ -222,12 +223,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 +250,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 +2032,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,22 +2044,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(Object.hasOwn(parsed.ankiConnect.nPlusOne ?? {}, 'nPlusOne'), false);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('legacy migration failures are logged and rethrown', () => {
|
test('legacy migration failures are logged and rethrown', () => {
|
||||||
@@ -2109,12 +2065,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,
|
||||||
@@ -2124,20 +2078,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);
|
||||||
@@ -2148,28 +2094,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,
|
||||||
@@ -2182,19 +2114,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', () => {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
@@ -69,7 +69,7 @@ export const SUBTITLE_DEFAULT_CONFIG: Pick<ResolvedConfig, 'subtitleStyle' | 'su
|
|||||||
autoOpen: false,
|
autoOpen: false,
|
||||||
layout: 'overlay',
|
layout: 'overlay',
|
||||||
toggleKey: 'Backslash',
|
toggleKey: 'Backslash',
|
||||||
pauseVideoOnHover: false,
|
pauseVideoOnHover: true,
|
||||||
autoScroll: true,
|
autoScroll: true,
|
||||||
css: {},
|
css: {},
|
||||||
maxWidth: 420,
|
maxWidth: 420,
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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) =>
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -19,17 +19,85 @@ test('settings registry splits viewing into appearance and behavior categories',
|
|||||||
assert.equal(field('subtitlePosition.yPercent').label, 'Subtitle Position');
|
assert.equal(field('subtitlePosition.yPercent').label, 'Subtitle Position');
|
||||||
assert.equal(field('subtitleStyle.frequencyDictionary.mode').label, 'Frequency Mode');
|
assert.equal(field('subtitleStyle.frequencyDictionary.mode').label, 'Frequency Mode');
|
||||||
assert.equal(field('auto_start_overlay').category, 'behavior');
|
assert.equal(field('auto_start_overlay').category, 'behavior');
|
||||||
assert.equal(field('auto_start_overlay').section, 'Visible Overlay Auto-Start');
|
assert.equal(field('auto_start_overlay').section, 'Playback Behavior');
|
||||||
assert.equal(field('youtube.primarySubLanguages').category, 'behavior');
|
assert.equal(field('youtube.primarySubLanguages').category, 'behavior');
|
||||||
assert.equal(field('youtube.primarySubLanguages').section, 'YouTube Playback Settings');
|
assert.equal(field('youtube.primarySubLanguages').section, 'YouTube Playback Settings');
|
||||||
assert.equal(field('mpv.launchMode').category, 'behavior');
|
assert.equal(field('mpv.launchMode').category, 'behavior');
|
||||||
assert.equal(field('mpv.launchMode').section, 'MPV Launcher');
|
assert.equal(field('mpv.launchMode').section, 'mpv Playback');
|
||||||
assert.ok(
|
assert.ok(
|
||||||
fields.findIndex((candidate) => candidate.configPath === 'subtitleStyle.primaryDefaultMode') <
|
fields.findIndex((candidate) => candidate.configPath === 'subtitleStyle.primaryDefaultMode') <
|
||||||
fields.findIndex((candidate) => candidate.configPath === 'secondarySub.defaultMode'),
|
fields.findIndex((candidate) => candidate.configPath === 'secondarySub.defaultMode'),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('settings registry groups playback startup controls under playback behavior', () => {
|
||||||
|
for (const path of [
|
||||||
|
'subtitleStyle.autoPauseVideoOnHover',
|
||||||
|
'subtitleStyle.autoPauseVideoOnYomitanPopup',
|
||||||
|
'subtitleSidebar.pauseVideoOnHover',
|
||||||
|
'mpv.autoStartSubMiner',
|
||||||
|
'auto_start_overlay',
|
||||||
|
'mpv.pauseUntilOverlayReady',
|
||||||
|
]) {
|
||||||
|
assert.equal(field(path).category, 'behavior', path);
|
||||||
|
assert.equal(field(path).section, 'Playback Behavior', path);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('settings registry moves AniSkip button key into input shortcuts and hot reload', () => {
|
||||||
|
assert.equal(field('mpv.aniskipButtonKey').category, 'input');
|
||||||
|
assert.equal(field('mpv.aniskipButtonKey').section, 'Overlay Shortcuts');
|
||||||
|
assert.equal(field('mpv.aniskipButtonKey').subsection, 'Playback');
|
||||||
|
assert.equal(field('mpv.aniskipButtonKey').control, 'mpv-key');
|
||||||
|
assert.equal(field('mpv.aniskipButtonKey').restartBehavior, 'hot-reload');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('settings registry hides removed modal-only fields', () => {
|
||||||
|
for (const path of [
|
||||||
|
'shortcuts.multiCopyTimeoutMs',
|
||||||
|
'anilist.characterDictionary.profileScope',
|
||||||
|
'jellyfin.directPlayContainers',
|
||||||
|
'jellyfin.remoteControlDeviceName',
|
||||||
|
]) {
|
||||||
|
assert.equal(
|
||||||
|
fields.some((candidate) => candidate.configPath === path),
|
||||||
|
false,
|
||||||
|
path,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('settings registry orders websocket server immediately after annotation websocket', () => {
|
||||||
|
const integrationSections = [
|
||||||
|
...new Set(
|
||||||
|
fields
|
||||||
|
.filter((candidate) => candidate.category === 'integrations')
|
||||||
|
.map((candidate) => candidate.section),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
const annotationIndex = integrationSections.indexOf('Annotation WebSocket');
|
||||||
|
assert.equal(integrationSections[annotationIndex + 1], 'WebSocket server');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('settings registry explains websocket auto mode and keeps it disabled by default', () => {
|
||||||
|
assert.equal(field('websocket.enabled').defaultValue, false);
|
||||||
|
assert.equal(
|
||||||
|
field('websocket.enabled').description,
|
||||||
|
'Built-in subtitle WebSocket server mode. Auto starts the built-in server only when mpv_websocket is not detected; otherwise it defers to the plugin.',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('settings registry places immersion tracking after other tracking and app sections', () => {
|
||||||
|
const trackingSections = [
|
||||||
|
...new Set(
|
||||||
|
fields
|
||||||
|
.filter((candidate) => candidate.category === 'tracking-app')
|
||||||
|
.map((candidate) => candidate.section),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
assert.equal(trackingSections.at(-1), 'Immersion tracking');
|
||||||
|
});
|
||||||
|
|
||||||
test('settings registry groups annotation display fields by config group', () => {
|
test('settings registry groups annotation display fields by config group', () => {
|
||||||
assert.equal(field('ankiConnect.knownWords.highlightEnabled').section, 'Annotation Display');
|
assert.equal(field('ankiConnect.knownWords.highlightEnabled').section, 'Annotation Display');
|
||||||
assert.equal(field('ankiConnect.knownWords.highlightEnabled').subsection, 'Known Words');
|
assert.equal(field('ankiConnect.knownWords.highlightEnabled').subsection, 'Known Words');
|
||||||
@@ -134,8 +202,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', () => {
|
||||||
@@ -190,6 +258,7 @@ test('settings registry hides app-managed and inactive config surfaces', () => {
|
|||||||
|
|
||||||
test('settings registry marks safe live config paths as hot-reloadable', () => {
|
test('settings registry marks safe live config paths as hot-reloadable', () => {
|
||||||
for (const path of [
|
for (const path of [
|
||||||
|
'mpv.aniskipButtonKey',
|
||||||
'stats.toggleKey',
|
'stats.toggleKey',
|
||||||
'stats.markWatchedKey',
|
'stats.markWatchedKey',
|
||||||
'logging.level',
|
'logging.level',
|
||||||
@@ -197,7 +266,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',
|
||||||
@@ -218,6 +287,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',
|
||||||
|
|||||||
@@ -65,13 +65,17 @@ export const LEGACY_HIDDEN_CONFIG_PATHS = [
|
|||||||
'youtubeSubgen.primarySubLanguages',
|
'youtubeSubgen.primarySubLanguages',
|
||||||
'anilist.characterDictionary.refreshTtlHours',
|
'anilist.characterDictionary.refreshTtlHours',
|
||||||
'anilist.characterDictionary.evictionPolicy',
|
'anilist.characterDictionary.evictionPolicy',
|
||||||
|
'anilist.characterDictionary.profileScope',
|
||||||
'jellyfin.accessToken',
|
'jellyfin.accessToken',
|
||||||
'jellyfin.userId',
|
'jellyfin.userId',
|
||||||
'jellyfin.clientName',
|
'jellyfin.clientName',
|
||||||
'jellyfin.clientVersion',
|
'jellyfin.clientVersion',
|
||||||
'jellyfin.defaultLibraryId',
|
'jellyfin.defaultLibraryId',
|
||||||
'jellyfin.deviceId',
|
'jellyfin.deviceId',
|
||||||
|
'jellyfin.directPlayContainers',
|
||||||
|
'jellyfin.remoteControlDeviceName',
|
||||||
'controller.buttonIndices',
|
'controller.buttonIndices',
|
||||||
|
'shortcuts.multiCopyTimeoutMs',
|
||||||
'subtitleSidebar.toggleKey',
|
'subtitleSidebar.toggleKey',
|
||||||
'jellyfin.recentServers',
|
'jellyfin.recentServers',
|
||||||
] as const;
|
] as const;
|
||||||
@@ -123,12 +127,11 @@ const SECTION_ORDER = new Map<string, number>(
|
|||||||
'Primary Subtitle Appearance',
|
'Primary Subtitle Appearance',
|
||||||
'Secondary Subtitle Appearance',
|
'Secondary Subtitle Appearance',
|
||||||
'Subtitle Sidebar Appearance',
|
'Subtitle Sidebar Appearance',
|
||||||
'Playback Pause Behavior',
|
'Playback Behavior',
|
||||||
'Subtitle Behavior',
|
'Subtitle Behavior',
|
||||||
'Subtitle Sidebar Behavior',
|
'Subtitle Sidebar Behavior',
|
||||||
'Visible Overlay Auto-Start',
|
|
||||||
'YouTube Playback Settings',
|
'YouTube Playback Settings',
|
||||||
'MPV Launcher',
|
'mpv Playback',
|
||||||
'Note Fields',
|
'Note Fields',
|
||||||
'Media Capture',
|
'Media Capture',
|
||||||
'Kiku/Lapis Features',
|
'Kiku/Lapis Features',
|
||||||
@@ -140,7 +143,19 @@ const SECTION_ORDER = new Map<string, number>(
|
|||||||
'MPV Keybindings',
|
'MPV Keybindings',
|
||||||
'Overlay Shortcuts',
|
'Overlay Shortcuts',
|
||||||
'Controller',
|
'Controller',
|
||||||
|
'Annotation WebSocket',
|
||||||
|
'WebSocket server',
|
||||||
|
'AniList',
|
||||||
'Character Dictionary',
|
'Character Dictionary',
|
||||||
|
'Discord Rich Presence',
|
||||||
|
'Jellyfin',
|
||||||
|
'Texthooker',
|
||||||
|
'Yomitan',
|
||||||
|
'Stats dashboard',
|
||||||
|
'Startup warmups',
|
||||||
|
'Logging',
|
||||||
|
'Updates',
|
||||||
|
'Immersion tracking',
|
||||||
].map((section, index) => [section, index]),
|
].map((section, index) => [section, index]),
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -169,9 +184,9 @@ const PATH_ORDER = new Map<string, number>(
|
|||||||
'mpv.backend',
|
'mpv.backend',
|
||||||
'mpv.subminerBinaryPath',
|
'mpv.subminerBinaryPath',
|
||||||
'mpv.aniskipEnabled',
|
'mpv.aniskipEnabled',
|
||||||
'mpv.aniskipButtonKey',
|
|
||||||
'mpv.launchMode',
|
'mpv.launchMode',
|
||||||
'mpv.executablePath',
|
'mpv.executablePath',
|
||||||
|
'mpv.aniskipButtonKey',
|
||||||
].map((path, index) => [path, index]),
|
].map((path, index) => [path, index]),
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -186,7 +201,6 @@ const SUBSECTION_ORDER = new Map<string, number>(
|
|||||||
'Toggle & Visibility',
|
'Toggle & Visibility',
|
||||||
'Open Panels',
|
'Open Panels',
|
||||||
'Playback',
|
'Playback',
|
||||||
'Timing',
|
|
||||||
'Default Fold State',
|
'Default Fold State',
|
||||||
].map((subsection, index) => [subsection, index]),
|
].map((subsection, index) => [subsection, index]),
|
||||||
);
|
);
|
||||||
@@ -215,6 +229,7 @@ const LABEL_OVERRIDES: Record<string, string> = {
|
|||||||
'mpv.pauseUntilOverlayReady': 'Pause Until Overlay Ready',
|
'mpv.pauseUntilOverlayReady': 'Pause Until Overlay Ready',
|
||||||
'mpv.aniskipEnabled': 'Enable AniSkip',
|
'mpv.aniskipEnabled': 'Enable AniSkip',
|
||||||
'mpv.aniskipButtonKey': 'AniSkip Button Key',
|
'mpv.aniskipButtonKey': 'AniSkip Button Key',
|
||||||
|
'discordPresence.updateIntervalMs': 'Update Interval (ms)',
|
||||||
};
|
};
|
||||||
|
|
||||||
const DESCRIPTION_OVERRIDES: Record<string, string> = {
|
const DESCRIPTION_OVERRIDES: Record<string, string> = {
|
||||||
@@ -232,6 +247,10 @@ const DESCRIPTION_OVERRIDES: Record<string, string> = {
|
|||||||
'CSS declarations applied to secondary subtitles. Includes color, background-color, and all font properties.',
|
'CSS declarations applied to secondary subtitles. Includes color, background-color, and all font properties.',
|
||||||
'subtitleSidebar.css':
|
'subtitleSidebar.css':
|
||||||
'CSS declarations applied to the subtitle sidebar. Includes color, background-color, all font properties, and sidebar CSS variables.',
|
'CSS declarations applied to the subtitle sidebar. Includes color, background-color, all font properties, and sidebar CSS variables.',
|
||||||
|
'websocket.enabled':
|
||||||
|
'Built-in subtitle WebSocket server mode. Auto starts the built-in server only when mpv_websocket is not detected; otherwise it defers to the plugin.',
|
||||||
|
'discordPresence.updateIntervalMs':
|
||||||
|
'Minimum interval between presence payload updates, in milliseconds.',
|
||||||
};
|
};
|
||||||
|
|
||||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||||
@@ -295,7 +314,7 @@ function categoryAndSection(path: string): { category: ConfigSettingsCategory; s
|
|||||||
path === 'subtitleStyle.autoPauseVideoOnYomitanPopup' ||
|
path === 'subtitleStyle.autoPauseVideoOnYomitanPopup' ||
|
||||||
path === 'subtitleSidebar.pauseVideoOnHover'
|
path === 'subtitleSidebar.pauseVideoOnHover'
|
||||||
) {
|
) {
|
||||||
return { category: 'behavior', section: 'Playback Pause Behavior' };
|
return { category: 'behavior', section: 'Playback Behavior' };
|
||||||
}
|
}
|
||||||
if (path === 'subtitleStyle.preserveLineBreaks') {
|
if (path === 'subtitleStyle.preserveLineBreaks') {
|
||||||
return { category: 'behavior', section: 'Subtitle Behavior' };
|
return { category: 'behavior', section: 'Subtitle Behavior' };
|
||||||
@@ -373,8 +392,15 @@ function categoryAndSection(path: string): { category: ConfigSettingsCategory; s
|
|||||||
if (path.startsWith('ankiConnect.')) {
|
if (path.startsWith('ankiConnect.')) {
|
||||||
return { category: 'mining-anki', section: 'AnkiConnect' };
|
return { category: 'mining-anki', section: 'AnkiConnect' };
|
||||||
}
|
}
|
||||||
if (path === 'auto_start_overlay') {
|
if (
|
||||||
return { category: 'behavior', section: topSection(path) };
|
path === 'auto_start_overlay' ||
|
||||||
|
path === 'mpv.autoStartSubMiner' ||
|
||||||
|
path === 'mpv.pauseUntilOverlayReady'
|
||||||
|
) {
|
||||||
|
return { category: 'behavior', section: 'Playback Behavior' };
|
||||||
|
}
|
||||||
|
if (path === 'mpv.aniskipButtonKey') {
|
||||||
|
return { category: 'input', section: 'Overlay Shortcuts' };
|
||||||
}
|
}
|
||||||
if (path.startsWith('mpv.') || path.startsWith('youtube.')) {
|
if (path.startsWith('mpv.') || path.startsWith('youtube.')) {
|
||||||
return { category: 'behavior', section: topSection(path) };
|
return { category: 'behavior', section: topSection(path) };
|
||||||
@@ -437,7 +463,7 @@ function topSection(path: string): string {
|
|||||||
jimaku: 'Jimaku',
|
jimaku: 'Jimaku',
|
||||||
jellyfin: 'Jellyfin',
|
jellyfin: 'Jellyfin',
|
||||||
logging: 'Logging',
|
logging: 'Logging',
|
||||||
mpv: 'MPV Launcher',
|
mpv: 'mpv Playback',
|
||||||
stats: 'Stats dashboard',
|
stats: 'Stats dashboard',
|
||||||
startupWarmups: 'Startup warmups',
|
startupWarmups: 'Startup warmups',
|
||||||
subsync: 'Subtitle Sync',
|
subsync: 'Subtitle Sync',
|
||||||
@@ -447,7 +473,7 @@ function topSection(path: string): string {
|
|||||||
yomitan: 'Yomitan',
|
yomitan: 'Yomitan',
|
||||||
youtube: 'YouTube Playback Settings',
|
youtube: 'YouTube Playback Settings',
|
||||||
youtubeSubgen: 'YouTube subtitle generation',
|
youtubeSubgen: 'YouTube subtitle generation',
|
||||||
auto_start_overlay: 'Visible Overlay Auto-Start',
|
auto_start_overlay: 'Playback Behavior',
|
||||||
};
|
};
|
||||||
return labels[top] ?? humanizePath(top);
|
return labels[top] ?? humanizePath(top);
|
||||||
}
|
}
|
||||||
@@ -515,9 +541,11 @@ function subsectionForPath(path: string): string | undefined {
|
|||||||
if (path === 'stats.toggleKey' || path === 'stats.markWatchedKey') {
|
if (path === 'stats.toggleKey' || path === 'stats.markWatchedKey') {
|
||||||
return 'Toggle & Visibility';
|
return 'Toggle & Visibility';
|
||||||
}
|
}
|
||||||
|
if (path === 'mpv.aniskipButtonKey') {
|
||||||
|
return 'Playback';
|
||||||
|
}
|
||||||
if (path.startsWith('shortcuts.')) {
|
if (path.startsWith('shortcuts.')) {
|
||||||
const leaf = path.split('.').at(-1) ?? '';
|
const leaf = path.split('.').at(-1) ?? '';
|
||||||
if (leaf === 'multiCopyTimeoutMs') return 'Timing';
|
|
||||||
if (
|
if (
|
||||||
leaf === 'copySubtitle' ||
|
leaf === 'copySubtitle' ||
|
||||||
leaf === 'copySubtitleMultiple' ||
|
leaf === 'copySubtitleMultiple' ||
|
||||||
@@ -632,6 +660,7 @@ function restartBehaviorForPath(path: string): ConfigSettingsRestartBehavior {
|
|||||||
path === 'ankiConnect.fields.miscInfo' ||
|
path === 'ankiConnect.fields.miscInfo' ||
|
||||||
path === 'ankiConnect.isLapis.sentenceCardModel' ||
|
path === 'ankiConnect.isLapis.sentenceCardModel' ||
|
||||||
path === 'ankiConnect.isKiku.fieldGrouping' ||
|
path === 'ankiConnect.isKiku.fieldGrouping' ||
|
||||||
|
path === 'mpv.aniskipButtonKey' ||
|
||||||
path === 'stats.toggleKey' ||
|
path === 'stats.toggleKey' ||
|
||||||
path === 'stats.markWatchedKey' ||
|
path === 'stats.markWatchedKey' ||
|
||||||
path === 'logging.level' ||
|
path === 'logging.level' ||
|
||||||
|
|||||||
@@ -14,8 +14,8 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
|
|||||||
toggle: false,
|
toggle: false,
|
||||||
toggleVisibleOverlay: false,
|
toggleVisibleOverlay: false,
|
||||||
togglePrimarySubtitleBar: false,
|
togglePrimarySubtitleBar: false,
|
||||||
|
yomitan: false,
|
||||||
settings: false,
|
settings: false,
|
||||||
configSettings: false,
|
|
||||||
setup: false,
|
setup: false,
|
||||||
show: false,
|
show: false,
|
||||||
hide: false,
|
hide: false,
|
||||||
@@ -223,3 +223,97 @@ test('startAppLifecycle queues second-instance commands until app ready runtime
|
|||||||
runSecondInstance(['SubMiner', '--start']);
|
runSecondInstance(['SubMiner', '--start']);
|
||||||
assert.deepEqual(handled, ['ready', 'second-instance:start', 'second-instance:start']);
|
assert.deepEqual(handled, ['ready', 'second-instance:start', 'second-instance:start']);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('startAppLifecycle routes control socket commands through the second-instance queue', async () => {
|
||||||
|
const handled: string[] = [];
|
||||||
|
let controlArgvHandler: ((argv: string[]) => void) | null = null;
|
||||||
|
let readyHandler: (() => Promise<void>) | null = null;
|
||||||
|
let releaseReady: (() => void) | null = null;
|
||||||
|
const readyFinished = new Promise<void>((resolve) => {
|
||||||
|
releaseReady = resolve;
|
||||||
|
});
|
||||||
|
|
||||||
|
const { deps } = createDeps({
|
||||||
|
shouldStartApp: () => true,
|
||||||
|
parseArgs: (argv) => makeArgs({ start: argv.includes('--start') }),
|
||||||
|
handleCliCommand: (args, source) => {
|
||||||
|
handled.push(`${source}:${args.start ? 'start' : 'other'}`);
|
||||||
|
},
|
||||||
|
startControlServer: (handler) => {
|
||||||
|
controlArgvHandler = handler;
|
||||||
|
return () => {
|
||||||
|
handled.push('control-close');
|
||||||
|
};
|
||||||
|
},
|
||||||
|
whenReady: (handler) => {
|
||||||
|
readyHandler = handler;
|
||||||
|
},
|
||||||
|
onReady: async () => {
|
||||||
|
await readyFinished;
|
||||||
|
handled.push('ready');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
let willQuitHandler: (() => void) | null = null;
|
||||||
|
deps.onWillQuit = (handler) => {
|
||||||
|
willQuitHandler = handler;
|
||||||
|
};
|
||||||
|
|
||||||
|
startAppLifecycle(makeArgs({ background: true }), deps);
|
||||||
|
|
||||||
|
assert.ok(controlArgvHandler);
|
||||||
|
(controlArgvHandler as (argv: string[]) => void)(['--start']);
|
||||||
|
assert.deepEqual(handled, []);
|
||||||
|
|
||||||
|
assert.ok(readyHandler);
|
||||||
|
const readyRun = (readyHandler as () => Promise<void>)();
|
||||||
|
await Promise.resolve();
|
||||||
|
assert.deepEqual(handled, []);
|
||||||
|
|
||||||
|
assert.ok(releaseReady);
|
||||||
|
(releaseReady as () => void)();
|
||||||
|
await readyRun;
|
||||||
|
assert.deepEqual(handled, ['ready', 'second-instance:start']);
|
||||||
|
|
||||||
|
assert.ok(willQuitHandler);
|
||||||
|
(willQuitHandler as () => void)();
|
||||||
|
assert.deepEqual(handled, ['ready', 'second-instance:start', 'control-close']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('startAppLifecycle quits macOS config-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({ settings: true }), deps);
|
||||||
|
|
||||||
|
const handler = windowAllClosedHandler as (() => void) | null;
|
||||||
|
assert.ok(handler);
|
||||||
|
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']);
|
||||||
|
});
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ export interface AppLifecycleServiceDeps {
|
|||||||
handleCliCommand: (args: CliArgs, source: CliCommandSource) => void;
|
handleCliCommand: (args: CliArgs, source: CliCommandSource) => void;
|
||||||
printHelp: () => void;
|
printHelp: () => void;
|
||||||
logNoRunningInstance: () => void;
|
logNoRunningInstance: () => void;
|
||||||
|
startControlServer?: (handleArgv: (argv: string[]) => void) => (() => void) | void;
|
||||||
whenReady: (handler: () => Promise<void>) => void;
|
whenReady: (handler: () => Promise<void>) => void;
|
||||||
onWindowAllClosed: (handler: () => void) => void;
|
onWindowAllClosed: (handler: () => void) => void;
|
||||||
onWillQuit: (handler: () => void) => void;
|
onWillQuit: (handler: () => void) => void;
|
||||||
@@ -41,6 +42,7 @@ export interface AppLifecycleDepsRuntimeOptions {
|
|||||||
handleCliCommand: (args: CliArgs, source: CliCommandSource) => void;
|
handleCliCommand: (args: CliArgs, source: CliCommandSource) => void;
|
||||||
printHelp: () => void;
|
printHelp: () => void;
|
||||||
logNoRunningInstance: () => void;
|
logNoRunningInstance: () => void;
|
||||||
|
startControlServer?: (handleArgv: (argv: string[]) => void) => (() => void) | void;
|
||||||
onReady: () => Promise<void>;
|
onReady: () => Promise<void>;
|
||||||
onWillQuitCleanup: () => void;
|
onWillQuitCleanup: () => void;
|
||||||
shouldRestoreWindowsOnActivate: () => boolean;
|
shouldRestoreWindowsOnActivate: () => boolean;
|
||||||
@@ -70,6 +72,7 @@ export function createAppLifecycleDepsRuntime(
|
|||||||
handleCliCommand: options.handleCliCommand,
|
handleCliCommand: options.handleCliCommand,
|
||||||
printHelp: options.printHelp,
|
printHelp: options.printHelp,
|
||||||
logNoRunningInstance: options.logNoRunningInstance,
|
logNoRunningInstance: options.logNoRunningInstance,
|
||||||
|
startControlServer: options.startControlServer,
|
||||||
whenReady: (handler) => {
|
whenReady: (handler) => {
|
||||||
options.app
|
options.app
|
||||||
.whenReady()
|
.whenReady()
|
||||||
@@ -116,6 +119,7 @@ export function startAppLifecycle(initialArgs: CliArgs, deps: AppLifecycleServic
|
|||||||
|
|
||||||
let appReadyRuntimeComplete = false;
|
let appReadyRuntimeComplete = false;
|
||||||
const pendingSecondInstanceCommands: CliArgs[] = [];
|
const pendingSecondInstanceCommands: CliArgs[] = [];
|
||||||
|
let stopControlServer: (() => void) | null = null;
|
||||||
const handleSecondInstanceCommand = (args: CliArgs): void => {
|
const handleSecondInstanceCommand = (args: CliArgs): void => {
|
||||||
try {
|
try {
|
||||||
deps.handleCliCommand(args, 'second-instance');
|
deps.handleCliCommand(args, 'second-instance');
|
||||||
@@ -133,7 +137,7 @@ export function startAppLifecycle(initialArgs: CliArgs, deps: AppLifecycleServic
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
deps.onSecondInstance((_event, argv) => {
|
const dispatchSecondInstanceArgv = (argv: string[]): void => {
|
||||||
try {
|
try {
|
||||||
const nextArgs = deps.parseArgs(argv);
|
const nextArgs = deps.parseArgs(argv);
|
||||||
if (!appReadyRuntimeComplete) {
|
if (!appReadyRuntimeComplete) {
|
||||||
@@ -145,6 +149,10 @@ export function startAppLifecycle(initialArgs: CliArgs, deps: AppLifecycleServic
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('Failed to handle second-instance CLI command:', error);
|
logger.error('Failed to handle second-instance CLI command:', error);
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
deps.onSecondInstance((_event, argv) => {
|
||||||
|
dispatchSecondInstanceArgv(argv);
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!deps.shouldStartApp(initialArgs)) {
|
if (!deps.shouldStartApp(initialArgs)) {
|
||||||
@@ -157,6 +165,12 @@ export function startAppLifecycle(initialArgs: CliArgs, deps: AppLifecycleServic
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
stopControlServer = deps.startControlServer?.(dispatchSecondInstanceArgv) ?? null;
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Failed to start app control socket:', error);
|
||||||
|
}
|
||||||
|
|
||||||
deps.whenReady(async () => {
|
deps.whenReady(async () => {
|
||||||
await deps.onReady();
|
await deps.onReady();
|
||||||
appReadyRuntimeComplete = true;
|
appReadyRuntimeComplete = true;
|
||||||
@@ -164,12 +178,17 @@ export function startAppLifecycle(initialArgs: CliArgs, deps: AppLifecycleServic
|
|||||||
});
|
});
|
||||||
|
|
||||||
deps.onWindowAllClosed(() => {
|
deps.onWindowAllClosed(() => {
|
||||||
if (!deps.isDarwinPlatform() && deps.shouldQuitOnWindowAllClosed()) {
|
if (
|
||||||
|
deps.shouldQuitOnWindowAllClosed() &&
|
||||||
|
(!deps.isDarwinPlatform() || initialArgs.settings || initialArgs.setup)
|
||||||
|
) {
|
||||||
deps.quitApp();
|
deps.quitApp();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
deps.onWillQuit(() => {
|
deps.onWillQuit(() => {
|
||||||
|
stopControlServer?.();
|
||||||
|
stopControlServer = null;
|
||||||
deps.onWillQuitCleanup();
|
deps.onWillQuitCleanup();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,11 @@
|
|||||||
import test from 'node:test';
|
import test from 'node:test';
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import { CliArgs } from '../../cli/args';
|
import { CliArgs } from '../../cli/args';
|
||||||
import { CliCommandServiceDeps, handleCliCommand } from './cli-command';
|
import {
|
||||||
|
CliCommandServiceDeps,
|
||||||
|
createCliCommandDepsRuntime,
|
||||||
|
handleCliCommand,
|
||||||
|
} from './cli-command';
|
||||||
|
|
||||||
function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
|
function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
|
||||||
return {
|
return {
|
||||||
@@ -15,8 +19,8 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
|
|||||||
stop: false,
|
stop: false,
|
||||||
toggle: false,
|
toggle: false,
|
||||||
toggleVisibleOverlay: false,
|
toggleVisibleOverlay: false,
|
||||||
|
yomitan: false,
|
||||||
settings: false,
|
settings: false,
|
||||||
configSettings: false,
|
|
||||||
setup: false,
|
setup: false,
|
||||||
show: false,
|
show: false,
|
||||||
hide: false,
|
hide: false,
|
||||||
@@ -501,6 +505,132 @@ test('handleCliCommand applies socket path and connects on start', () => {
|
|||||||
assert.ok(calls.includes('connectMpvClient'));
|
assert.ok(calls.includes('connectMpvClient'));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('createCliCommandDepsRuntime reconnects MPV client when reconnect hook exists', () => {
|
||||||
|
const calls: string[] = [];
|
||||||
|
const client = {
|
||||||
|
setSocketPath: (socketPath: string) => {
|
||||||
|
calls.push(`setSocketPath:${socketPath}`);
|
||||||
|
},
|
||||||
|
connect: () => {
|
||||||
|
calls.push('connect');
|
||||||
|
},
|
||||||
|
reconnect: () => {
|
||||||
|
calls.push('reconnect');
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const deps = createCliCommandDepsRuntime({
|
||||||
|
mpv: {
|
||||||
|
getSocketPath: () => '/tmp/runtime.sock',
|
||||||
|
setSocketPath: () => {},
|
||||||
|
getClient: () => client,
|
||||||
|
showOsd: () => {},
|
||||||
|
},
|
||||||
|
texthooker: {
|
||||||
|
service: { isRunning: () => false, start: () => {} },
|
||||||
|
getPort: () => 5174,
|
||||||
|
setPort: () => {},
|
||||||
|
getWebsocketUrl: () => undefined,
|
||||||
|
shouldOpenBrowser: () => false,
|
||||||
|
openInBrowser: () => {},
|
||||||
|
},
|
||||||
|
overlay: {
|
||||||
|
isInitialized: () => true,
|
||||||
|
initialize: () => {},
|
||||||
|
toggleVisible: () => {},
|
||||||
|
togglePrimarySubtitleBar: () => {},
|
||||||
|
setVisible: () => {},
|
||||||
|
},
|
||||||
|
mining: {
|
||||||
|
copyCurrentSubtitle: () => {},
|
||||||
|
startPendingMultiCopy: () => {},
|
||||||
|
mineSentenceCard: async () => {},
|
||||||
|
startPendingMineSentenceMultiple: () => {},
|
||||||
|
updateLastCardFromClipboard: async () => {},
|
||||||
|
refreshKnownWords: async () => {},
|
||||||
|
triggerFieldGrouping: async () => {},
|
||||||
|
triggerSubsyncFromConfig: async () => {},
|
||||||
|
markLastCardAsAudioCard: async () => {},
|
||||||
|
},
|
||||||
|
anilist: {
|
||||||
|
getStatus: () => ({
|
||||||
|
tokenStatus: 'not_checked',
|
||||||
|
tokenSource: 'none',
|
||||||
|
tokenMessage: null,
|
||||||
|
tokenResolvedAt: null,
|
||||||
|
tokenErrorAt: null,
|
||||||
|
queuePending: 0,
|
||||||
|
queueReady: 0,
|
||||||
|
queueDeadLetter: 0,
|
||||||
|
queueLastAttemptAt: null,
|
||||||
|
queueLastError: null,
|
||||||
|
}),
|
||||||
|
clearToken: () => {},
|
||||||
|
openSetup: () => {},
|
||||||
|
getQueueStatus: () => ({
|
||||||
|
pending: 0,
|
||||||
|
ready: 0,
|
||||||
|
deadLetter: 0,
|
||||||
|
lastAttemptAt: null,
|
||||||
|
lastError: null,
|
||||||
|
}),
|
||||||
|
retryQueueNow: async () => ({ ok: true, message: 'ok' }),
|
||||||
|
},
|
||||||
|
dictionary: {
|
||||||
|
generate: async () => ({
|
||||||
|
zipPath: '/tmp/test.zip',
|
||||||
|
fromCache: false,
|
||||||
|
mediaId: 1,
|
||||||
|
mediaTitle: 'Test',
|
||||||
|
entryCount: 0,
|
||||||
|
}),
|
||||||
|
getSelection: async () => ({
|
||||||
|
seriesKey: 'test',
|
||||||
|
guessTitle: null,
|
||||||
|
current: null,
|
||||||
|
override: null,
|
||||||
|
candidates: [],
|
||||||
|
}),
|
||||||
|
setSelection: async () => ({
|
||||||
|
ok: true,
|
||||||
|
seriesKey: 'test',
|
||||||
|
selected: { id: 1, title: 'Test', episodes: null },
|
||||||
|
staleMediaIds: [],
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
jellyfin: {
|
||||||
|
openSetup: () => {},
|
||||||
|
runStatsCommand: async () => {},
|
||||||
|
runCommand: async () => {},
|
||||||
|
},
|
||||||
|
ui: {
|
||||||
|
openFirstRunSetup: () => {},
|
||||||
|
openYomitanSettings: () => {},
|
||||||
|
openConfigSettingsWindow: () => {},
|
||||||
|
cycleSecondarySubMode: () => {},
|
||||||
|
openRuntimeOptionsPalette: () => {},
|
||||||
|
printHelp: () => {},
|
||||||
|
},
|
||||||
|
app: {
|
||||||
|
stop: () => {},
|
||||||
|
hasMainWindow: () => true,
|
||||||
|
runUpdateCommand: async () => {},
|
||||||
|
runYoutubePlaybackFlow: async () => {},
|
||||||
|
},
|
||||||
|
dispatchSessionAction: async () => {},
|
||||||
|
getMultiCopyTimeoutMs: () => 2500,
|
||||||
|
schedule: () => undefined,
|
||||||
|
log: () => {},
|
||||||
|
logDebug: () => {},
|
||||||
|
warn: () => {},
|
||||||
|
error: () => {},
|
||||||
|
});
|
||||||
|
|
||||||
|
deps.setMpvClientSocketPath('/tmp/runtime.sock');
|
||||||
|
deps.connectMpvClient();
|
||||||
|
|
||||||
|
assert.deepEqual(calls, ['setSocketPath:/tmp/runtime.sock', 'reconnect']);
|
||||||
|
});
|
||||||
|
|
||||||
test('handleCliCommand warns when texthooker port override used while running', () => {
|
test('handleCliCommand warns when texthooker port override used while running', () => {
|
||||||
const { deps, calls } = createDeps({
|
const { deps, calls } = createDeps({
|
||||||
isTexthookerRunning: () => true,
|
isTexthookerRunning: () => true,
|
||||||
@@ -586,8 +716,8 @@ test('handleCliCommand handles visibility and utility command dispatches', () =>
|
|||||||
args: Partial<CliArgs>;
|
args: Partial<CliArgs>;
|
||||||
expected: string;
|
expected: string;
|
||||||
}> = [
|
}> = [
|
||||||
{ args: { settings: true }, expected: 'openYomitanSettingsDelayed:1000' },
|
{ args: { yomitan: true }, expected: 'openYomitanSettingsDelayed:1000' },
|
||||||
{ args: { configSettings: true }, expected: 'openConfigSettingsWindow' },
|
{ args: { settings: true }, expected: 'openConfigSettingsWindow' },
|
||||||
{
|
{
|
||||||
args: { showVisibleOverlay: true },
|
args: { showVisibleOverlay: true },
|
||||||
expected: 'setVisibleOverlayVisible:true',
|
expected: 'setVisibleOverlayVisible:true',
|
||||||
|
|||||||
@@ -115,6 +115,7 @@ export interface CliCommandServiceDeps {
|
|||||||
interface MpvClientLike {
|
interface MpvClientLike {
|
||||||
setSocketPath: (socketPath: string) => void;
|
setSocketPath: (socketPath: string) => void;
|
||||||
connect: () => void;
|
connect: () => void;
|
||||||
|
reconnect?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface TexthookerServiceLike {
|
interface TexthookerServiceLike {
|
||||||
@@ -235,6 +236,10 @@ export function createCliCommandDepsRuntime(
|
|||||||
connectMpvClient: () => {
|
connectMpvClient: () => {
|
||||||
const client = options.mpv.getClient();
|
const client = options.mpv.getClient();
|
||||||
if (!client) return;
|
if (!client) return;
|
||||||
|
if (client.reconnect) {
|
||||||
|
client.reconnect();
|
||||||
|
return;
|
||||||
|
}
|
||||||
client.connect();
|
client.connect();
|
||||||
},
|
},
|
||||||
isTexthookerRunning: () => options.texthooker.service.isRunning(),
|
isTexthookerRunning: () => options.texthooker.service.isRunning(),
|
||||||
@@ -386,9 +391,9 @@ export function handleCliCommand(
|
|||||||
} else if (args.setup) {
|
} else if (args.setup) {
|
||||||
deps.openFirstRunSetup(true);
|
deps.openFirstRunSetup(true);
|
||||||
deps.logDebug('Opened first-run setup flow.');
|
deps.logDebug('Opened first-run setup flow.');
|
||||||
} else if (args.settings) {
|
} else if (args.yomitan) {
|
||||||
deps.openYomitanSettingsDelayed(1000);
|
deps.openYomitanSettingsDelayed(1000);
|
||||||
} else if (args.configSettings) {
|
} else if (args.settings) {
|
||||||
deps.openConfigSettingsWindow();
|
deps.openConfigSettingsWindow();
|
||||||
} else if (args.show || args.showVisibleOverlay) {
|
} else if (args.show || args.showVisibleOverlay) {
|
||||||
deps.setVisibleOverlayVisible(true);
|
deps.setVisibleOverlayVisible(true);
|
||||||
|
|||||||
@@ -21,12 +21,13 @@ test('classifyConfigHotReloadDiff separates hot and restart-required fields', ()
|
|||||||
test('classifyConfigHotReloadDiff treats safe nested config paths as hot-reloadable', () => {
|
test('classifyConfigHotReloadDiff treats safe nested config paths as hot-reloadable', () => {
|
||||||
const prev = deepCloneConfig(DEFAULT_CONFIG);
|
const prev = deepCloneConfig(DEFAULT_CONFIG);
|
||||||
const next = deepCloneConfig(DEFAULT_CONFIG);
|
const next = deepCloneConfig(DEFAULT_CONFIG);
|
||||||
|
next.mpv.aniskipButtonKey = 'F8';
|
||||||
next.stats.toggleKey = 'F8';
|
next.stats.toggleKey = 'F8';
|
||||||
next.stats.markWatchedKey = 'F9';
|
next.stats.markWatchedKey = 'F9';
|
||||||
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;
|
||||||
@@ -52,11 +53,12 @@ test('classifyConfigHotReloadDiff treats safe nested config paths as hot-reloada
|
|||||||
new Set(diff.hotReloadFields),
|
new Set(diff.hotReloadFields),
|
||||||
new Set([
|
new Set([
|
||||||
'stats.toggleKey',
|
'stats.toggleKey',
|
||||||
|
'mpv.aniskipButtonKey',
|
||||||
'stats.markWatchedKey',
|
'stats.markWatchedKey',
|
||||||
'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',
|
||||||
|
|||||||
@@ -56,6 +56,7 @@ const HOT_RELOAD_ROOTS = ['subtitleStyle', 'keybindings', 'shortcuts', 'subtitle
|
|||||||
|
|
||||||
const HOT_RELOAD_EXACT_OR_PREFIX_PATHS = [
|
const HOT_RELOAD_EXACT_OR_PREFIX_PATHS = [
|
||||||
'secondarySub.defaultMode',
|
'secondarySub.defaultMode',
|
||||||
|
'mpv.aniskipButtonKey',
|
||||||
'ankiConnect.ai.enabled',
|
'ankiConnect.ai.enabled',
|
||||||
'stats.toggleKey',
|
'stats.toggleKey',
|
||||||
'stats.markWatchedKey',
|
'stats.markWatchedKey',
|
||||||
|
|||||||
@@ -32,6 +32,12 @@ class FakeSocket extends EventEmitter {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class ManualCloseSocket extends FakeSocket {
|
||||||
|
override destroy(): void {
|
||||||
|
this.destroyed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const wait = () => new Promise((resolve) => setTimeout(resolve, 0));
|
const wait = () => new Promise((resolve) => setTimeout(resolve, 0));
|
||||||
|
|
||||||
test('getMpvReconnectDelay follows existing reconnect ramp', () => {
|
test('getMpvReconnectDelay follows existing reconnect ramp', () => {
|
||||||
@@ -203,12 +209,15 @@ test('MpvSocketTransport ignores connect requests while already connecting or co
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('MpvSocketTransport.shutdown clears socket and lifecycle flags', async () => {
|
test('MpvSocketTransport.shutdown clears socket and lifecycle flags', async () => {
|
||||||
|
const events: string[] = [];
|
||||||
const transport = new MpvSocketTransport({
|
const transport = new MpvSocketTransport({
|
||||||
socketPath: '/tmp/mpv.sock',
|
socketPath: '/tmp/mpv.sock',
|
||||||
onConnect: () => {},
|
onConnect: () => {},
|
||||||
onData: () => {},
|
onData: () => {},
|
||||||
onError: () => {},
|
onError: () => {},
|
||||||
onClose: () => {},
|
onClose: () => {
|
||||||
|
events.push('close');
|
||||||
|
},
|
||||||
socketFactory: () => new FakeSocket() as unknown as net.Socket,
|
socketFactory: () => new FakeSocket() as unknown as net.Socket,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -220,4 +229,45 @@ test('MpvSocketTransport.shutdown clears socket and lifecycle flags', async () =
|
|||||||
assert.equal(transport.isConnected, false);
|
assert.equal(transport.isConnected, false);
|
||||||
assert.equal(transport.isConnecting, false);
|
assert.equal(transport.isConnecting, false);
|
||||||
assert.equal(transport.getSocket(), null);
|
assert.equal(transport.getSocket(), null);
|
||||||
|
assert.deepEqual(events, []);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('MpvSocketTransport ignores stale socket events after shutdown and reconnect', async () => {
|
||||||
|
const events: string[] = [];
|
||||||
|
const sockets: ManualCloseSocket[] = [];
|
||||||
|
const transport = new MpvSocketTransport({
|
||||||
|
socketPath: '/tmp/mpv.sock',
|
||||||
|
onConnect: () => {
|
||||||
|
events.push('connect');
|
||||||
|
},
|
||||||
|
onData: () => {
|
||||||
|
events.push('data');
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
events.push('error');
|
||||||
|
},
|
||||||
|
onClose: () => {
|
||||||
|
events.push('close');
|
||||||
|
},
|
||||||
|
socketFactory: () => {
|
||||||
|
const socket = new ManualCloseSocket();
|
||||||
|
sockets.push(socket);
|
||||||
|
return socket as unknown as net.Socket;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
transport.connect();
|
||||||
|
await wait();
|
||||||
|
transport.shutdown();
|
||||||
|
transport.connect();
|
||||||
|
await wait();
|
||||||
|
const eventsBeforeStaleSocket = [...events];
|
||||||
|
|
||||||
|
sockets[0]!.emit('data', Buffer.from('{}'));
|
||||||
|
sockets[0]!.emit('error', new Error('stale'));
|
||||||
|
sockets[0]!.emit('close');
|
||||||
|
|
||||||
|
assert.deepEqual(events, eventsBeforeStaleSocket);
|
||||||
|
assert.equal(transport.isConnected, true);
|
||||||
|
assert.equal(transport.getSocket(), sockets[1]);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -105,32 +105,37 @@ export class MpvSocketTransport {
|
|||||||
}
|
}
|
||||||
|
|
||||||
this.connecting = true;
|
this.connecting = true;
|
||||||
this.socketRef = this.socketFactory();
|
const socket = this.socketFactory();
|
||||||
this.socket = this.socketRef;
|
this.socketRef = socket;
|
||||||
|
this.socket = socket;
|
||||||
|
|
||||||
this.socketRef.on('connect', () => {
|
socket.on('connect', () => {
|
||||||
|
if (this.socketRef !== socket) return;
|
||||||
this.connected = true;
|
this.connected = true;
|
||||||
this.connecting = false;
|
this.connecting = false;
|
||||||
this.callbacks.onConnect();
|
this.callbacks.onConnect();
|
||||||
});
|
});
|
||||||
|
|
||||||
this.socketRef.on('data', (data: Buffer) => {
|
socket.on('data', (data: Buffer) => {
|
||||||
|
if (this.socketRef !== socket) return;
|
||||||
this.callbacks.onData(data);
|
this.callbacks.onData(data);
|
||||||
});
|
});
|
||||||
|
|
||||||
this.socketRef.on('error', (error: Error) => {
|
socket.on('error', (error: Error) => {
|
||||||
|
if (this.socketRef !== socket) return;
|
||||||
this.connected = false;
|
this.connected = false;
|
||||||
this.connecting = false;
|
this.connecting = false;
|
||||||
this.callbacks.onError(error);
|
this.callbacks.onError(error);
|
||||||
});
|
});
|
||||||
|
|
||||||
this.socketRef.on('close', () => {
|
socket.on('close', () => {
|
||||||
|
if (this.socketRef !== socket) return;
|
||||||
this.connected = false;
|
this.connected = false;
|
||||||
this.connecting = false;
|
this.connecting = false;
|
||||||
this.callbacks.onClose();
|
this.callbacks.onClose();
|
||||||
});
|
});
|
||||||
|
|
||||||
this.socketRef.connect(this.socketPath);
|
socket.connect(this.socketPath);
|
||||||
}
|
}
|
||||||
|
|
||||||
send(payload: MpvSocketMessagePayload): boolean {
|
send(payload: MpvSocketMessagePayload): boolean {
|
||||||
@@ -144,13 +149,14 @@ export class MpvSocketTransport {
|
|||||||
}
|
}
|
||||||
|
|
||||||
shutdown(): void {
|
shutdown(): void {
|
||||||
if (this.socketRef) {
|
const socket = this.socketRef;
|
||||||
this.socketRef.destroy();
|
|
||||||
}
|
|
||||||
this.socketRef = null;
|
this.socketRef = null;
|
||||||
this.socket = null;
|
this.socket = null;
|
||||||
this.connected = false;
|
this.connected = false;
|
||||||
this.connecting = false;
|
this.connecting = false;
|
||||||
|
if (socket) {
|
||||||
|
socket.destroy();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
getSocket(): net.Socket | null {
|
getSocket(): net.Socket | null {
|
||||||
|
|||||||
@@ -168,6 +168,37 @@ test('MpvIpcClient connect logs connect-request at debug level', () => {
|
|||||||
assert.equal(requestLogs.length, 1);
|
assert.equal(requestLogs.length, 1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('MpvIpcClient reconnect clears stale connected state and starts a fresh transport connect', () => {
|
||||||
|
const client = new MpvIpcClient('/tmp/mpv.sock', makeDeps());
|
||||||
|
const calls: string[] = [];
|
||||||
|
const connectionChanges: boolean[] = [];
|
||||||
|
const resolved: unknown[] = [];
|
||||||
|
client.on('connection-change', ({ connected }) => {
|
||||||
|
connectionChanges.push(connected);
|
||||||
|
});
|
||||||
|
(client as any).connected = true;
|
||||||
|
(client as any).connecting = false;
|
||||||
|
(client as any).socket = {};
|
||||||
|
(client as any).pendingRequests.set(10, (message: unknown) => {
|
||||||
|
resolved.push(message);
|
||||||
|
});
|
||||||
|
(client as any).transport.shutdown = () => {
|
||||||
|
calls.push('shutdown');
|
||||||
|
};
|
||||||
|
(client as any).transport.connect = () => {
|
||||||
|
calls.push('connect');
|
||||||
|
};
|
||||||
|
|
||||||
|
client.reconnect();
|
||||||
|
|
||||||
|
assert.deepEqual(calls, ['shutdown', 'connect']);
|
||||||
|
assert.equal(client.connected, false);
|
||||||
|
assert.equal((client as any).connecting, true);
|
||||||
|
assert.equal((client as any).socket, null);
|
||||||
|
assert.deepEqual(connectionChanges, [false]);
|
||||||
|
assert.deepEqual(resolved, [{ request_id: 10, error: 'disconnected' }]);
|
||||||
|
});
|
||||||
|
|
||||||
test('MpvIpcClient failPendingRequests resolves outstanding requests as disconnected', () => {
|
test('MpvIpcClient failPendingRequests resolves outstanding requests as disconnected', () => {
|
||||||
const client = new MpvIpcClient('/tmp/mpv.sock', makeDeps());
|
const client = new MpvIpcClient('/tmp/mpv.sock', makeDeps());
|
||||||
const resolved: unknown[] = [];
|
const resolved: unknown[] = [];
|
||||||
|
|||||||
@@ -275,6 +275,21 @@ export class MpvIpcClient implements MpvClient {
|
|||||||
this.transport.connect();
|
this.transport.connect();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
reconnect(): void {
|
||||||
|
logger.debug('MPV IPC reconnect requested.');
|
||||||
|
const wasConnected = this.connected;
|
||||||
|
this.transport.shutdown();
|
||||||
|
this.connected = false;
|
||||||
|
this.connecting = false;
|
||||||
|
this.socket = null;
|
||||||
|
this.playbackPaused = null;
|
||||||
|
if (wasConnected) {
|
||||||
|
this.emit('connection-change', { connected: false });
|
||||||
|
}
|
||||||
|
this.failPendingRequests();
|
||||||
|
this.connect();
|
||||||
|
}
|
||||||
|
|
||||||
private scheduleReconnect(): void {
|
private scheduleReconnect(): void {
|
||||||
this.reconnectAttempt = scheduleMpvReconnect({
|
this.reconnectAttempt = scheduleMpvReconnect({
|
||||||
attempt: this.reconnectAttempt,
|
attempt: this.reconnectAttempt,
|
||||||
|
|||||||
@@ -14,8 +14,8 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
|
|||||||
toggle: false,
|
toggle: false,
|
||||||
toggleVisibleOverlay: false,
|
toggleVisibleOverlay: false,
|
||||||
togglePrimarySubtitleBar: false,
|
togglePrimarySubtitleBar: false,
|
||||||
|
yomitan: false,
|
||||||
settings: false,
|
settings: false,
|
||||||
configSettings: false,
|
|
||||||
setup: false,
|
setup: false,
|
||||||
show: false,
|
show: false,
|
||||||
hide: false,
|
hide: false,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -217,6 +217,38 @@ test('serializeSubtitleWebsocketMessage emits structured token api payload', ()
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('serializeSubtitleWebsocketMessage can force plain subtitle payloads', () => {
|
||||||
|
const payload: SubtitleData = {
|
||||||
|
text: '無事',
|
||||||
|
tokens: [
|
||||||
|
{
|
||||||
|
surface: '無事',
|
||||||
|
reading: 'ぶじ',
|
||||||
|
headword: '無事',
|
||||||
|
startPos: 0,
|
||||||
|
endPos: 2,
|
||||||
|
partOfSpeech: PartOfSpeech.other,
|
||||||
|
isMerged: false,
|
||||||
|
isKnown: true,
|
||||||
|
isNPlusOneTarget: false,
|
||||||
|
jlptLevel: 'N2',
|
||||||
|
frequencyRank: 745,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const raw = serializeSubtitleWebsocketMessage(payload, frequencyOptions, {
|
||||||
|
payloadMode: 'plain',
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.deepEqual(JSON.parse(raw), {
|
||||||
|
version: 1,
|
||||||
|
text: '無事',
|
||||||
|
sentence: '無事',
|
||||||
|
tokens: [],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
test('serializeInitialSubtitleWebsocketMessage keeps annotated current subtitle content', () => {
|
test('serializeInitialSubtitleWebsocketMessage keeps annotated current subtitle content', () => {
|
||||||
const payload: SubtitleData = {
|
const payload: SubtitleData = {
|
||||||
text: 'ignored fallback',
|
text: 'ignored fallback',
|
||||||
|
|||||||
@@ -18,6 +18,12 @@ export type SubtitleWebsocketFrequencyOptions = {
|
|||||||
mode: 'single' | 'banded';
|
mode: 'single' | 'banded';
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type SubtitleWebsocketPayloadMode = 'plain' | 'annotated';
|
||||||
|
|
||||||
|
type SubtitleWebsocketMessageOptions = {
|
||||||
|
payloadMode?: SubtitleWebsocketPayloadMode;
|
||||||
|
};
|
||||||
|
|
||||||
type SerializedSubtitleToken = Pick<
|
type SerializedSubtitleToken = Pick<
|
||||||
MergedToken,
|
MergedToken,
|
||||||
| 'surface'
|
| 'surface'
|
||||||
@@ -198,7 +204,17 @@ export function serializeSubtitleMarkup(
|
|||||||
export function serializeSubtitleWebsocketMessage(
|
export function serializeSubtitleWebsocketMessage(
|
||||||
payload: SubtitleData,
|
payload: SubtitleData,
|
||||||
options: SubtitleWebsocketFrequencyOptions,
|
options: SubtitleWebsocketFrequencyOptions,
|
||||||
|
messageOptions: SubtitleWebsocketMessageOptions = {},
|
||||||
): string {
|
): string {
|
||||||
|
if (messageOptions.payloadMode === 'plain') {
|
||||||
|
return JSON.stringify({
|
||||||
|
version: 1,
|
||||||
|
text: payload.text,
|
||||||
|
sentence: escapeHtml(payload.text).replaceAll('\n', '<br>'),
|
||||||
|
tokens: [],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
return JSON.stringify({
|
return JSON.stringify({
|
||||||
version: 1,
|
version: 1,
|
||||||
text: payload.text,
|
text: payload.text,
|
||||||
@@ -210,18 +226,21 @@ export function serializeSubtitleWebsocketMessage(
|
|||||||
export function serializeInitialSubtitleWebsocketMessage(
|
export function serializeInitialSubtitleWebsocketMessage(
|
||||||
payload: SubtitleData | null,
|
payload: SubtitleData | null,
|
||||||
options: SubtitleWebsocketFrequencyOptions,
|
options: SubtitleWebsocketFrequencyOptions,
|
||||||
|
messageOptions: SubtitleWebsocketMessageOptions = {},
|
||||||
): string | null {
|
): string | null {
|
||||||
if (!payload || !payload.text.trim()) {
|
if (!payload || !payload.text.trim()) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return serializeSubtitleWebsocketMessage(payload, options);
|
return serializeSubtitleWebsocketMessage(payload, options, messageOptions);
|
||||||
}
|
}
|
||||||
|
|
||||||
export class SubtitleWebSocket {
|
export class SubtitleWebSocket {
|
||||||
private server: WebSocket.Server | null = null;
|
private server: WebSocket.Server | null = null;
|
||||||
private latestMessage = '';
|
private latestMessage = '';
|
||||||
|
|
||||||
|
public constructor(private readonly payloadMode: SubtitleWebsocketPayloadMode = 'annotated') {}
|
||||||
|
|
||||||
public isRunning(): boolean {
|
public isRunning(): boolean {
|
||||||
return this.server !== null;
|
return this.server !== null;
|
||||||
}
|
}
|
||||||
@@ -247,6 +266,7 @@ export class SubtitleWebSocket {
|
|||||||
const currentMessage = serializeInitialSubtitleWebsocketMessage(
|
const currentMessage = serializeInitialSubtitleWebsocketMessage(
|
||||||
getCurrentSubtitleData(),
|
getCurrentSubtitleData(),
|
||||||
getFrequencyOptions(),
|
getFrequencyOptions(),
|
||||||
|
{ payloadMode: this.payloadMode },
|
||||||
);
|
);
|
||||||
if (currentMessage) {
|
if (currentMessage) {
|
||||||
ws.send(currentMessage);
|
ws.send(currentMessage);
|
||||||
@@ -262,7 +282,9 @@ export class SubtitleWebSocket {
|
|||||||
|
|
||||||
public broadcast(payload: SubtitleData, options: SubtitleWebsocketFrequencyOptions): void {
|
public broadcast(payload: SubtitleData, options: SubtitleWebsocketFrequencyOptions): void {
|
||||||
if (!this.server) return;
|
if (!this.server) return;
|
||||||
const message = serializeSubtitleWebsocketMessage(payload, options);
|
const message = serializeSubtitleWebsocketMessage(payload, options, {
|
||||||
|
payloadMode: this.payloadMode,
|
||||||
|
});
|
||||||
this.latestMessage = message;
|
this.latestMessage = message;
|
||||||
for (const client of this.server.clients) {
|
for (const client of this.server.clients) {
|
||||||
if (client.readyState === WebSocket.OPEN) {
|
if (client.readyState === WebSocket.OPEN) {
|
||||||
|
|||||||
+64
-28
@@ -21,7 +21,6 @@ import {
|
|||||||
clipboard,
|
clipboard,
|
||||||
globalShortcut,
|
globalShortcut,
|
||||||
ipcMain,
|
ipcMain,
|
||||||
net,
|
|
||||||
shell,
|
shell,
|
||||||
protocol,
|
protocol,
|
||||||
Extension,
|
Extension,
|
||||||
@@ -35,6 +34,8 @@ import {
|
|||||||
import { applyControllerConfigUpdate } from './main/controller-config-update.js';
|
import { applyControllerConfigUpdate } from './main/controller-config-update.js';
|
||||||
import { openPlaylistBrowser as openPlaylistBrowserRuntime } from './main/runtime/playlist-browser-open';
|
import { openPlaylistBrowser as openPlaylistBrowserRuntime } from './main/runtime/playlist-browser-open';
|
||||||
import { createDiscordRpcClient } from './main/runtime/discord-rpc-client.js';
|
import { createDiscordRpcClient } from './main/runtime/discord-rpc-client.js';
|
||||||
|
import { startAppControlServer } from './main/runtime/app-control-server';
|
||||||
|
import { getAppControlSocketPath } from './shared/app-control';
|
||||||
import {
|
import {
|
||||||
type CancelLinuxMpvFullscreenOverlayRefreshBurst,
|
type CancelLinuxMpvFullscreenOverlayRefreshBurst,
|
||||||
clearLinuxMpvFullscreenOverlayRefreshTimeouts,
|
clearLinuxMpvFullscreenOverlayRefreshTimeouts,
|
||||||
@@ -91,7 +92,7 @@ protocol.registerSchemesAsPrivileged([
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
import * as fs from 'fs';
|
import * as fs from 'fs';
|
||||||
import { spawn } from 'node:child_process';
|
import { execFile, spawn } from 'node:child_process';
|
||||||
import * as os from 'os';
|
import * as os from 'os';
|
||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
import { MecabTokenizer } from './mecab-tokenizer';
|
import { MecabTokenizer } from './mecab-tokenizer';
|
||||||
@@ -167,6 +168,7 @@ import {
|
|||||||
rememberAnilistAttemptedUpdateKey,
|
rememberAnilistAttemptedUpdateKey,
|
||||||
} from './main/runtime/domains/anilist';
|
} from './main/runtime/domains/anilist';
|
||||||
import { DEFAULT_MIN_WATCH_RATIO } from './shared/watch-threshold';
|
import { DEFAULT_MIN_WATCH_RATIO } from './shared/watch-threshold';
|
||||||
|
import { shouldShowTexthookerTrayEntry } from './main/runtime/tray-main-actions';
|
||||||
import {
|
import {
|
||||||
createApplyJellyfinMpvDefaultsHandler,
|
createApplyJellyfinMpvDefaultsHandler,
|
||||||
createBuildApplyJellyfinMpvDefaultsMainDepsHandler,
|
createBuildApplyJellyfinMpvDefaultsMainDepsHandler,
|
||||||
@@ -505,11 +507,7 @@ import {
|
|||||||
createElectronAppUpdater,
|
createElectronAppUpdater,
|
||||||
isNativeUpdaterSupported,
|
isNativeUpdaterSupported,
|
||||||
} from './main/runtime/update/app-updater';
|
} from './main/runtime/update/app-updater';
|
||||||
import {
|
import { createCurlFetch, createGlobalFetch } from './main/runtime/update/fetch-adapter';
|
||||||
createCurlFetch,
|
|
||||||
createElectronNetFetch,
|
|
||||||
createGlobalFetch,
|
|
||||||
} from './main/runtime/update/fetch-adapter';
|
|
||||||
import { createCurlHttpExecutor } from './main/runtime/update/curl-http-executor';
|
import { createCurlHttpExecutor } from './main/runtime/update/curl-http-executor';
|
||||||
import { createFetchHttpExecutor } from './main/runtime/update/fetch-http-executor';
|
import { createFetchHttpExecutor } from './main/runtime/update/fetch-http-executor';
|
||||||
import {
|
import {
|
||||||
@@ -618,6 +616,7 @@ const ANILIST_UPDATE_MIN_WATCH_SECONDS = 10 * 60;
|
|||||||
const ANILIST_DURATION_RETRY_INTERVAL_MS = 15_000;
|
const ANILIST_DURATION_RETRY_INTERVAL_MS = 15_000;
|
||||||
const ANILIST_MAX_ATTEMPTED_UPDATE_KEYS = 1000;
|
const ANILIST_MAX_ATTEMPTED_UPDATE_KEYS = 1000;
|
||||||
const TRAY_TOOLTIP = 'SubMiner';
|
const TRAY_TOOLTIP = 'SubMiner';
|
||||||
|
const SUBMINER_BUNDLE_ID = 'com.sudacode.SubMiner';
|
||||||
const JELLYFIN_SETUP_PRELOAD_PATH = path.join(__dirname, 'preload-jellyfin-setup.js');
|
const JELLYFIN_SETUP_PRELOAD_PATH = path.join(__dirname, 'preload-jellyfin-setup.js');
|
||||||
|
|
||||||
let anilistMediaGuessRuntimeState: AnilistMediaGuessRuntimeState =
|
let anilistMediaGuessRuntimeState: AnilistMediaGuessRuntimeState =
|
||||||
@@ -794,7 +793,7 @@ const bootServices = createMainBootServices({
|
|||||||
warn: (message: string, details?: unknown) => console.warn(message, details),
|
warn: (message: string, details?: unknown) => console.warn(message, details),
|
||||||
error: (message: string, details?: unknown) => console.error(message, details),
|
error: (message: string, details?: unknown) => console.error(message, details),
|
||||||
}),
|
}),
|
||||||
createSubtitleWebSocket: () => new SubtitleWebSocket(),
|
createSubtitleWebSocket: (payloadMode) => new SubtitleWebSocket(payloadMode),
|
||||||
createLogger,
|
createLogger,
|
||||||
createMainRuntimeRegistry,
|
createMainRuntimeRegistry,
|
||||||
createOverlayManager,
|
createOverlayManager,
|
||||||
@@ -3077,6 +3076,16 @@ const openFirstRunSetupWindowHandler = createOpenFirstRunSetupWindowHandler({
|
|||||||
: 'Yomitan settings are unavailable while external read-only profile mode is enabled.';
|
: 'Yomitan settings are unavailable while external read-only profile mode is enabled.';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (submission.action === 'open-config-settings') {
|
||||||
|
const opened = openConfigSettingsWindow();
|
||||||
|
firstRunSetupMessage = opened
|
||||||
|
? 'Opened SubMiner settings.'
|
||||||
|
: 'SubMiner settings are unavailable.';
|
||||||
|
if (opened) {
|
||||||
|
return { skipRender: true };
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (submission.action === 'refresh') {
|
if (submission.action === 'refresh') {
|
||||||
const snapshot = await firstRunSetupService.refreshStatus('Status refreshed.');
|
const snapshot = await firstRunSetupService.refreshStatus('Status refreshed.');
|
||||||
firstRunSetupMessage = snapshot.message;
|
firstRunSetupMessage = snapshot.message;
|
||||||
@@ -4894,28 +4903,19 @@ flushPendingMpvLogWrites = () => {
|
|||||||
|
|
||||||
const updateStateStore = createFileUpdateStateStore(path.join(USER_DATA_PATH, 'update-state.json'));
|
const updateStateStore = createFileUpdateStateStore(path.join(USER_DATA_PATH, 'update-state.json'));
|
||||||
let updateService: ReturnType<typeof createUpdateService> | null = null;
|
let updateService: ReturnType<typeof createUpdateService> | null = null;
|
||||||
const electronNetFetch = createElectronNetFetch({
|
|
||||||
fetch: (url, init) => net.fetch(url, init as RequestInit),
|
|
||||||
});
|
|
||||||
const globalFetchForUpdater = createGlobalFetch();
|
const globalFetchForUpdater = createGlobalFetch();
|
||||||
const curlFetch = createCurlFetch();
|
const curlFetch = createCurlFetch();
|
||||||
|
|
||||||
function createNativeUpdaterHttpExecutor() {
|
function createNativeUpdaterHttpExecutor() {
|
||||||
if (process.platform === 'darwin') {
|
|
||||||
return createCurlHttpExecutor();
|
|
||||||
}
|
|
||||||
if (process.platform === 'win32') {
|
if (process.platform === 'win32') {
|
||||||
return createFetchHttpExecutor();
|
return createFetchHttpExecutor();
|
||||||
}
|
}
|
||||||
return undefined;
|
return createCurlHttpExecutor();
|
||||||
}
|
}
|
||||||
|
|
||||||
function getFetchForUpdater() {
|
function getFetchForUpdater() {
|
||||||
if (process.platform === 'win32') {
|
if (process.platform === 'win32') return globalFetchForUpdater;
|
||||||
return globalFetchForUpdater;
|
return curlFetch;
|
||||||
}
|
|
||||||
if (process.platform === 'linux') return curlFetch;
|
|
||||||
return electronNetFetch;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function updateLauncherFromSelectedRelease(
|
async function updateLauncherFromSelectedRelease(
|
||||||
@@ -4962,11 +4962,8 @@ function getUpdateService() {
|
|||||||
isPackaged: app.isPackaged,
|
isPackaged: app.isPackaged,
|
||||||
log: (message) => logger.info(message),
|
log: (message) => logger.info(message),
|
||||||
getChannel: () => getResolvedConfig().updates.channel,
|
getChannel: () => getResolvedConfig().updates.channel,
|
||||||
configureHttpExecutor:
|
configureHttpExecutor: createNativeUpdaterHttpExecutor,
|
||||||
process.platform === 'darwin' || process.platform === 'win32'
|
disableDifferentialDownload: true,
|
||||||
? createNativeUpdaterHttpExecutor
|
|
||||||
: undefined,
|
|
||||||
disableDifferentialDownload: process.platform === 'darwin' || process.platform === 'win32',
|
|
||||||
isNativeUpdaterSupported: () =>
|
isNativeUpdaterSupported: () =>
|
||||||
isNativeUpdaterSupported({
|
isNativeUpdaterSupported({
|
||||||
platform: process.platform,
|
platform: process.platform,
|
||||||
@@ -4978,7 +4975,37 @@ function getUpdateService() {
|
|||||||
});
|
});
|
||||||
const updateDialogPresenter = createUpdateDialogPresenter({
|
const updateDialogPresenter = createUpdateDialogPresenter({
|
||||||
platform: process.platform,
|
platform: process.platform,
|
||||||
focusApp: () => app.focus({ steal: true }),
|
focusApp: async () => {
|
||||||
|
if (process.platform !== 'darwin') {
|
||||||
|
app.focus({ steal: true });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await app.dock?.show();
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn('Failed to show macOS dock before update dialog', error);
|
||||||
|
}
|
||||||
|
// app.focus({ steal: true }) alone does not reliably activate the process
|
||||||
|
// when SubMiner was reached via `subminer -u` (single-instance forwarding
|
||||||
|
// from a CLI-spawned child). osascript's `activate` uses LaunchServices,
|
||||||
|
// which is the only path that reliably brings the running app forward.
|
||||||
|
await new Promise<void>((resolve) => {
|
||||||
|
execFile(
|
||||||
|
'/usr/bin/osascript',
|
||||||
|
['-e', `tell application id "${SUBMINER_BUNDLE_ID}" to activate`],
|
||||||
|
{ timeout: 2000 },
|
||||||
|
(error) => {
|
||||||
|
if (error) {
|
||||||
|
logger.warn(
|
||||||
|
`Failed to activate SubMiner via osascript: ${error instanceof Error ? error.message : String(error)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
resolve();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
app.focus({ steal: true });
|
||||||
|
},
|
||||||
showMessageBox: (options) => dialog.showMessageBox(options),
|
showMessageBox: (options) => dialog.showMessageBox(options),
|
||||||
});
|
});
|
||||||
updateService = createUpdateService({
|
updateService = createUpdateService({
|
||||||
@@ -5782,6 +5809,16 @@ const { runAndApplyStartupState } = composeHeadlessStartupHandlers<
|
|||||||
handleCliCommand(nextArgs, source),
|
handleCliCommand(nextArgs, source),
|
||||||
printHelp: () => printHelp(DEFAULT_TEXTHOOKER_PORT),
|
printHelp: () => printHelp(DEFAULT_TEXTHOOKER_PORT),
|
||||||
logNoRunningInstance: () => appLogger.logNoRunningInstance(),
|
logNoRunningInstance: () => appLogger.logNoRunningInstance(),
|
||||||
|
startControlServer: (handleArgv: (argv: string[]) => void) => {
|
||||||
|
const server = startAppControlServer({
|
||||||
|
socketPath: getAppControlSocketPath({ configDir: CONFIG_DIR }),
|
||||||
|
platform: process.platform,
|
||||||
|
handleArgv,
|
||||||
|
logDebug: (message) => logger.debug(message),
|
||||||
|
logWarn: (message, error) => logger.warn(message, error),
|
||||||
|
});
|
||||||
|
return () => server.close();
|
||||||
|
},
|
||||||
onReady: runAppReadyRuntimeWithFatalReporting,
|
onReady: runAppReadyRuntimeWithFatalReporting,
|
||||||
onWillQuitCleanup: () => onWillQuitCleanupHandler(),
|
onWillQuitCleanup: () => onWillQuitCleanupHandler(),
|
||||||
shouldRestoreWindowsOnActivate: () => shouldRestoreWindowsOnActivateHandler(),
|
shouldRestoreWindowsOnActivate: () => shouldRestoreWindowsOnActivateHandler(),
|
||||||
@@ -5929,12 +5966,11 @@ const { ensureTray: ensureTrayHandler, destroyTray: destroyTrayHandler } =
|
|||||||
openSessionHelpModal: () => openSessionHelpOverlay(),
|
openSessionHelpModal: () => openSessionHelpOverlay(),
|
||||||
openTexthookerInBrowser: () =>
|
openTexthookerInBrowser: () =>
|
||||||
handleCliCommand(parseArgs(['--texthooker', '--open-browser'])),
|
handleCliCommand(parseArgs(['--texthooker', '--open-browser'])),
|
||||||
showTexthookerPage: () => getResolvedConfig().texthooker.launchAtStartup !== false,
|
showTexthookerPage: () => shouldShowTexthookerTrayEntry(getResolvedConfig()),
|
||||||
showFirstRunSetup: () => !firstRunSetupService.isSetupCompleted(),
|
showFirstRunSetup: () => !firstRunSetupService.isSetupCompleted(),
|
||||||
openFirstRunSetupWindow: () => openFirstRunSetupWindow(),
|
openFirstRunSetupWindow: () => openFirstRunSetupWindow(),
|
||||||
showWindowsMpvLauncherSetup: () => process.platform === 'win32',
|
showWindowsMpvLauncherSetup: () => process.platform === 'win32',
|
||||||
openYomitanSettings: () => openYomitanSettings(),
|
openYomitanSettings: () => openYomitanSettings(),
|
||||||
openRuntimeOptionsPalette: () => openRuntimeOptionsPalette(),
|
|
||||||
openConfigSettingsWindow: () => openConfigSettingsWindow(),
|
openConfigSettingsWindow: () => openConfigSettingsWindow(),
|
||||||
openJellyfinSetupWindow: () => openJellyfinSetupWindow(),
|
openJellyfinSetupWindow: () => openJellyfinSetupWindow(),
|
||||||
isJellyfinConfigured: () =>
|
isJellyfinConfigured: () =>
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ export interface AppLifecycleRuntimeDepsFactoryInput {
|
|||||||
handleCliCommand: (nextArgs: CliArgs, source: CliCommandSource) => void;
|
handleCliCommand: (nextArgs: CliArgs, source: CliCommandSource) => void;
|
||||||
printHelp: () => void;
|
printHelp: () => void;
|
||||||
logNoRunningInstance: () => void;
|
logNoRunningInstance: () => void;
|
||||||
|
startControlServer?: (handleArgv: (argv: string[]) => void) => (() => void) | void;
|
||||||
onReady: () => Promise<void>;
|
onReady: () => Promise<void>;
|
||||||
onWillQuitCleanup: () => void;
|
onWillQuitCleanup: () => void;
|
||||||
shouldRestoreWindowsOnActivate: () => boolean;
|
shouldRestoreWindowsOnActivate: () => boolean;
|
||||||
@@ -73,6 +74,7 @@ export function createAppLifecycleRuntimeDeps(
|
|||||||
handleCliCommand: params.handleCliCommand,
|
handleCliCommand: params.handleCliCommand,
|
||||||
printHelp: params.printHelp,
|
printHelp: params.printHelp,
|
||||||
logNoRunningInstance: params.logNoRunningInstance,
|
logNoRunningInstance: params.logNoRunningInstance,
|
||||||
|
startControlServer: params.startControlServer,
|
||||||
onReady: params.onReady,
|
onReady: params.onReady,
|
||||||
onWillQuitCleanup: params.onWillQuitCleanup,
|
onWillQuitCleanup: params.onWillQuitCleanup,
|
||||||
shouldRestoreWindowsOnActivate: params.shouldRestoreWindowsOnActivate,
|
shouldRestoreWindowsOnActivate: params.shouldRestoreWindowsOnActivate,
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ test('createMainBootServices builds boot-phase service bundle', () => {
|
|||||||
{ targetPath: string },
|
{ targetPath: string },
|
||||||
{ targetPath: string },
|
{ targetPath: string },
|
||||||
{ targetPath: string },
|
{ targetPath: string },
|
||||||
{ kind: string },
|
{ kind: string; payloadMode: 'plain' | 'annotated' },
|
||||||
{ scope: string; warn: () => void; info: () => void; error: () => void },
|
{ scope: string; warn: () => void; info: () => void; error: () => void },
|
||||||
{ registry: boolean },
|
{ registry: boolean },
|
||||||
{ getMainWindow: () => null; getModalWindow: () => null },
|
{ getMainWindow: () => null; getModalWindow: () => null },
|
||||||
@@ -76,7 +76,7 @@ test('createMainBootServices builds boot-phase service bundle', () => {
|
|||||||
createAnilistTokenStore: (targetPath) => ({ targetPath }),
|
createAnilistTokenStore: (targetPath) => ({ targetPath }),
|
||||||
createJellyfinTokenStore: (targetPath) => ({ targetPath }),
|
createJellyfinTokenStore: (targetPath) => ({ targetPath }),
|
||||||
createAnilistUpdateQueue: (targetPath) => ({ targetPath }),
|
createAnilistUpdateQueue: (targetPath) => ({ targetPath }),
|
||||||
createSubtitleWebSocket: () => ({ kind: 'ws' }),
|
createSubtitleWebSocket: (payloadMode) => ({ kind: 'ws', payloadMode }),
|
||||||
createLogger: (scope) =>
|
createLogger: (scope) =>
|
||||||
({
|
({
|
||||||
scope,
|
scope,
|
||||||
@@ -115,6 +115,11 @@ test('createMainBootServices builds boot-phase service bundle', () => {
|
|||||||
assert.deepEqual(services.anilistUpdateQueue, {
|
assert.deepEqual(services.anilistUpdateQueue, {
|
||||||
targetPath: '/tmp/subminer-config/anilist-retry-queue.json',
|
targetPath: '/tmp/subminer-config/anilist-retry-queue.json',
|
||||||
});
|
});
|
||||||
|
assert.deepEqual(services.subtitleWsService, { kind: 'ws', payloadMode: 'plain' });
|
||||||
|
assert.deepEqual(services.annotationSubtitleWsService, {
|
||||||
|
kind: 'ws',
|
||||||
|
payloadMode: 'annotated',
|
||||||
|
});
|
||||||
assert.deepEqual(services.appState, {
|
assert.deepEqual(services.appState, {
|
||||||
mpvSocketPath: '/tmp/subminer.sock',
|
mpvSocketPath: '/tmp/subminer.sock',
|
||||||
texthookerPort: 5174,
|
texthookerPort: 5174,
|
||||||
|
|||||||
@@ -64,7 +64,7 @@ export interface MainBootServicesParams<
|
|||||||
createAnilistTokenStore: (targetPath: string) => TAnilistTokenStore;
|
createAnilistTokenStore: (targetPath: string) => TAnilistTokenStore;
|
||||||
createJellyfinTokenStore: (targetPath: string) => TJellyfinTokenStore;
|
createJellyfinTokenStore: (targetPath: string) => TJellyfinTokenStore;
|
||||||
createAnilistUpdateQueue: (targetPath: string) => TAnilistUpdateQueue;
|
createAnilistUpdateQueue: (targetPath: string) => TAnilistUpdateQueue;
|
||||||
createSubtitleWebSocket: () => TSubtitleWebSocket;
|
createSubtitleWebSocket: (payloadMode: 'plain' | 'annotated') => TSubtitleWebSocket;
|
||||||
createLogger: (scope: string) => TLogger & {
|
createLogger: (scope: string) => TLogger & {
|
||||||
warn: (message: string) => void;
|
warn: (message: string) => void;
|
||||||
info: (message: string) => void;
|
info: (message: string) => void;
|
||||||
@@ -205,8 +205,8 @@ export function createMainBootServices<
|
|||||||
const anilistUpdateQueue = params.createAnilistUpdateQueue(
|
const anilistUpdateQueue = params.createAnilistUpdateQueue(
|
||||||
params.joinPath(userDataPath, 'anilist-retry-queue.json'),
|
params.joinPath(userDataPath, 'anilist-retry-queue.json'),
|
||||||
);
|
);
|
||||||
const subtitleWsService = params.createSubtitleWebSocket();
|
const subtitleWsService = params.createSubtitleWebSocket('plain');
|
||||||
const annotationSubtitleWsService = params.createSubtitleWebSocket();
|
const annotationSubtitleWsService = params.createSubtitleWebSocket('annotated');
|
||||||
const logger = params.createLogger('main');
|
const logger = params.createLogger('main');
|
||||||
const runtimeRegistry = params.createMainRuntimeRegistry();
|
const runtimeRegistry = params.createMainRuntimeRegistry();
|
||||||
const overlayManager = params.createOverlayManager();
|
const overlayManager = params.createOverlayManager();
|
||||||
|
|||||||
@@ -0,0 +1,131 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { EventEmitter } from 'node:events';
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import net from 'node:net';
|
||||||
|
import os from 'node:os';
|
||||||
|
import path from 'node:path';
|
||||||
|
import test from 'node:test';
|
||||||
|
import { sendAppControlCommand } from '../../shared/app-control-client';
|
||||||
|
import { startAppControlServer } from './app-control-server';
|
||||||
|
|
||||||
|
async function waitForSocketPath(socketPath: string): Promise<void> {
|
||||||
|
const timeoutMs = 1000;
|
||||||
|
const deadline = Date.now() + timeoutMs;
|
||||||
|
while (Date.now() < deadline) {
|
||||||
|
if (fs.existsSync(socketPath)) return;
|
||||||
|
await new Promise<void>((resolve) => setTimeout(resolve, 10));
|
||||||
|
}
|
||||||
|
throw new Error(`Timed out waiting for control socket ${socketPath} after ${timeoutMs}ms`);
|
||||||
|
}
|
||||||
|
|
||||||
|
test('app control server dispatches argv requests and replies ok', async () => {
|
||||||
|
if (process.platform === 'win32') return;
|
||||||
|
|
||||||
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-control-test-'));
|
||||||
|
const socketPath = path.join(dir, 'control.sock');
|
||||||
|
const received: string[][] = [];
|
||||||
|
const server = startAppControlServer({
|
||||||
|
socketPath,
|
||||||
|
platform: 'linux',
|
||||||
|
handleArgv: (argv) => {
|
||||||
|
received.push(argv);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
await waitForSocketPath(socketPath);
|
||||||
|
const result = await sendAppControlCommand(['--start', '--socket', '/tmp/mpv.sock'], {
|
||||||
|
socketPath,
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.deepEqual(result, { ok: true });
|
||||||
|
assert.deepEqual(received, [['--start', '--socket', '/tmp/mpv.sock']]);
|
||||||
|
} finally {
|
||||||
|
server.close();
|
||||||
|
fs.rmSync(dir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('app control server rejects requests larger than 64KB by UTF-8 byte length', async () => {
|
||||||
|
if (process.platform === 'win32') return;
|
||||||
|
|
||||||
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-control-test-'));
|
||||||
|
const socketPath = path.join(dir, 'control.sock');
|
||||||
|
const received: string[][] = [];
|
||||||
|
const server = startAppControlServer({
|
||||||
|
socketPath,
|
||||||
|
platform: 'linux',
|
||||||
|
handleArgv: (argv) => {
|
||||||
|
received.push(argv);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
await waitForSocketPath(socketPath);
|
||||||
|
const result = await sendAppControlCommand(
|
||||||
|
Array.from({ length: 4 }, () => 'あ'.repeat(6000)),
|
||||||
|
{
|
||||||
|
socketPath,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.deepEqual(result, { ok: false, error: 'App control request too large' });
|
||||||
|
assert.deepEqual(received, []);
|
||||||
|
} finally {
|
||||||
|
server.close();
|
||||||
|
fs.rmSync(dir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('app control server logs and closes errored client sockets', () => {
|
||||||
|
const originalCreateServer = net.createServer;
|
||||||
|
let socketHandler: ((socket: net.Socket) => void) | null = null;
|
||||||
|
const fakeServer = new EventEmitter() as net.Server;
|
||||||
|
fakeServer.listen = (() => fakeServer) as net.Server['listen'];
|
||||||
|
fakeServer.close = ((callback?: (err?: Error) => void) => {
|
||||||
|
callback?.();
|
||||||
|
return fakeServer;
|
||||||
|
}) as net.Server['close'];
|
||||||
|
const received: string[][] = [];
|
||||||
|
const warnings: Array<{ message: string; error?: unknown }> = [];
|
||||||
|
|
||||||
|
try {
|
||||||
|
net.createServer = ((handler?: (socket: net.Socket) => void) => {
|
||||||
|
socketHandler = handler ?? null;
|
||||||
|
return fakeServer;
|
||||||
|
}) as typeof net.createServer;
|
||||||
|
|
||||||
|
const server = startAppControlServer({
|
||||||
|
socketPath: '\\\\.\\pipe\\subminer-test-control',
|
||||||
|
platform: 'win32',
|
||||||
|
handleArgv: (argv) => {
|
||||||
|
received.push(argv);
|
||||||
|
},
|
||||||
|
logWarn: (message, error) => {
|
||||||
|
warnings.push({ message, error });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const error = new Error('client reset');
|
||||||
|
let destroyed = false;
|
||||||
|
const socket = new EventEmitter() as net.Socket;
|
||||||
|
socket.destroy = (() => {
|
||||||
|
destroyed = true;
|
||||||
|
return socket;
|
||||||
|
}) as net.Socket['destroy'];
|
||||||
|
|
||||||
|
const handler = socketHandler as ((socket: net.Socket) => void) | null;
|
||||||
|
assert.ok(handler);
|
||||||
|
handler(socket);
|
||||||
|
socket.emit('error', error);
|
||||||
|
socket.emit('data', Buffer.from('{"argv":["--start"]}\n'));
|
||||||
|
|
||||||
|
assert.equal(destroyed, true);
|
||||||
|
assert.deepEqual(received, []);
|
||||||
|
assert.deepEqual(warnings, [{ message: 'App control client socket error.', error }]);
|
||||||
|
|
||||||
|
server.close();
|
||||||
|
} finally {
|
||||||
|
net.createServer = originalCreateServer;
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
import fs from 'node:fs';
|
||||||
|
import net from 'node:net';
|
||||||
|
import path from 'node:path';
|
||||||
|
import {
|
||||||
|
encodeAppControlResponse,
|
||||||
|
parseAppControlRequestLine,
|
||||||
|
type AppControlResponse,
|
||||||
|
} from '../../shared/app-control';
|
||||||
|
|
||||||
|
export interface AppControlServerOptions {
|
||||||
|
socketPath: string;
|
||||||
|
platform?: NodeJS.Platform;
|
||||||
|
handleArgv: (argv: string[]) => void;
|
||||||
|
logDebug?: (message: string) => void;
|
||||||
|
logWarn?: (message: string, error?: unknown) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AppControlServerHandle {
|
||||||
|
close: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function prepareSocketPath(socketPath: string, platform: NodeJS.Platform): void {
|
||||||
|
if (platform === 'win32') return;
|
||||||
|
fs.mkdirSync(path.dirname(socketPath), { recursive: true });
|
||||||
|
fs.rmSync(socketPath, { force: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
function cleanupSocketPath(socketPath: string, platform: NodeJS.Platform): void {
|
||||||
|
if (platform === 'win32') return;
|
||||||
|
try {
|
||||||
|
fs.rmSync(socketPath, { force: true });
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeResponse(socket: net.Socket, response: AppControlResponse): void {
|
||||||
|
socket.end(encodeAppControlResponse(response));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function startAppControlServer(options: AppControlServerOptions): AppControlServerHandle {
|
||||||
|
const platform = options.platform ?? process.platform;
|
||||||
|
prepareSocketPath(options.socketPath, platform);
|
||||||
|
|
||||||
|
const server = net.createServer((socket) => {
|
||||||
|
let buffer = '';
|
||||||
|
let byteCount = 0;
|
||||||
|
let handled = false;
|
||||||
|
|
||||||
|
socket.on('error', (error) => {
|
||||||
|
if (handled) return;
|
||||||
|
handled = true;
|
||||||
|
options.logWarn?.('App control client socket error.', error);
|
||||||
|
socket.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on('data', (chunk) => {
|
||||||
|
if (handled) return;
|
||||||
|
byteCount += chunk.length;
|
||||||
|
buffer += chunk.toString('utf8');
|
||||||
|
if (byteCount > 65536) {
|
||||||
|
handled = true;
|
||||||
|
writeResponse(socket, { ok: false, error: 'App control request too large' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const newlineIndex = buffer.indexOf('\n');
|
||||||
|
if (newlineIndex < 0) return;
|
||||||
|
handled = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const request = parseAppControlRequestLine(buffer.slice(0, newlineIndex));
|
||||||
|
options.handleArgv(request.argv);
|
||||||
|
writeResponse(socket, { ok: true });
|
||||||
|
} catch (error) {
|
||||||
|
options.logWarn?.('Failed to handle app control command.', error);
|
||||||
|
writeResponse(socket, {
|
||||||
|
ok: false,
|
||||||
|
error: error instanceof Error ? error.message : String(error),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
server.on('error', (error) => {
|
||||||
|
options.logWarn?.(`App control socket failed: ${options.socketPath}`, error);
|
||||||
|
});
|
||||||
|
server.listen(options.socketPath, () => {
|
||||||
|
options.logDebug?.(`App control socket listening: ${options.socketPath}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
let closed = false;
|
||||||
|
return {
|
||||||
|
close: () => {
|
||||||
|
if (closed) return;
|
||||||
|
closed = true;
|
||||||
|
try {
|
||||||
|
server.close();
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
cleanupSocketPath(options.socketPath, platform);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -2,6 +2,10 @@ import assert from 'node:assert/strict';
|
|||||||
import test from 'node:test';
|
import test from 'node:test';
|
||||||
import { createAutoplayTokenizationWarmRelease } from './autoplay-tokenization-warm-release';
|
import { createAutoplayTokenizationWarmRelease } from './autoplay-tokenization-warm-release';
|
||||||
|
|
||||||
|
function flushMicrotasks(): Promise<void> {
|
||||||
|
return new Promise((resolve) => setTimeout(resolve, 0));
|
||||||
|
}
|
||||||
|
|
||||||
test('autoplay tokenization warm release signals immediately when warmups are ready', () => {
|
test('autoplay tokenization warm release signals immediately when warmups are ready', () => {
|
||||||
const calls: string[] = [];
|
const calls: string[] = [];
|
||||||
const release = createAutoplayTokenizationWarmRelease({
|
const release = createAutoplayTokenizationWarmRelease({
|
||||||
@@ -45,14 +49,17 @@ test('autoplay tokenization warm release primes subtitles before waiting for war
|
|||||||
|
|
||||||
resolveWarmup();
|
resolveWarmup();
|
||||||
await warmup;
|
await warmup;
|
||||||
await Promise.resolve();
|
await flushMicrotasks();
|
||||||
|
|
||||||
assert.deepEqual(calls, ['prime', 'warmup', 'signal']);
|
assert.deepEqual(calls, ['prime', 'warmup', 'signal']);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('autoplay tokenization warm release does not await subtitle priming before signaling ready media', async () => {
|
test('autoplay tokenization warm release waits for subtitle priming before signaling ready media', async () => {
|
||||||
const calls: string[] = [];
|
const calls: string[] = [];
|
||||||
const never = new Promise<void>(() => {});
|
let resolvePrime!: () => void;
|
||||||
|
const prime = new Promise<void>((resolve) => {
|
||||||
|
resolvePrime = resolve;
|
||||||
|
});
|
||||||
const release = createAutoplayTokenizationWarmRelease({
|
const release = createAutoplayTokenizationWarmRelease({
|
||||||
isTokenizationWarmupReady: () => true,
|
isTokenizationWarmupReady: () => true,
|
||||||
startTokenizationWarmups: async () => {
|
startTokenizationWarmups: async () => {
|
||||||
@@ -61,7 +68,7 @@ test('autoplay tokenization warm release does not await subtitle priming before
|
|||||||
getCurrentMediaPath: () => '/tmp/video.mkv',
|
getCurrentMediaPath: () => '/tmp/video.mkv',
|
||||||
primeCurrentSubtitle: () => {
|
primeCurrentSubtitle: () => {
|
||||||
calls.push('prime');
|
calls.push('prime');
|
||||||
return never;
|
return prime;
|
||||||
},
|
},
|
||||||
signalAutoplayReady: () => calls.push('signal'),
|
signalAutoplayReady: () => calls.push('signal'),
|
||||||
warn: () => {},
|
warn: () => {},
|
||||||
@@ -70,6 +77,12 @@ test('autoplay tokenization warm release does not await subtitle priming before
|
|||||||
release('/tmp/video.mkv');
|
release('/tmp/video.mkv');
|
||||||
await Promise.resolve();
|
await Promise.resolve();
|
||||||
|
|
||||||
|
assert.deepEqual(calls, ['prime']);
|
||||||
|
|
||||||
|
resolvePrime();
|
||||||
|
await prime;
|
||||||
|
await Promise.resolve();
|
||||||
|
|
||||||
assert.deepEqual(calls, ['prime', 'signal']);
|
assert.deepEqual(calls, ['prime', 'signal']);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -22,24 +22,41 @@ export function createAutoplayTokenizationWarmRelease(deps: {
|
|||||||
deps.signalAutoplayReady();
|
deps.signalAutoplayReady();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const primeSubtitleForRelease = (mediaPath: string): Promise<void> | null => {
|
||||||
|
if (!deps.primeCurrentSubtitle) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return Promise.resolve(deps.primeCurrentSubtitle(mediaPath)).catch((error) => {
|
||||||
|
deps.warn('Startup subtitle priming failed before autoplay readiness release:', error);
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
deps.warn('Startup subtitle priming failed before autoplay readiness release:', error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (mediaPath) => {
|
return (mediaPath) => {
|
||||||
const normalizedPath = normalizeMediaPath(mediaPath);
|
const normalizedPath = normalizeMediaPath(mediaPath);
|
||||||
if (!normalizedPath) {
|
if (!normalizedPath) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
const primePromise = primeSubtitleForRelease(normalizedPath);
|
||||||
void Promise.resolve(deps.primeCurrentSubtitle?.(normalizedPath)).catch((error) => {
|
|
||||||
deps.warn('Startup subtitle priming failed before autoplay readiness release:', error);
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
deps.warn('Startup subtitle priming failed before autoplay readiness release:', error);
|
|
||||||
}
|
|
||||||
if (deps.isTokenizationWarmupReady()) {
|
if (deps.isTokenizationWarmupReady()) {
|
||||||
signalIfCurrent(normalizedPath);
|
if (!primePromise) {
|
||||||
|
signalIfCurrent(normalizedPath);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
void primePromise.then(() => {
|
||||||
|
signalIfCurrent(normalizedPath);
|
||||||
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
void deps
|
const warmupPromise = deps.startTokenizationWarmups();
|
||||||
.startTokenizationWarmups()
|
const readinessPromise = primePromise
|
||||||
|
? Promise.all([primePromise, warmupPromise]).then(() => {})
|
||||||
|
: warmupPromise;
|
||||||
|
void readinessPromise
|
||||||
.then(() => {
|
.then(() => {
|
||||||
signalIfCurrent(normalizedPath);
|
signalIfCurrent(normalizedPath);
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ const fields: ConfigSettingsField[] = [
|
|||||||
description: 'Launch mode setting.',
|
description: 'Launch mode setting.',
|
||||||
configPath: 'mpv.launchMode',
|
configPath: 'mpv.launchMode',
|
||||||
category: 'behavior',
|
category: 'behavior',
|
||||||
section: 'MPV Launcher',
|
section: 'mpv Playback',
|
||||||
control: 'select',
|
control: 'select',
|
||||||
defaultValue: 'windowed',
|
defaultValue: 'windowed',
|
||||||
restartBehavior: 'restart',
|
restartBehavior: 'restart',
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ export function createOpenConfigSettingsWindowHandler<TWindow extends ConfigSett
|
|||||||
const window = deps.createSettingsWindow();
|
const window = deps.createSettingsWindow();
|
||||||
void Promise.resolve(window.loadFile(deps.settingsHtmlPath)).catch((error) => {
|
void Promise.resolve(window.loadFile(deps.settingsHtmlPath)).catch((error) => {
|
||||||
const message = error instanceof Error ? error.message : String(error);
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
deps.log?.(`Failed to load configuration settings window: ${message}`);
|
deps.log?.(`Failed to load settings window: ${message}`);
|
||||||
deps.setSettingsWindow(null);
|
deps.setSettingsWindow(null);
|
||||||
window.destroy?.();
|
window.destroy?.();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -29,8 +29,8 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
|
|||||||
toggle: false,
|
toggle: false,
|
||||||
toggleVisibleOverlay: false,
|
toggleVisibleOverlay: false,
|
||||||
togglePrimarySubtitleBar: false,
|
togglePrimarySubtitleBar: false,
|
||||||
|
yomitan: false,
|
||||||
settings: false,
|
settings: false,
|
||||||
configSettings: false,
|
|
||||||
setup: false,
|
setup: false,
|
||||||
show: false,
|
show: false,
|
||||||
hide: false,
|
hide: false,
|
||||||
@@ -122,12 +122,12 @@ function createCommandLineLauncherSnapshot(
|
|||||||
test('shouldAutoOpenFirstRunSetup only for startup/setup intents', () => {
|
test('shouldAutoOpenFirstRunSetup only for startup/setup intents', () => {
|
||||||
assert.equal(shouldAutoOpenFirstRunSetup(makeArgs({ start: true, background: true })), true);
|
assert.equal(shouldAutoOpenFirstRunSetup(makeArgs({ start: true, background: true })), true);
|
||||||
assert.equal(shouldAutoOpenFirstRunSetup(makeArgs({ background: true, setup: true })), true);
|
assert.equal(shouldAutoOpenFirstRunSetup(makeArgs({ background: true, setup: true })), true);
|
||||||
assert.equal(shouldAutoOpenFirstRunSetup(makeArgs({ start: true, configSettings: true })), false);
|
assert.equal(shouldAutoOpenFirstRunSetup(makeArgs({ start: true, settings: true })), false);
|
||||||
assert.equal(
|
assert.equal(
|
||||||
shouldAutoOpenFirstRunSetup(makeArgs({ background: true, jellyfinRemoteAnnounce: true })),
|
shouldAutoOpenFirstRunSetup(makeArgs({ background: true, jellyfinRemoteAnnounce: true })),
|
||||||
false,
|
false,
|
||||||
);
|
);
|
||||||
assert.equal(shouldAutoOpenFirstRunSetup(makeArgs({ settings: true })), false);
|
assert.equal(shouldAutoOpenFirstRunSetup(makeArgs({ yomitan: true })), false);
|
||||||
assert.equal(shouldAutoOpenFirstRunSetup(makeArgs({ start: true, update: true })), false);
|
assert.equal(shouldAutoOpenFirstRunSetup(makeArgs({ start: true, update: true })), false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -71,8 +71,8 @@ function hasAnyStartupCommandBeyondSetup(args: CliArgs): boolean {
|
|||||||
args.toggleVisibleOverlay ||
|
args.toggleVisibleOverlay ||
|
||||||
args.togglePrimarySubtitleBar ||
|
args.togglePrimarySubtitleBar ||
|
||||||
args.launchMpv ||
|
args.launchMpv ||
|
||||||
|
args.yomitan ||
|
||||||
args.settings ||
|
args.settings ||
|
||||||
args.configSettings ||
|
|
||||||
args.show ||
|
args.show ||
|
||||||
args.hide ||
|
args.hide ||
|
||||||
args.showVisibleOverlay ||
|
args.showVisibleOverlay ||
|
||||||
|
|||||||
@@ -59,10 +59,15 @@ test('buildFirstRunSetupHtml renders macchiato setup actions and disabled finish
|
|||||||
assert.match(html, /SubMiner setup/);
|
assert.match(html, /SubMiner setup/);
|
||||||
assert.doesNotMatch(html, /Install legacy mpv plugin/);
|
assert.doesNotMatch(html, /Install legacy mpv plugin/);
|
||||||
assert.doesNotMatch(html, /action=install-plugin/);
|
assert.doesNotMatch(html, /action=install-plugin/);
|
||||||
assert.match(html, /Ready/);
|
assert.doesNotMatch(html, /mpv runtime plugin/);
|
||||||
assert.doesNotMatch(html, /Bundled ready/);
|
assert.doesNotMatch(html, /Bundled ready/);
|
||||||
assert.match(html, /Managed mpv launches use the bundled runtime plugin\./);
|
assert.doesNotMatch(html, /Managed mpv launches use the bundled runtime plugin\./);
|
||||||
assert.match(html, /Open Yomitan Settings/);
|
assert.match(html, /Open Yomitan Settings/);
|
||||||
|
assert.match(html, /Open SubMiner Settings/);
|
||||||
|
assert.match(
|
||||||
|
html,
|
||||||
|
/action=open-yomitan-settings'">Open Yomitan Settings<\/button>\s*<button class="ghost" onclick="window\.location\.href='subminer:\/\/first-run-setup\?action=refresh'">Refresh status<\/button>\s*<button onclick="window\.location\.href='subminer:\/\/first-run-setup\?action=open-config-settings'">Open SubMiner Settings<\/button>\s*<button class="primary" disabled onclick="window\.location\.href='subminer:\/\/first-run-setup\?action=finish'">Finish setup<\/button>/,
|
||||||
|
);
|
||||||
assert.match(html, /Finish setup/);
|
assert.match(html, /Finish setup/);
|
||||||
assert.match(html, /disabled/);
|
assert.match(html, /disabled/);
|
||||||
assert.match(html, /html,\s*body\s*{\s*min-height:\s*100%;/);
|
assert.match(html, /html,\s*body\s*{\s*min-height:\s*100%;/);
|
||||||
@@ -70,7 +75,7 @@ test('buildFirstRunSetupHtml renders macchiato setup actions and disabled finish
|
|||||||
assert.match(html, /box-sizing:\s*border-box;/);
|
assert.match(html, /box-sizing:\s*border-box;/);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('buildFirstRunSetupHtml switches plugin action to reinstall when already installed', () => {
|
test('buildFirstRunSetupHtml omits bundled mpv plugin readiness when already installed', () => {
|
||||||
const html = buildFirstRunSetupHtml({
|
const html = buildFirstRunSetupHtml({
|
||||||
configReady: true,
|
configReady: true,
|
||||||
dictionaryCount: 1,
|
dictionaryCount: 1,
|
||||||
@@ -94,10 +99,11 @@ test('buildFirstRunSetupHtml switches plugin action to reinstall when already in
|
|||||||
|
|
||||||
assert.doesNotMatch(html, /Reinstall mpv plugin/);
|
assert.doesNotMatch(html, /Reinstall mpv plugin/);
|
||||||
assert.doesNotMatch(html, /action=install-plugin/);
|
assert.doesNotMatch(html, /action=install-plugin/);
|
||||||
|
assert.doesNotMatch(html, /mpv runtime plugin/);
|
||||||
assert.match(html, /mpv executable path/);
|
assert.match(html, /mpv executable path/);
|
||||||
assert.match(html, /Leave blank to auto-discover mpv\.exe from PATH\./);
|
assert.match(html, /Leave blank to auto-discover mpv\.exe from PATH\./);
|
||||||
assert.match(html, /aria-label="Path to mpv\.exe"/);
|
assert.match(html, /aria-label="Path to mpv\.exe"/);
|
||||||
assert.match(html, /SubMiner-managed mpv launches use the bundled runtime plugin\./);
|
assert.doesNotMatch(html, /SubMiner-managed mpv launches use the bundled runtime plugin\./);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('buildFirstRunSetupHtml shows legacy mpv plugin removal action with confirmation', () => {
|
test('buildFirstRunSetupHtml shows legacy mpv plugin removal action with confirmation', () => {
|
||||||
@@ -124,7 +130,8 @@ test('buildFirstRunSetupHtml shows legacy mpv plugin removal action with confirm
|
|||||||
});
|
});
|
||||||
|
|
||||||
assert.match(html, /Legacy mpv plugin/);
|
assert.match(html, /Legacy mpv plugin/);
|
||||||
assert.match(html, /Legacy detected/);
|
assert.doesNotMatch(html, /mpv runtime plugin/);
|
||||||
|
assert.match(html, /Found/);
|
||||||
assert.match(html, /\/tmp\/mpv\/scripts\/subminer/);
|
assert.match(html, /\/tmp\/mpv\/scripts\/subminer/);
|
||||||
assert.match(html, /\/tmp\/mpv\/scripts\/subminer\.lua/);
|
assert.match(html, /\/tmp\/mpv\/scripts\/subminer\.lua/);
|
||||||
assert.match(html, /Remove legacy mpv plugin/);
|
assert.match(html, /Remove legacy mpv plugin/);
|
||||||
@@ -251,6 +258,12 @@ test('parseFirstRunSetupSubmissionUrl parses supported custom actions', () => {
|
|||||||
action: 'remove-legacy-plugin',
|
action: 'remove-legacy-plugin',
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
assert.deepEqual(
|
||||||
|
parseFirstRunSetupSubmissionUrl('subminer://first-run-setup?action=open-config-settings'),
|
||||||
|
{
|
||||||
|
action: 'open-config-settings',
|
||||||
|
},
|
||||||
|
);
|
||||||
assert.equal(
|
assert.equal(
|
||||||
parseFirstRunSetupSubmissionUrl('subminer://first-run-setup?action=skip-plugin'),
|
parseFirstRunSetupSubmissionUrl('subminer://first-run-setup?action=skip-plugin'),
|
||||||
null,
|
null,
|
||||||
@@ -542,6 +555,89 @@ test('opening first-run setup skips rendering if window is destroyed after snaps
|
|||||||
assert.deepEqual(calls, ['set', 'show', 'focus', 'in-progress', 'snapshot']);
|
assert.deepEqual(calls, ['set', 'show', 'focus', 'in-progress', 'snapshot']);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('first-run setup action can skip rerender after launching another window', async () => {
|
||||||
|
const calls: string[] = [];
|
||||||
|
let navigateHandler: ((event: unknown, url: string) => void) | undefined;
|
||||||
|
const handler = createOpenFirstRunSetupWindowHandler({
|
||||||
|
maybeFocusExistingSetupWindow: () => false,
|
||||||
|
createSetupWindow: () =>
|
||||||
|
({
|
||||||
|
webContents: {
|
||||||
|
on: (_event: 'will-navigate', callback: (event: unknown, url: string) => void) => {
|
||||||
|
navigateHandler = callback;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
loadURL: async () => {
|
||||||
|
calls.push('load');
|
||||||
|
},
|
||||||
|
on: () => {},
|
||||||
|
isDestroyed: () => false,
|
||||||
|
close: () => {},
|
||||||
|
show: () => calls.push('show'),
|
||||||
|
focus: () => calls.push('focus'),
|
||||||
|
}) as never,
|
||||||
|
getSetupSnapshot: async () => ({
|
||||||
|
configReady: true,
|
||||||
|
dictionaryCount: 1,
|
||||||
|
canFinish: true,
|
||||||
|
externalYomitanConfigured: false,
|
||||||
|
pluginStatus: 'installed',
|
||||||
|
pluginInstallPathSummary: null,
|
||||||
|
mpvExecutablePath: '',
|
||||||
|
mpvExecutablePathStatus: 'blank',
|
||||||
|
windowsMpvShortcuts: {
|
||||||
|
supported: false,
|
||||||
|
startMenuEnabled: true,
|
||||||
|
desktopEnabled: true,
|
||||||
|
startMenuInstalled: false,
|
||||||
|
desktopInstalled: false,
|
||||||
|
status: 'optional',
|
||||||
|
},
|
||||||
|
commandLineLauncher: createCommandLineLauncherSnapshot(),
|
||||||
|
message: null,
|
||||||
|
}),
|
||||||
|
buildSetupHtml: () => '<html></html>',
|
||||||
|
parseSubmissionUrl: (url) => parseFirstRunSetupSubmissionUrl(url),
|
||||||
|
handleAction: async () => {
|
||||||
|
calls.push('action');
|
||||||
|
return { skipRender: true };
|
||||||
|
},
|
||||||
|
markSetupInProgress: async () => {
|
||||||
|
calls.push('in-progress');
|
||||||
|
},
|
||||||
|
markSetupCancelled: async () => undefined,
|
||||||
|
isSetupCompleted: () => true,
|
||||||
|
shouldQuitWhenClosedIncomplete: () => false,
|
||||||
|
quitApp: () => {},
|
||||||
|
clearSetupWindow: () => {},
|
||||||
|
setSetupWindow: () => {
|
||||||
|
calls.push('set');
|
||||||
|
},
|
||||||
|
encodeURIComponent: (value) => value,
|
||||||
|
logError: () => {},
|
||||||
|
});
|
||||||
|
|
||||||
|
handler();
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||||
|
navigateHandler?.(
|
||||||
|
{ preventDefault: () => calls.push('preventDefault') },
|
||||||
|
'subminer://first-run-setup?action=open-config-settings',
|
||||||
|
);
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||||
|
|
||||||
|
assert.deepEqual(calls, [
|
||||||
|
'set',
|
||||||
|
'show',
|
||||||
|
'focus',
|
||||||
|
'in-progress',
|
||||||
|
'load',
|
||||||
|
'show',
|
||||||
|
'focus',
|
||||||
|
'preventDefault',
|
||||||
|
'action',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
test('closing incomplete first-run setup quits app outside background mode', async () => {
|
test('closing incomplete first-run setup quits app outside background mode', async () => {
|
||||||
const calls: string[] = [];
|
const calls: string[] = [];
|
||||||
let closedHandler: (() => void) | undefined;
|
let closedHandler: (() => void) | undefined;
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ export type FirstRunSetupAction =
|
|||||||
| 'install-bun'
|
| 'install-bun'
|
||||||
| 'install-command-line-launcher'
|
| 'install-command-line-launcher'
|
||||||
| 'open-yomitan-settings'
|
| 'open-yomitan-settings'
|
||||||
|
| 'open-config-settings'
|
||||||
| 'refresh'
|
| 'refresh'
|
||||||
| 'finish';
|
| 'finish';
|
||||||
|
|
||||||
@@ -200,14 +201,6 @@ export function buildFirstRunSetupHtml(model: FirstRunSetupHtmlModel): string {
|
|||||||
legacyMpvPluginPaths.length > 0 && model.canFinish
|
legacyMpvPluginPaths.length > 0 && model.canFinish
|
||||||
? 'Continue without removing'
|
? 'Continue without removing'
|
||||||
: 'Finish setup';
|
: 'Finish setup';
|
||||||
const pluginLabel =
|
|
||||||
legacyMpvPluginPaths.length > 0
|
|
||||||
? 'Legacy detected'
|
|
||||||
: model.pluginStatus === 'failed'
|
|
||||||
? 'Failed'
|
|
||||||
: 'Ready';
|
|
||||||
const pluginTone =
|
|
||||||
legacyMpvPluginPaths.length > 0 ? 'warn' : model.pluginStatus === 'failed' ? 'danger' : 'ready';
|
|
||||||
const windowsShortcutLabel =
|
const windowsShortcutLabel =
|
||||||
model.windowsMpvShortcuts.status === 'installed'
|
model.windowsMpvShortcuts.status === 'installed'
|
||||||
? 'Installed'
|
? 'Installed'
|
||||||
@@ -326,7 +319,7 @@ export function buildFirstRunSetupHtml(model: FirstRunSetupHtmlModel): string {
|
|||||||
: model.canFinish
|
: model.canFinish
|
||||||
? model.externalYomitanConfigured
|
? model.externalYomitanConfigured
|
||||||
? 'Finish stays unlocked while SubMiner is reusing an external Yomitan profile. If you later launch without yomitan.externalProfilePath, setup will require at least one internal dictionary.'
|
? 'Finish stays unlocked while SubMiner is reusing an external Yomitan profile. If you later launch without yomitan.externalProfilePath, setup will require at least one internal dictionary.'
|
||||||
: 'Finish stays unlocked once Yomitan reports at least one installed dictionary. SubMiner-managed mpv launches use the bundled runtime plugin.'
|
: 'Finish stays unlocked once Yomitan reports at least one installed dictionary.'
|
||||||
: 'Finish stays locked until Yomitan reports at least one installed dictionary.';
|
: 'Finish stays locked until Yomitan reports at least one installed dictionary.';
|
||||||
|
|
||||||
return `<!doctype html>
|
return `<!doctype html>
|
||||||
@@ -522,14 +515,6 @@ export function buildFirstRunSetupHtml(model: FirstRunSetupHtmlModel): string {
|
|||||||
</div>
|
</div>
|
||||||
${renderStatusBadge(model.configReady ? 'Ready' : 'Missing', model.configReady ? 'ready' : 'danger')}
|
${renderStatusBadge(model.configReady ? 'Ready' : 'Missing', model.configReady ? 'ready' : 'danger')}
|
||||||
</div>
|
</div>
|
||||||
<div class="card">
|
|
||||||
<div>
|
|
||||||
<strong>mpv runtime plugin</strong>
|
|
||||||
<div class="meta">${escapeHtml(model.pluginInstallPathSummary ?? 'Default mpv scripts location')}</div>
|
|
||||||
<div class="meta">Managed mpv launches use the bundled runtime plugin.</div>
|
|
||||||
</div>
|
|
||||||
${renderStatusBadge(pluginLabel, pluginTone)}
|
|
||||||
</div>
|
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div>
|
<div>
|
||||||
<strong>Yomitan dictionaries</strong>
|
<strong>Yomitan dictionaries</strong>
|
||||||
@@ -544,6 +529,7 @@ export function buildFirstRunSetupHtml(model: FirstRunSetupHtmlModel): string {
|
|||||||
<div class="actions">
|
<div class="actions">
|
||||||
<button onclick="window.location.href='subminer://first-run-setup?action=open-yomitan-settings'">Open Yomitan Settings</button>
|
<button onclick="window.location.href='subminer://first-run-setup?action=open-yomitan-settings'">Open Yomitan Settings</button>
|
||||||
<button class="ghost" onclick="window.location.href='subminer://first-run-setup?action=refresh'">Refresh status</button>
|
<button class="ghost" onclick="window.location.href='subminer://first-run-setup?action=refresh'">Refresh status</button>
|
||||||
|
<button onclick="window.location.href='subminer://first-run-setup?action=open-config-settings'">Open SubMiner Settings</button>
|
||||||
<button class="primary" ${model.canFinish ? '' : 'disabled'} onclick="window.location.href='subminer://first-run-setup?action=finish'">${finishButtonLabel}</button>
|
<button class="primary" ${model.canFinish ? '' : 'disabled'} onclick="window.location.href='subminer://first-run-setup?action=finish'">${finishButtonLabel}</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="message">${model.message ? escapeHtml(model.message) : ''}</div>
|
<div class="message">${model.message ? escapeHtml(model.message) : ''}</div>
|
||||||
@@ -566,6 +552,7 @@ export function parseFirstRunSetupSubmissionUrl(rawUrl: string): FirstRunSetupSu
|
|||||||
action !== 'install-bun' &&
|
action !== 'install-bun' &&
|
||||||
action !== 'install-command-line-launcher' &&
|
action !== 'install-command-line-launcher' &&
|
||||||
action !== 'open-yomitan-settings' &&
|
action !== 'open-yomitan-settings' &&
|
||||||
|
action !== 'open-config-settings' &&
|
||||||
action !== 'refresh' &&
|
action !== 'refresh' &&
|
||||||
action !== 'finish'
|
action !== 'finish'
|
||||||
) {
|
) {
|
||||||
@@ -632,7 +619,9 @@ export function createOpenFirstRunSetupWindowHandler<
|
|||||||
getSetupSnapshot: () => Promise<FirstRunSetupHtmlModel>;
|
getSetupSnapshot: () => Promise<FirstRunSetupHtmlModel>;
|
||||||
buildSetupHtml: (model: FirstRunSetupHtmlModel) => string;
|
buildSetupHtml: (model: FirstRunSetupHtmlModel) => string;
|
||||||
parseSubmissionUrl: (rawUrl: string) => FirstRunSetupSubmission | null;
|
parseSubmissionUrl: (rawUrl: string) => FirstRunSetupSubmission | null;
|
||||||
handleAction: (submission: FirstRunSetupSubmission) => Promise<{ closeWindow?: boolean } | void>;
|
handleAction: (
|
||||||
|
submission: FirstRunSetupSubmission,
|
||||||
|
) => Promise<{ closeWindow?: boolean; skipRender?: boolean } | void>;
|
||||||
markSetupInProgress: () => Promise<unknown>;
|
markSetupInProgress: () => Promise<unknown>;
|
||||||
markSetupCancelled: () => Promise<unknown>;
|
markSetupCancelled: () => Promise<unknown>;
|
||||||
isSetupCompleted: () => boolean;
|
isSetupCompleted: () => boolean;
|
||||||
@@ -680,6 +669,9 @@ export function createOpenFirstRunSetupWindowHandler<
|
|||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (result?.skipRender) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (!setupWindow.isDestroyed()) {
|
if (!setupWindow.isDestroyed()) {
|
||||||
await render();
|
await render();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -144,3 +144,34 @@ test('managed local subtitle selection runtime promotes a single unlabeled exter
|
|||||||
['set_property', 'secondary-sid', 1],
|
['set_property', 'secondary-sid', 1],
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('managed local subtitle selection keeps waiting for primary after early secondary-only track list', () => {
|
||||||
|
const commands: Array<Array<string | number>> = [];
|
||||||
|
const runtime = createManagedLocalSubtitleSelectionRuntime({
|
||||||
|
getCurrentMediaPath: () => '/videos/example.mkv',
|
||||||
|
getMpvClient: () => null,
|
||||||
|
getPrimarySubtitleLanguages: () => [],
|
||||||
|
getSecondarySubtitleLanguages: () => [],
|
||||||
|
sendMpvCommand: (command) => {
|
||||||
|
commands.push(command);
|
||||||
|
},
|
||||||
|
schedule: () => 1 as never,
|
||||||
|
clearScheduled: () => {},
|
||||||
|
});
|
||||||
|
|
||||||
|
runtime.handleMediaPathChange('/videos/example.mkv');
|
||||||
|
runtime.handleSubtitleTrackListChange([
|
||||||
|
{ type: 'sub', id: 1, lang: 'eng', title: 'ASS', external: false },
|
||||||
|
{ type: 'sub', id: 2, lang: 'en', title: 'en.srt', external: true },
|
||||||
|
]);
|
||||||
|
runtime.handleSubtitleTrackListChange([
|
||||||
|
{ type: 'sub', id: 1, lang: 'eng', title: 'ASS', external: false },
|
||||||
|
{ type: 'sub', id: 2, lang: 'en', title: 'en.srt', external: true },
|
||||||
|
{ type: 'sub', id: 3, lang: 'ja', title: 'ja.srt', external: true },
|
||||||
|
]);
|
||||||
|
|
||||||
|
assert.deepEqual(commands, [
|
||||||
|
['set_property', 'secondary-sid', 2],
|
||||||
|
['set_property', 'sid', 3],
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|||||||
@@ -200,7 +200,8 @@ export function createManagedLocalSubtitleSelectionRuntime(deps: {
|
|||||||
}) {
|
}) {
|
||||||
const delayMs = deps.delayMs ?? 400;
|
const delayMs = deps.delayMs ?? 400;
|
||||||
let currentMediaPath: string | null = null;
|
let currentMediaPath: string | null = null;
|
||||||
let appliedMediaPath: string | null = null;
|
let appliedPrimaryMediaPath: string | null = null;
|
||||||
|
let appliedSecondaryMediaPath: string | null = null;
|
||||||
let pendingTimer: ReturnType<typeof setTimeout> | null = null;
|
let pendingTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
|
||||||
const clearPendingTimer = (): void => {
|
const clearPendingTimer = (): void => {
|
||||||
@@ -212,7 +213,11 @@ export function createManagedLocalSubtitleSelectionRuntime(deps: {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const maybeApplySelection = (trackList: unknown[] | null): void => {
|
const maybeApplySelection = (trackList: unknown[] | null): void => {
|
||||||
if (!currentMediaPath || appliedMediaPath === currentMediaPath) {
|
if (
|
||||||
|
!currentMediaPath ||
|
||||||
|
(appliedPrimaryMediaPath === currentMediaPath &&
|
||||||
|
appliedSecondaryMediaPath === currentMediaPath)
|
||||||
|
) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const selection = resolveManagedLocalSubtitleSelection({
|
const selection = resolveManagedLocalSubtitleSelection({
|
||||||
@@ -223,14 +228,17 @@ export function createManagedLocalSubtitleSelectionRuntime(deps: {
|
|||||||
if (!selection.hasPrimaryMatch && !selection.hasSecondaryMatch) {
|
if (!selection.hasPrimaryMatch && !selection.hasSecondaryMatch) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (selection.primaryTrackId !== null) {
|
if (selection.primaryTrackId !== null && appliedPrimaryMediaPath !== currentMediaPath) {
|
||||||
deps.sendMpvCommand(['set_property', 'sid', selection.primaryTrackId]);
|
deps.sendMpvCommand(['set_property', 'sid', selection.primaryTrackId]);
|
||||||
|
appliedPrimaryMediaPath = currentMediaPath;
|
||||||
}
|
}
|
||||||
if (selection.secondaryTrackId !== null) {
|
if (selection.secondaryTrackId !== null && appliedSecondaryMediaPath !== currentMediaPath) {
|
||||||
deps.sendMpvCommand(['set_property', 'secondary-sid', selection.secondaryTrackId]);
|
deps.sendMpvCommand(['set_property', 'secondary-sid', selection.secondaryTrackId]);
|
||||||
|
appliedSecondaryMediaPath = currentMediaPath;
|
||||||
|
}
|
||||||
|
if (appliedPrimaryMediaPath === currentMediaPath) {
|
||||||
|
clearPendingTimer();
|
||||||
}
|
}
|
||||||
appliedMediaPath = currentMediaPath;
|
|
||||||
clearPendingTimer();
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const refreshFromMpv = async (): Promise<void> => {
|
const refreshFromMpv = async (): Promise<void> => {
|
||||||
@@ -252,7 +260,7 @@ export function createManagedLocalSubtitleSelectionRuntime(deps: {
|
|||||||
|
|
||||||
const scheduleRefresh = (): void => {
|
const scheduleRefresh = (): void => {
|
||||||
clearPendingTimer();
|
clearPendingTimer();
|
||||||
if (!currentMediaPath || appliedMediaPath === currentMediaPath) {
|
if (!currentMediaPath || appliedPrimaryMediaPath === currentMediaPath) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
pendingTimer = deps.schedule(() => {
|
pendingTimer = deps.schedule(() => {
|
||||||
@@ -265,7 +273,8 @@ export function createManagedLocalSubtitleSelectionRuntime(deps: {
|
|||||||
handleMediaPathChange: (mediaPath: string | null | undefined): void => {
|
handleMediaPathChange: (mediaPath: string | null | undefined): void => {
|
||||||
const normalizedPath = normalizeLocalMediaPath(mediaPath);
|
const normalizedPath = normalizeLocalMediaPath(mediaPath);
|
||||||
if (normalizedPath !== currentMediaPath) {
|
if (normalizedPath !== currentMediaPath) {
|
||||||
appliedMediaPath = null;
|
appliedPrimaryMediaPath = null;
|
||||||
|
appliedSecondaryMediaPath = null;
|
||||||
}
|
}
|
||||||
currentMediaPath = normalizedPath;
|
currentMediaPath = normalizedPath;
|
||||||
if (!currentMediaPath) {
|
if (!currentMediaPath) {
|
||||||
|
|||||||
@@ -161,3 +161,67 @@ test('main mpv event binder runs mpv-connected callback on connection', () => {
|
|||||||
|
|
||||||
assert.ok(calls.includes('mpv-connected'));
|
assert.ok(calls.includes('mpv-connected'));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('main mpv event binder clears media path on disconnect', () => {
|
||||||
|
const handlers = new Map<string, (payload: unknown) => void>();
|
||||||
|
const calls: string[] = [];
|
||||||
|
|
||||||
|
const bind = createBindMpvMainEventHandlersHandler({
|
||||||
|
reportJellyfinRemoteStopped: () => calls.push('remote-stopped'),
|
||||||
|
syncOverlayMpvSubtitleSuppression: () => calls.push('sync-overlay-mpv-sub'),
|
||||||
|
resetSubtitleSidebarEmbeddedLayout: () => calls.push('reset-sidebar-layout'),
|
||||||
|
hasInitialPlaybackQuitOnDisconnectArg: () => false,
|
||||||
|
isOverlayRuntimeInitialized: () => true,
|
||||||
|
shouldQuitOnDisconnectWhenOverlayRuntimeInitialized: () => false,
|
||||||
|
isQuitOnDisconnectArmed: () => false,
|
||||||
|
scheduleQuitCheck: () => {},
|
||||||
|
isMpvConnected: () => false,
|
||||||
|
quitApp: () => {},
|
||||||
|
|
||||||
|
recordImmersionSubtitleLine: () => {},
|
||||||
|
hasSubtitleTimingTracker: () => false,
|
||||||
|
recordSubtitleTiming: () => {},
|
||||||
|
maybeRunAnilistPostWatchUpdate: async () => {},
|
||||||
|
logSubtitleTimingError: () => {},
|
||||||
|
setCurrentSubText: () => {},
|
||||||
|
broadcastSubtitle: () => {},
|
||||||
|
onSubtitleChange: () => {},
|
||||||
|
refreshDiscordPresence: () => calls.push('presence-refresh'),
|
||||||
|
|
||||||
|
setCurrentSubAssText: () => {},
|
||||||
|
broadcastSubtitleAss: () => {},
|
||||||
|
broadcastSecondarySubtitle: () => {},
|
||||||
|
|
||||||
|
updateCurrentMediaPath: (path) => calls.push(`media-path:${path}`),
|
||||||
|
restoreMpvSubVisibility: () => {},
|
||||||
|
getCurrentAnilistMediaKey: () => null,
|
||||||
|
resetAnilistMediaTracking: () => {},
|
||||||
|
maybeProbeAnilistDuration: () => {},
|
||||||
|
ensureAnilistMediaGuess: () => {},
|
||||||
|
syncImmersionMediaState: () => {},
|
||||||
|
|
||||||
|
updateCurrentMediaTitle: () => {},
|
||||||
|
resetAnilistMediaGuessState: () => {},
|
||||||
|
notifyImmersionTitleUpdate: () => {},
|
||||||
|
|
||||||
|
recordPlaybackPosition: () => {},
|
||||||
|
recordMediaDuration: () => {},
|
||||||
|
reportJellyfinRemoteProgress: () => {},
|
||||||
|
recordPauseState: () => {},
|
||||||
|
|
||||||
|
updateSubtitleRenderMetrics: () => {},
|
||||||
|
setPreviousSecondarySubVisibility: () => {},
|
||||||
|
});
|
||||||
|
|
||||||
|
bind({
|
||||||
|
on: (event, handler) => {
|
||||||
|
handlers.set(event, handler as (payload: unknown) => void);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
handlers.get('connection-change')?.({ connected: false });
|
||||||
|
|
||||||
|
assert.ok(calls.includes('media-path:'));
|
||||||
|
assert.ok(calls.includes('remote-stopped'));
|
||||||
|
assert.ok(calls.includes('presence-refresh'));
|
||||||
|
});
|
||||||
|
|||||||
@@ -101,6 +101,8 @@ export function createBindMpvMainEventHandlersHandler(deps: {
|
|||||||
}): void => {
|
}): void => {
|
||||||
if (connected) {
|
if (connected) {
|
||||||
deps.resetSubtitleSidebarEmbeddedLayout();
|
deps.resetSubtitleSidebarEmbeddedLayout();
|
||||||
|
} else {
|
||||||
|
deps.updateCurrentMediaPath('');
|
||||||
}
|
}
|
||||||
handleMpvConnectionChange({ connected });
|
handleMpvConnectionChange({ connected });
|
||||||
};
|
};
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user